Coverage for src/prosemark/app/remove_node.py: 100%
46 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"""RemoveNode use case for removing nodes from the binder."""
3from pathlib import Path
4from typing import TYPE_CHECKING
6from prosemark.domain.models import BinderItem, NodeId
7from prosemark.exceptions import FileSystemError, 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
13 from prosemark.ports.node_repo import NodeRepo
16class RemoveNode:
17 """Remove nodes from the binder hierarchy."""
19 def __init__(
20 self,
21 *,
22 binder_repo: 'BinderRepo',
23 node_repo: 'NodeRepo',
24 console: 'ConsolePort',
25 logger: 'Logger',
26 ) -> None:
27 """Initialize the RemoveNode use case.
29 Args:
30 binder_repo: Repository for binder operations.
31 node_repo: Repository for node operations.
32 console: Console output port.
33 logger: Logger port.
35 """
36 self.binder_repo = binder_repo
37 self.node_repo = node_repo
38 self.console = console
39 self.logger = logger
41 def execute(
42 self,
43 *,
44 node_id: NodeId,
45 keep_children: bool = False,
46 delete_files: bool = False,
47 project_path: Path | None = None,
48 ) -> None:
49 """Remove a node from the binder.
51 Args:
52 node_id: ID of the node to remove.
53 keep_children: If True, promote children to parent level.
54 If False, remove entire subtree.
55 delete_files: If True, delete the node's .md and .notes.md files.
56 project_path: Project directory path.
58 Raises:
59 NodeNotFoundError: If the node to remove is not found.
61 """
62 project_path = project_path or Path.cwd()
63 self.logger.info('Removing node: %s', node_id.value)
65 # Load existing binder
66 binder = self.binder_repo.load()
68 # Find the node's parent and position
69 parent_items, position = self._find_parent_and_position(binder.roots, node_id)
70 if parent_items is None:
71 msg = f'Node {node_id.value} not found in binder'
72 raise NodeNotFoundError(msg)
74 # Get the item to remove
75 item_to_remove = parent_items[position]
77 # Handle children based on keep_children flag
78 if keep_children:
79 # Promote children to parent level
80 for i, child in enumerate(item_to_remove.children):
81 parent_items.insert(position + 1 + i, child)
83 # Remove the item
84 parent_items.pop(position)
86 # Save updated binder
87 self.binder_repo.save(binder)
89 # Delete files if requested
90 if delete_files:
91 try:
92 self.node_repo.delete(node_id, delete_files=True)
93 self.console.print_info(f'Deleted files: {node_id.value}.md, {node_id.value}.notes.md')
94 except FileSystemError as e:
95 self.console.print_warning(f'Could not delete files: {e}')
97 self.console.print_success(f'Removed node {node_id.value} from binder')
98 if keep_children:
99 self.console.print_info('Children promoted to parent level')
100 elif item_to_remove.children:
101 self.console.print_info(f'Removed {len(item_to_remove.children)} child nodes')
102 self.logger.info('Node removed: %s', node_id.value)
104 def _find_parent_and_position(
105 self,
106 items: list[BinderItem],
107 node_id: NodeId,
108 parent: list[BinderItem] | None = None,
109 ) -> tuple[list[BinderItem] | None, int]:
110 """Find the parent list and position of a node.
112 Args:
113 items: List of binder items to search.
114 node_id: Node ID to find.
115 parent: Parent list (used for recursion).
117 Returns:
118 Tuple of (parent_list, position) if found, (None, -1) otherwise.
120 """
121 if parent is None:
122 parent = items
124 for i, item in enumerate(items):
125 if item.node_id and item.node_id.value == node_id.value:
126 return parent, i
127 found_parent, found_pos = self._find_parent_and_position(item.children, node_id, item.children)
128 if found_parent is not None:
129 return found_parent, found_pos
130 return None, -1