Coverage for src/prosemark/app/move_node.py: 100%

52 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +0000

1"""MoveNode use case for reorganizing nodes in the binder hierarchy.""" 

2 

3from pathlib import Path 

4from typing import TYPE_CHECKING 

5 

6from prosemark.domain.models import BinderItem, NodeId 

7from prosemark.exceptions import NodeNotFoundError 

8 

9if TYPE_CHECKING: # pragma: no cover 

10 from prosemark.ports.binder_repo import BinderRepo 

11 from prosemark.ports.console_port import ConsolePort 

12 from prosemark.ports.logger import Logger 

13 

14 

15class MoveNode: 

16 """Move nodes within the binder hierarchy.""" 

17 

18 def __init__( 

19 self, 

20 *, 

21 binder_repo: 'BinderRepo', 

22 console: 'ConsolePort', 

23 logger: 'Logger', 

24 ) -> None: 

25 """Initialize the MoveNode use case. 

26 

27 Args: 

28 binder_repo: Repository for binder operations. 

29 console: Console output port. 

30 logger: Logger port. 

31 

32 """ 

33 self.binder_repo = binder_repo 

34 self.console = console 

35 self.logger = logger 

36 

37 def execute( 

38 self, 

39 *, 

40 node_id: NodeId, 

41 parent_id: NodeId | None = None, 

42 position: int | None = None, 

43 project_path: Path | None = None, 

44 ) -> None: 

45 """Move a node to a new position in the hierarchy. 

46 

47 Args: 

48 node_id: ID of the node to move. 

49 parent_id: Optional new parent node ID (None for root level). 

50 position: Optional position within parent's children. 

51 project_path: Project directory path. 

52 

53 Raises: 

54 NodeNotFoundError: If the node to move is not found. 

55 

56 """ 

57 project_path = project_path or Path.cwd() 

58 self.logger.info('Moving node: %s', node_id.value) 

59 

60 # Load existing binder 

61 binder = self.binder_repo.load() 

62 

63 # Find and remove the node from its current position 

64 item_to_move = self._remove_item(binder.roots, node_id) 

65 if not item_to_move: 

66 msg = f'Node {node_id.value} not found in binder' 

67 raise NodeNotFoundError(msg) 

68 

69 # Add the node to its new position 

70 if parent_id: 

71 # Find new parent and add as child 

72 parent_item = self._find_item(binder.roots, parent_id) 

73 if not parent_item: 

74 # Restore item to original position and fail 

75 binder.roots.append(item_to_move) 

76 msg = f'Parent node {parent_id.value} not found' 

77 raise NodeNotFoundError(msg) 

78 

79 # Check for circular reference 

80 if self._would_create_cycle(item_to_move, parent_id): # pragma: no cover 

81 # Restore item to original position and fail # pragma: no cover 

82 binder.roots.append(item_to_move) # pragma: no cover 

83 self.console.print_error( 

84 f'Cannot move {node_id.value}: would create circular reference', 

85 ) # pragma: no cover 

86 return # pragma: no cover 

87 

88 if position is not None and 0 <= position <= len(parent_item.children): 

89 parent_item.children.insert(position, item_to_move) 

90 else: 

91 parent_item.children.append(item_to_move) 

92 # Add as root item 

93 elif position is not None and 0 <= position <= len(binder.roots): 

94 binder.roots.insert(position, item_to_move) 

95 else: 

96 binder.roots.append(item_to_move) 

97 

98 # Save updated binder 

99 self.binder_repo.save(binder) 

100 

101 self.console.print_success(f'Moved node {node_id.value}') 

102 if parent_id: 

103 self.console.print_info(f'New parent: {parent_id.value}') 

104 else: 

105 self.console.print_info('Moved to root level') 

106 self.logger.info('Node moved: %s', node_id.value) 

107 

108 def _find_item(self, items: list[BinderItem], node_id: NodeId) -> BinderItem | None: 

109 """Find an item by node ID in the hierarchy. 

110 

111 Args: 

112 items: List of binder items to search. 

113 node_id: Node ID to find. 

114 

115 Returns: 

116 The binder item if found, None otherwise. 

117 

118 """ 

119 for item in items: 

120 if item.node_id and item.node_id.value == node_id.value: 

121 return item 

122 found = self._find_item(item.children, node_id) 

123 if found: # pragma: no cover 

124 return found # pragma: no cover 

125 return None 

126 

127 def _remove_item(self, items: list[BinderItem], node_id: NodeId) -> BinderItem | None: 

128 """Remove and return an item from the hierarchy. 

129 

130 Args: 

131 items: List of binder items to search. 

132 node_id: Node ID to remove. 

133 

134 Returns: 

135 The removed item if found, None otherwise. 

136 

137 """ 

138 for item in items: 

139 if item.node_id and item.node_id.value == node_id.value: 

140 items.remove(item) 

141 return item 

142 removed = self._remove_item(item.children, node_id) 

143 if removed: 

144 return removed 

145 return None 

146 

147 def _would_create_cycle(self, item: BinderItem, parent_id: NodeId) -> bool: 

148 """Check if moving an item would create a cycle. 

149 

150 Args: 

151 item: The item being moved. 

152 parent_id: The proposed new parent ID. 

153 

154 Returns: 

155 True if this would create a cycle, False otherwise. 

156 

157 """ 

158 # Check if the parent is a descendant of the item being moved 

159 return self._find_item([item], parent_id) is not None