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

1"""RemoveNode use case for removing nodes from the binder.""" 

2 

3from pathlib import Path 

4from typing import TYPE_CHECKING 

5 

6from prosemark.domain.models import BinderItem, NodeId 

7from prosemark.exceptions import FileSystemError, 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 from prosemark.ports.node_repo import NodeRepo 

14 

15 

16class RemoveNode: 

17 """Remove nodes from the binder hierarchy.""" 

18 

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. 

28 

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. 

34 

35 """ 

36 self.binder_repo = binder_repo 

37 self.node_repo = node_repo 

38 self.console = console 

39 self.logger = logger 

40 

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. 

50 

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. 

57 

58 Raises: 

59 NodeNotFoundError: If the node to remove is not found. 

60 

61 """ 

62 project_path = project_path or Path.cwd() 

63 self.logger.info('Removing node: %s', node_id.value) 

64 

65 # Load existing binder 

66 binder = self.binder_repo.load() 

67 

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) 

73 

74 # Get the item to remove 

75 item_to_remove = parent_items[position] 

76 

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) 

82 

83 # Remove the item 

84 parent_items.pop(position) 

85 

86 # Save updated binder 

87 self.binder_repo.save(binder) 

88 

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}') 

96 

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) 

103 

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. 

111 

112 Args: 

113 items: List of binder items to search. 

114 node_id: Node ID to find. 

115 parent: Parent list (used for recursion). 

116 

117 Returns: 

118 Tuple of (parent_list, position) if found, (None, -1) otherwise. 

119 

120 """ 

121 if parent is None: 

122 parent = items 

123 

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