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
« 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."""
3from pathlib import Path
4from typing import TYPE_CHECKING
6from prosemark.domain.models import BinderItem, NodeId
7from prosemark.exceptions import NodeNotFoundError
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
15class MoveNode:
16 """Move nodes within the binder hierarchy."""
18 def __init__(
19 self,
20 *,
21 binder_repo: 'BinderRepo',
22 console: 'ConsolePort',
23 logger: 'Logger',
24 ) -> None:
25 """Initialize the MoveNode use case.
27 Args:
28 binder_repo: Repository for binder operations.
29 console: Console output port.
30 logger: Logger port.
32 """
33 self.binder_repo = binder_repo
34 self.console = console
35 self.logger = logger
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.
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.
53 Raises:
54 NodeNotFoundError: If the node to move is not found.
56 """
57 project_path = project_path or Path.cwd()
58 self.logger.info('Moving node: %s', node_id.value)
60 # Load existing binder
61 binder = self.binder_repo.load()
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)
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)
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
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)
98 # Save updated binder
99 self.binder_repo.save(binder)
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)
108 def _find_item(self, items: list[BinderItem], node_id: NodeId) -> BinderItem | None:
109 """Find an item by node ID in the hierarchy.
111 Args:
112 items: List of binder items to search.
113 node_id: Node ID to find.
115 Returns:
116 The binder item if found, None otherwise.
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
127 def _remove_item(self, items: list[BinderItem], node_id: NodeId) -> BinderItem | None:
128 """Remove and return an item from the hierarchy.
130 Args:
131 items: List of binder items to search.
132 node_id: Node ID to remove.
134 Returns:
135 The removed item if found, None otherwise.
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
147 def _would_create_cycle(self, item: BinderItem, parent_id: NodeId) -> bool:
148 """Check if moving an item would create a cycle.
150 Args:
151 item: The item being moved.
152 parent_id: The proposed new parent ID.
154 Returns:
155 True if this would create a cycle, False otherwise.
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