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

499 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-28 19:17 +0000

1"""Use case interactors for prosemark application layer.""" 

2 

3import json 

4from dataclasses import dataclass, field 

5from pathlib import Path 

6from typing import TYPE_CHECKING 

7 

8from prosemark.domain.models import Binder, BinderItem, NodeId 

9from prosemark.exceptions import ( 

10 AlreadyMaterializedError, 

11 BinderIntegrityError, 

12 EditorLaunchError, 

13 FileSystemError, 

14 NodeIdentityError, 

15 NodeNotFoundError, 

16 PlaceholderNotFoundError, 

17) 

18 

19if TYPE_CHECKING: # pragma: no cover 

20 from prosemark.ports.binder_repo import BinderRepo 

21 from prosemark.ports.clock import Clock 

22 from prosemark.ports.config_port import ConfigPort 

23 from prosemark.ports.console_port import ConsolePort 

24 from prosemark.ports.daily_repo import DailyRepo 

25 from prosemark.ports.editor_port import EditorPort 

26 from prosemark.ports.id_generator import IdGenerator 

27 from prosemark.ports.logger import Logger 

28 from prosemark.ports.node_repo import NodeRepo 

29 

30 

31@dataclass(frozen=True) 

32class PlaceholderIssue: 

33 """Represents a placeholder item found during audit.""" 

34 

35 display_title: str 

36 position: str # Human-readable position like "[0][1]" 

37 

38 

39@dataclass(frozen=True) 

40class MissingIssue: 

41 """Represents a missing node file found during audit.""" 

42 

43 node_id: NodeId 

44 expected_path: str 

45 

46 

47@dataclass(frozen=True) 

48class OrphanIssue: 

49 """Represents an orphaned node file found during audit.""" 

50 

51 node_id: NodeId 

52 file_path: str 

53 

54 

55@dataclass(frozen=True) 

56class MismatchIssue: 

57 """Represents a frontmatter ID mismatch found during audit.""" 

58 

59 file_path: str 

60 expected_id: NodeId 

61 actual_id: NodeId 

62 

63 

64@dataclass 

65class AuditReport: 

66 """Contains the results of a binder audit operation.""" 

67 

68 placeholders: list[PlaceholderIssue] = field(default_factory=list) 

69 missing: list[MissingIssue] = field(default_factory=list) 

70 orphans: list[OrphanIssue] = field(default_factory=list) 

71 mismatches: list[MismatchIssue] = field(default_factory=list) 

72 

73 def is_clean(self) -> bool: 

74 """Check if the audit found no issues. 

75 

76 Placeholders are not considered errors - they are informational items 

77 that indicate planned content without actual implementation. 

78 

79 Returns: 

80 True if no issues were found, False otherwise 

81 

82 """ 

83 return not self.missing and not self.orphans and not self.mismatches 

84 

85 def format_report(self) -> str: 

86 """Format the audit results as a human-readable report. 

87 

88 Returns: 

89 Formatted string report with issues organized by type 

90 

91 """ 

92 if self.is_clean() and not self.placeholders: 

93 return 'Audit Results:\n============\n✓ Clean (no issues found)' 

94 

95 lines = ['Issues Found:' if not self.is_clean() else 'Audit Results:', '============'] 

96 

97 if self.placeholders: 

98 lines.append(f'PLACEHOLDERS ({len(self.placeholders)}):') 

99 lines.extend( 

100 f' - "{placeholder.display_title}" at position {placeholder.position}' 

101 for placeholder in self.placeholders 

102 ) 

103 lines.append('') 

104 

105 if self.missing: 

106 lines.append(f'MISSING ({len(self.missing)}):') 

107 lines.extend( 

108 f' - Node {missing.expected_path} referenced in binder but file missing' for missing in self.missing 

109 ) 

110 lines.append('') 

111 

112 if self.orphans: 

113 lines.append(f'ORPHANS ({len(self.orphans)}):') 

114 lines.extend(f' - Node {orphan.file_path} exists but not in binder' for orphan in self.orphans) 

115 lines.append('') 

116 

117 if self.mismatches: 

118 lines.append(f'MISMATCHES ({len(self.mismatches)}):') 

119 lines.extend( 

120 f' - File {mismatch.file_path} has frontmatter id: {mismatch.actual_id}' 

121 for mismatch in self.mismatches 

122 ) 

123 lines.append('') 

124 

125 return '\n'.join(lines).rstrip() 

126 

127 def to_json(self) -> str: 

128 """Convert audit results to JSON format. 

129 

130 Returns: 

131 JSON string representation of the audit results 

132 

133 """ 

134 data = { 

135 'placeholders': [ 

136 { 

137 'display_title': p.display_title, 

138 'position': p.position, 

139 } 

140 for p in self.placeholders 

141 ], 

142 'missing': [ 

143 { 

144 'node_id': str(m.node_id), 

145 'expected_path': m.expected_path, 

146 } 

147 for m in self.missing 

148 ], 

149 'orphans': [ 

150 { 

151 'node_id': str(o.node_id), 

152 'file_path': o.file_path, 

153 } 

154 for o in self.orphans 

155 ], 

156 'mismatches': [ 

157 { 

158 'file_path': m.file_path, 

159 'expected_id': str(m.expected_id), 

160 'actual_id': str(m.actual_id), 

161 } 

162 for m in self.mismatches 

163 ], 

164 } 

165 return json.dumps(data, indent=2) 

166 

167 

168class InitProject: 

169 """Use case interactor for initializing a new prosemark project. 

170 

171 Orchestrates the creation of a new prosemark project by setting up 

172 the necessary file structure, configuration, and initial binder state. 

173 Follows hexagonal architecture principles with pure business logic 

174 that delegates all I/O operations to injected port implementations. 

175 

176 The initialization process: 

177 1. Validates the target directory is suitable for project creation 

178 2. Checks for existing project files to prevent conflicts 

179 3. Creates an empty binder structure with proper managed blocks 

180 4. Generates default configuration file (.prosemark.yml) 

181 5. Logs operational details and provides user feedback 

182 

183 Args: 

184 binder_repo: Port for binder persistence operations 

185 config_port: Port for configuration file management 

186 console_port: Port for user output and messaging 

187 logger: Port for operational logging and audit trails 

188 clock: Port for timestamp generation 

189 

190 Examples: 

191 >>> # With dependency injection 

192 >>> interactor = InitProject( 

193 ... binder_repo=file_binder_repo, 

194 ... config_port=yaml_config_port, 

195 ... console_port=terminal_console, 

196 ... logger=production_logger, 

197 ... clock=system_clock, 

198 ... ) 

199 >>> interactor.execute(Path('/path/to/new/project')) 

200 

201 """ 

202 

203 def __init__( 

204 self, 

205 binder_repo: 'BinderRepo', 

206 config_port: 'ConfigPort', 

207 console_port: 'ConsolePort', 

208 logger: 'Logger', 

209 clock: 'Clock', 

210 ) -> None: 

211 """Initialize InitProject with injected dependencies. 

212 

213 Args: 

214 binder_repo: Port for binder persistence operations 

215 config_port: Port for configuration file management 

216 console_port: Port for user output and messaging 

217 logger: Port for operational logging and audit trails 

218 clock: Port for timestamp generation 

219 

220 """ 

221 self._binder_repo = binder_repo 

222 self._config_port = config_port 

223 self._console_port = console_port 

224 self._logger = logger 

225 self._clock = clock 

226 

227 def execute(self, project_path: Path) -> None: 

228 """Execute project initialization workflow. 

229 

230 Creates a new prosemark project at the specified path with default 

231 configuration and empty binder structure. Validates that the target 

232 directory doesn't already contain a prosemark project. 

233 

234 Args: 

235 project_path: Directory where project should be initialized 

236 

237 Raises: 

238 BinderIntegrityError: If project is already initialized (_binder.md exists) 

239 FileSystemError: If files cannot be created (propagated from ports) 

240 

241 """ 

242 self._logger.info('Starting project initialization at %s', project_path) 

243 

244 # Validation Phase - Check for existing project 

245 binder_path = project_path / '_binder.md' 

246 config_path = project_path / '.prosemark.yml' 

247 

248 if binder_path.exists(): 

249 self._logger.error('Project initialization failed: project already exists at %s', binder_path) 

250 msg = 'Project already initialized' 

251 raise BinderIntegrityError(msg, str(binder_path)) 

252 

253 self._logger.debug('Validation passed: no existing project found') 

254 

255 # Creation Phase - Set up project structure 

256 self._clock.now_iso() 

257 self._create_initial_binder() 

258 self._create_default_config(config_path) 

259 

260 # User Feedback - Confirm successful initialization 

261 self._console_port.print(f'Initialized prosemark project at {project_path}') 

262 self._logger.info('Project initialization completed successfully at %s', project_path) 

263 

264 def _create_initial_binder(self) -> None: 

265 """Create initial empty binder structure. 

266 

267 Creates a new Binder aggregate with empty roots list and saves it 

268 through the binder repository. This establishes the foundational 

269 hierarchy structure for the project. 

270 

271 """ 

272 self._logger.debug('Creating initial empty binder structure') 

273 initial_binder = Binder(roots=[]) 

274 self._binder_repo.save(initial_binder) 

275 self._logger.info('Initial binder structure created and saved') 

276 

277 def _create_default_config(self, config_path: Path) -> None: 

278 """Create default configuration file. 

279 

280 Delegates configuration file creation to the config port, which 

281 handles the specific format and default values according to the 

282 MVP specification. 

283 

284 Args: 

285 config_path: Path where configuration file should be created 

286 

287 """ 

288 self._logger.debug('Creating default configuration at %s', config_path) 

289 self._config_port.create_default_config(config_path) 

290 self._logger.info('Default configuration created at %s', config_path) 

291 

292 

293class AddNode: 

294 """Use case interactor for adding new nodes to the binder structure. 

295 

296 Orchestrates the creation of new nodes by generating unique identifiers, 

297 creating node files with proper frontmatter, and updating the binder 

298 hierarchy. Follows hexagonal architecture principles with pure business 

299 logic that delegates all I/O operations to injected port implementations. 

300 

301 The node creation process: 

302 1. Generates unique NodeId for the new node 

303 2. Creates node draft file ({id}.md) with YAML frontmatter 

304 3. Creates node notes file ({id}.notes.md) as empty file 

305 4. Validates parent node exists when specified 

306 5. Adds BinderItem to binder structure at specified position 

307 6. Updates and saves binder changes to _binder.md 

308 7. Logs all operations with NodeId for traceability 

309 

310 Args: 

311 binder_repo: Port for binder persistence operations 

312 node_repo: Port for node file creation and management 

313 id_generator: Port for generating unique NodeId values 

314 logger: Port for operational logging and audit trails 

315 clock: Port for timestamp generation 

316 

317 Examples: 

318 >>> # With dependency injection 

319 >>> interactor = AddNode( 

320 ... binder_repo=file_binder_repo, 

321 ... node_repo=file_node_repo, 

322 ... id_generator=uuid_generator, 

323 ... logger=production_logger, 

324 ... clock=system_clock, 

325 ... ) 

326 >>> node_id = interactor.execute(title='Chapter One', synopsis='The beginning', parent_id=None, position=None) 

327 

328 """ 

329 

330 def __init__( 

331 self, 

332 binder_repo: 'BinderRepo', 

333 node_repo: 'NodeRepo', 

334 id_generator: 'IdGenerator', 

335 logger: 'Logger', 

336 clock: 'Clock', 

337 ) -> None: 

338 """Initialize AddNode with injected dependencies. 

339 

340 Args: 

341 binder_repo: Port for binder persistence operations 

342 node_repo: Port for node file creation and management 

343 id_generator: Port for generating unique NodeId values 

344 logger: Port for operational logging and audit trails 

345 clock: Port for timestamp generation 

346 

347 """ 

348 self._binder_repo = binder_repo 

349 self._node_repo = node_repo 

350 self._id_generator = id_generator 

351 self._logger = logger 

352 self._clock = clock 

353 

354 def execute( 

355 self, 

356 title: str | None, 

357 synopsis: str | None, 

358 parent_id: NodeId | None, 

359 position: int | None, 

360 ) -> NodeId: 

361 """Execute node creation workflow. 

362 

363 Creates a new node with the specified metadata and adds it to the 

364 binder hierarchy. The node is added at the root level if no parent 

365 is specified, or under the specified parent node. 

366 

367 Args: 

368 title: Optional title for the node (used as display_title) 

369 synopsis: Optional synopsis/summary for the node 

370 parent_id: Optional parent NodeId for nested placement 

371 position: Optional position for insertion order (None = append) 

372 

373 Returns: 

374 NodeId of the created node 

375 

376 Raises: 

377 NodeNotFoundError: If specified parent_id doesn't exist in binder 

378 BinderIntegrityError: If binder integrity is violated after addition 

379 FileSystemError: If node files cannot be created (propagated from ports) 

380 

381 """ 

382 self._logger.info('Starting node creation with title=%s, parent_id=%s', title, parent_id) 

383 

384 # Generation Phase - Create unique identity 

385 node_id = self._id_generator.new() 

386 self._logger.debug('Generated new NodeId: %s', node_id) 

387 

388 # Creation Phase - Set up node files with proper metadata 

389 self._clock.now_iso() 

390 self._node_repo.create(node_id, title, synopsis) 

391 self._logger.debug('Created node files for NodeId: %s', node_id) 

392 

393 # Integration Phase - Add to binder structure 

394 binder = self._binder_repo.load() 

395 self._add_node_to_binder(binder, node_id, title, parent_id, position) 

396 self._binder_repo.save(binder) 

397 self._logger.debug('Added node to binder and saved changes for NodeId: %s', node_id) 

398 

399 # Completion 

400 self._logger.info('Node creation completed successfully for NodeId: %s', node_id) 

401 return node_id 

402 

403 def _add_node_to_binder( 

404 self, 

405 binder: Binder, 

406 node_id: NodeId, 

407 title: str | None, 

408 parent_id: NodeId | None, 

409 position: int | None, 

410 ) -> None: 

411 """Add the new node to the binder hierarchy. 

412 

413 Creates a BinderItem for the node and adds it to the appropriate 

414 location in the binder tree structure. 

415 

416 Args: 

417 binder: Binder instance to modify 

418 node_id: NodeId of the new node 

419 title: Title to use as display_title (or empty string if None) 

420 parent_id: Optional parent NodeId for nested placement 

421 position: Optional position for insertion order 

422 

423 Raises: 

424 NodeNotFoundError: If parent_id is specified but doesn't exist 

425 

426 """ 

427 # Create BinderItem for the new node 

428 display_title = title if title is not None else '(untitled)' 

429 new_item = BinderItem(display_title=display_title, node_id=node_id, children=[]) 

430 

431 if parent_id is None: 

432 # Add to root level 

433 self._logger.debug('Adding node to binder roots for NodeId: %s', node_id) 

434 if position is None: 

435 binder.roots.append(new_item) 

436 else: 

437 binder.roots.insert(position, new_item) 

438 else: 

439 # Add under specified parent 

440 self._logger.debug('Adding node under parent %s for NodeId: %s', parent_id, node_id) 

441 parent_item = binder.find_by_id(parent_id) 

442 if parent_item is None: 

443 self._logger.error('Parent node not found in binder: %s', parent_id) 

444 msg = 'Parent node not found' 

445 raise NodeNotFoundError(msg, str(parent_id)) 

446 

447 if position is None: 

448 parent_item.children.append(new_item) 

449 else: 

450 parent_item.children.insert(position, new_item) 

451 

452 # Validate binder integrity after modification 

453 binder.validate_integrity() # pragma: no cover 

454 

455 

456class EditPart: 

457 """Use case interactor for editing node parts in external editor. 

458 

459 Orchestrates the opening of node parts (draft, notes, synopsis) in the 

460 configured external editor. Follows hexagonal architecture principles 

461 with pure business logic that delegates all I/O operations to injected 

462 port implementations. 

463 

464 The edit process: 

465 1. Validates that the specified node exists in the binder 

466 2. Validates that the requested part is valid (draft, notes, synopsis) 

467 3. Opens the appropriate file part in the external editor 

468 4. Logs the editor operation for traceability 

469 

470 Args: 

471 binder_repo: Port for binder persistence operations (validation) 

472 node_repo: Port for node file operations and editor integration 

473 logger: Port for operational logging and audit trails 

474 

475 Examples: 

476 >>> # With dependency injection 

477 >>> interactor = EditPart( 

478 ... binder_repo=file_binder_repo, 

479 ... node_repo=file_node_repo, 

480 ... logger=production_logger, 

481 ... ) 

482 >>> interactor.execute(node_id=node_id, part='draft') 

483 

484 """ 

485 

486 def __init__( 

487 self, 

488 binder_repo: 'BinderRepo', 

489 node_repo: 'NodeRepo', 

490 logger: 'Logger', 

491 ) -> None: 

492 """Initialize EditPart with injected dependencies. 

493 

494 Args: 

495 binder_repo: Port for binder persistence operations (validation) 

496 node_repo: Port for node file operations and editor integration 

497 logger: Port for operational logging and audit trails 

498 

499 """ 

500 self._binder_repo = binder_repo 

501 self._node_repo = node_repo 

502 self._logger = logger 

503 

504 def execute(self, node_id: NodeId, part: str) -> None: 

505 """Execute part editing workflow. 

506 

507 Opens the specified part of the node in the external editor. 

508 Validates that both the node and part are valid before proceeding. 

509 

510 Args: 

511 node_id: NodeId of the node to edit 

512 part: Which part to edit - must be one of: 

513 - 'draft': Edit the main content in {id}.md 

514 - 'notes': Edit the notes in {id}.notes.md 

515 - 'synopsis': Edit the synopsis field in {id}.md frontmatter 

516 

517 Raises: 

518 NodeNotFoundError: If node_id doesn't exist in binder 

519 ValueError: If part is not a valid option 

520 FileSystemError: If editor cannot be launched or files don't exist 

521 

522 """ 

523 self._logger.info('Starting edit operation for NodeId: %s, part: %s', node_id, part) 

524 

525 # Validation Phase - Check node exists in binder 

526 binder = self._binder_repo.load() 

527 target_item = binder.find_by_id(node_id) 

528 if target_item is None: 

529 self._logger.error('Node not found in binder: %s', node_id) 

530 msg = 'Node not found in binder' 

531 raise NodeNotFoundError(msg, str(node_id)) 

532 

533 # Validation Phase - Check part is valid 

534 valid_parts = {'draft', 'notes', 'synopsis'} 

535 if part not in valid_parts: 

536 self._logger.error('Invalid part specified: %s (valid: %s)', part, valid_parts) 

537 msg = f'Invalid part: {part}. Must be one of: {", ".join(sorted(valid_parts))}' 

538 raise ValueError(msg) 

539 

540 self._logger.debug('Validation passed: node exists and part is valid') 

541 

542 # Editor Launch Phase - Open file in external editor 

543 self._logger.debug('Opening %s part of node %s in editor', part, node_id) 

544 self._node_repo.open_in_editor(node_id, part) 

545 

546 self._logger.info('Edit operation completed successfully for NodeId: %s, part: %s', node_id, part) 

547 

548 

549class MoveNode: 

550 """Use case interactor for moving nodes within the binder hierarchy. 

551 

552 Orchestrates the movement of existing nodes by updating the binder 

553 structure while preserving node identity and files. Follows hexagonal 

554 architecture principles with pure business logic that delegates all I/O 

555 operations to injected port implementations. 

556 

557 The node movement process: 

558 1. Validates source node exists in binder hierarchy 

559 2. Validates target parent exists when specified 

560 3. Checks for circular dependencies using ancestor traversal 

561 4. Removes node from current location in binder tree 

562 5. Adds node to new location at specified position 

563 6. Updates and saves binder changes to _binder.md 

564 7. Logs all operations with NodeId details for traceability 

565 

566 Node files remain unchanged during move operations - only the binder 

567 hierarchy structure is modified. 

568 

569 Args: 

570 binder_repo: Port for binder persistence operations 

571 logger: Port for operational logging and audit trails 

572 

573 Examples: 

574 >>> # With dependency injection 

575 >>> interactor = MoveNode( 

576 ... binder_repo=file_binder_repo, 

577 ... logger=production_logger, 

578 ... ) 

579 >>> interactor.execute(node_id=node_id, parent_id=new_parent_id, position=0) 

580 

581 """ 

582 

583 def __init__( 

584 self, 

585 binder_repo: 'BinderRepo', 

586 logger: 'Logger', 

587 ) -> None: 

588 """Initialize MoveNode with injected dependencies. 

589 

590 Args: 

591 binder_repo: Port for binder persistence operations 

592 logger: Port for operational logging and audit trails 

593 

594 """ 

595 self._binder_repo = binder_repo 

596 self._logger = logger 

597 

598 def execute( 

599 self, 

600 node_id: NodeId, 

601 parent_id: NodeId | None, 

602 position: int | None, 

603 ) -> None: 

604 """Execute node movement workflow. 

605 

606 Moves the specified node to a new location in the binder hierarchy. 

607 The node is moved to the root level if no parent is specified, or 

608 under the specified parent node at the given position. 

609 

610 Args: 

611 node_id: NodeId of the node to move 

612 parent_id: Optional target parent NodeId (None = move to root) 

613 position: Optional position for insertion order (None = append) 

614 

615 Raises: 

616 NodeNotFoundError: If node_id or parent_id doesn't exist in binder 

617 BinderIntegrityError: If move would create circular dependency 

618 FileSystemError: If binder file cannot be saved (propagated from ports) 

619 

620 """ 

621 self._logger.info( 

622 'Starting move node operation for NodeId: %s to parent: %s position: %s', 

623 node_id, 

624 parent_id, 

625 position, 

626 ) 

627 

628 # Load and validate binder structure 

629 binder = self._binder_repo.load() 

630 self._logger.debug('Validating source and target nodes') 

631 

632 # Validate source node exists 

633 source_item = binder.find_by_id(node_id) 

634 if source_item is None: 

635 self._logger.error('Source node not found in binder: %s', node_id) 

636 msg = 'Source node not found in binder' 

637 raise NodeNotFoundError(msg, str(node_id)) 

638 

639 # Validate target parent exists (if specified) 

640 if parent_id is not None: 

641 target_parent = binder.find_by_id(parent_id) 

642 if target_parent is None: 

643 self._logger.error('Target parent not found in binder: %s', parent_id) 

644 msg = 'Target parent not found in binder' 

645 raise NodeNotFoundError(msg, str(parent_id)) 

646 

647 # Check for circular dependencies 

648 self._logger.debug('Checking for circular dependencies') 

649 if MoveNode._would_create_circular_dependency(binder, node_id, parent_id): 

650 self._logger.error( 

651 'Circular dependency detected: cannot move %s under %s', 

652 node_id, 

653 parent_id, 

654 ) 

655 msg = 'Move would create circular dependency' 

656 raise BinderIntegrityError( 

657 msg, 

658 str(node_id), 

659 str(parent_id), 

660 ) 

661 

662 # Perform the move operation 

663 self._remove_node_from_current_location(binder, source_item) 

664 self._add_node_to_new_location(binder, source_item, parent_id, position) 

665 

666 # Save updated binder 

667 self._binder_repo.save(binder) 

668 

669 self._logger.info('Move node operation completed successfully for NodeId: %s', node_id) 

670 

671 @staticmethod 

672 def _would_create_circular_dependency( 

673 binder: Binder, 

674 node_id: NodeId, 

675 parent_id: NodeId | None, 

676 ) -> bool: 

677 """Check if moving node under parent would create circular dependency. 

678 

679 Uses ancestor traversal approach: walks up from target parent to see 

680 if the source node is an ancestor. 

681 

682 Args: 

683 binder: Binder instance to check 

684 node_id: NodeId of node being moved 

685 parent_id: Target parent NodeId (None means root level) 

686 

687 Returns: 

688 True if move would create circular dependency, False otherwise 

689 

690 """ 

691 # Moving to root level cannot create circular dependency 

692 if parent_id is None: 

693 return False 

694 

695 # Check if source node is an ancestor of target parent 

696 return MoveNode._is_ancestor(binder, node_id, parent_id) 

697 

698 @staticmethod 

699 def _is_ancestor(binder: Binder, potential_ancestor_id: NodeId, descendant_id: NodeId) -> bool: 

700 """Check if potential_ancestor_id is an ancestor of descendant_id. 

701 

702 Traverses up the tree from descendant to see if potential_ancestor 

703 is found in the ancestry chain. 

704 

705 Args: 

706 binder: Binder instance to traverse 

707 potential_ancestor_id: NodeId that might be an ancestor 

708 descendant_id: NodeId to check ancestry for 

709 

710 Returns: 

711 True if potential_ancestor_id is an ancestor of descendant_id 

712 

713 """ 

714 current_id: NodeId | None = descendant_id 

715 

716 while current_id is not None: 

717 # Find parent of current node 

718 parent_item = MoveNode._find_parent_of_node(binder, current_id) 

719 

720 if parent_item is None: 

721 # Reached root level, no more ancestors 

722 return False 

723 

724 if parent_item.id == potential_ancestor_id: 

725 # Found the potential ancestor in ancestry chain 

726 return True 

727 

728 # Continue up the tree 

729 current_id = parent_item.id 

730 

731 return False # pragma: no cover 

732 

733 @staticmethod 

734 def _find_parent_of_node(binder: Binder, node_id: NodeId) -> BinderItem | None: 

735 """Find the parent BinderItem of the specified node. 

736 

737 Args: 

738 binder: Binder instance to search 

739 node_id: NodeId to find parent for 

740 

741 Returns: 

742 Parent BinderItem or None if node is at root level 

743 

744 """ 

745 

746 def _search_for_parent(item: BinderItem) -> BinderItem | None: 

747 """Recursively search for parent of node_id.""" 

748 # Check if any direct child matches the target node_id 

749 for child in item.children: 

750 if child.id == node_id: 

751 return item 

752 

753 # Recursively search in children 

754 for child in item.children: 

755 result = _search_for_parent(child) 

756 if result is not None: 

757 return result 

758 

759 return None 

760 

761 # Search through all root items 

762 for root_item in binder.roots: 

763 if root_item.id == node_id: 

764 # Node is at root level, no parent 

765 return None 

766 

767 result = _search_for_parent(root_item) 

768 if result is not None: 

769 return result 

770 

771 return None # pragma: no cover 

772 

773 def _remove_node_from_current_location(self, binder: Binder, source_item: BinderItem) -> None: 

774 """Remove the source node from its current location in the binder. 

775 

776 Args: 

777 binder: Binder instance to modify 

778 source_item: BinderItem to remove 

779 

780 """ 

781 self._logger.debug('Removing node from current location: %s', source_item.id) 

782 

783 # Source item must have a valid NodeId to be moved 

784 if source_item.id is None: 

785 msg = 'Cannot remove item without NodeId' 

786 raise BinderIntegrityError(msg, source_item) 

787 

788 # Find parent and remove from its children list 

789 parent_item = MoveNode._find_parent_of_node(binder, source_item.id) 

790 

791 if parent_item is None: 

792 # Node is at root level 

793 binder.roots.remove(source_item) 

794 else: 

795 # Node is under a parent 

796 parent_item.children.remove(source_item) 

797 

798 def _add_node_to_new_location( 

799 self, 

800 binder: Binder, 

801 source_item: BinderItem, 

802 parent_id: NodeId | None, 

803 position: int | None, 

804 ) -> None: 

805 """Add the source node to its new location in the binder. 

806 

807 Args: 

808 binder: Binder instance to modify 

809 source_item: BinderItem to add 

810 parent_id: Target parent NodeId (None = root level) 

811 position: Position for insertion (None = append, out-of-bounds = append) 

812 

813 """ 

814 self._logger.debug('Adding node to new location: %s under parent: %s', source_item.id, parent_id) 

815 

816 if parent_id is None: 

817 # Add to root level 

818 target_list = binder.roots 

819 else: 

820 # Add under specified parent 

821 parent_item = binder.find_by_id(parent_id) 

822 if parent_item is None: 

823 msg = 'Parent item not found' 

824 raise NodeNotFoundError(msg, parent_id) 

825 target_list = parent_item.children 

826 

827 # Insert at specified position or append 

828 if position is None or position >= len(target_list): 

829 target_list.append(source_item) 

830 else: 

831 # Ensure position is not negative (treat as 0) 

832 position = max(0, position) 

833 target_list.insert(position, source_item) 

834 

835 

836class RemoveNode: 

837 """Use case interactor for removing nodes from the binder structure. 

838 

839 Orchestrates the removal of nodes by updating the binder hierarchy while 

840 optionally deleting associated files. Follows hexagonal architecture 

841 principles with pure business logic that delegates all I/O operations 

842 to injected port implementations. 

843 

844 The node removal process: 

845 1. Validates node exists in binder hierarchy 

846 2. Handles child nodes by promoting them to removed node's parent level 

847 3. Removes node from binder structure (from parent or root level) 

848 4. Optionally deletes node files using NodeRepo when delete_files=True 

849 5. Updates and saves binder changes to _binder.md 

850 6. Logs removal operations with NodeId and file deletion status 

851 7. Preserves binder integrity after node removal 

852 

853 Child nodes are promoted to maintain hierarchy consistency - when a parent 

854 node is removed, its children are moved to the grandparent level rather 

855 than being orphaned or automatically removed. 

856 

857 Args: 

858 binder_repo: Port for binder persistence operations 

859 node_repo: Port for node file deletion when delete_files=True 

860 logger: Port for operational logging and audit trails 

861 

862 Examples: 

863 >>> # With dependency injection 

864 >>> interactor = RemoveNode( 

865 ... binder_repo=file_binder_repo, 

866 ... node_repo=file_node_repo, 

867 ... logger=production_logger, 

868 ... ) 

869 >>> interactor.execute(node_id=node_id, delete_files=False) 

870 

871 """ 

872 

873 def __init__( 

874 self, 

875 binder_repo: 'BinderRepo', 

876 node_repo: 'NodeRepo', 

877 logger: 'Logger', 

878 ) -> None: 

879 """Initialize RemoveNode with injected dependencies. 

880 

881 Args: 

882 binder_repo: Port for binder persistence operations 

883 node_repo: Port for node file deletion when delete_files=True 

884 logger: Port for operational logging and audit trails 

885 

886 """ 

887 self._binder_repo = binder_repo 

888 self._node_repo = node_repo 

889 self._logger = logger 

890 

891 def execute(self, node_id: NodeId, *, delete_files: bool = False) -> None: 

892 """Execute node removal workflow. 

893 

894 Removes the specified node from the binder hierarchy and optionally 

895 deletes the associated files. Child nodes are promoted to the parent 

896 level to maintain hierarchy consistency. 

897 

898 Args: 

899 node_id: NodeId of the node to remove 

900 delete_files: If True, delete {id}.md and {id}.notes.md files 

901 

902 Raises: 

903 NodeNotFoundError: If node_id doesn't exist in binder 

904 FileSystemError: If binder or node files cannot be updated 

905 

906 """ 

907 self._logger.info( 

908 'Starting node removal for NodeId: %s with delete_files=%s', 

909 node_id, 

910 delete_files, 

911 ) 

912 

913 # Load and validate binder structure 

914 binder = self._binder_repo.load() 

915 self._logger.debug('Validating node exists in binder') 

916 

917 # Validate node exists 

918 target_item = binder.find_by_id(node_id) 

919 if target_item is None: 

920 self._logger.error('Node not found in binder: %s', node_id) 

921 msg = 'Node not found in binder' 

922 raise NodeNotFoundError(msg, str(node_id)) 

923 

924 # Find parent for child promotion logic 

925 parent_item = RemoveNode._find_parent_of_node(binder, node_id) 

926 

927 # Promote children before removing node 

928 if target_item.children: 

929 self._logger.debug( 

930 'Promoting %d children of node %s to parent level', 

931 len(target_item.children), 

932 node_id, 

933 ) 

934 self._promote_children_to_parent_level(binder, target_item, parent_item) 

935 

936 # Remove node from binder structure 

937 self._remove_node_from_binder(binder, target_item, parent_item) 

938 

939 # Delete files if requested 

940 if delete_files: 

941 self._logger.debug('Deleting node files for NodeId: %s', node_id) 

942 self._node_repo.delete(node_id, delete_files=True) 

943 

944 # Save updated binder 

945 self._binder_repo.save(binder) 

946 

947 self._logger.info( 

948 'Node removal completed successfully for NodeId: %s (files deleted: %s)', 

949 node_id, 

950 delete_files, 

951 ) 

952 

953 @staticmethod 

954 def _find_parent_of_node(binder: Binder, node_id: NodeId) -> BinderItem | None: 

955 """Find the parent BinderItem of the specified node. 

956 

957 Args: 

958 binder: Binder instance to search 

959 node_id: NodeId to find parent for 

960 

961 Returns: 

962 Parent BinderItem or None if node is at root level 

963 

964 """ 

965 

966 def _search_for_parent(item: BinderItem) -> BinderItem | None: 

967 """Recursively search for parent of node_id.""" 

968 # Check if any direct child matches the target node_id 

969 for child in item.children: 

970 if child.id == node_id: 

971 return item 

972 

973 # Recursively search in children 

974 for child in item.children: 

975 result = _search_for_parent(child) 

976 if result is not None: 

977 return result 

978 

979 return None 

980 

981 # Search through all root items 

982 for root_item in binder.roots: 

983 if root_item.id == node_id: 

984 # Node is at root level, no parent 

985 return None 

986 

987 result = _search_for_parent(root_item) 

988 if result is not None: 

989 return result 

990 

991 return None # pragma: no cover 

992 

993 def _promote_children_to_parent_level( 

994 self, 

995 binder: Binder, 

996 target_item: BinderItem, 

997 parent_item: BinderItem | None, 

998 ) -> None: 

999 """Promote children of target node to parent level. 

1000 

1001 Args: 

1002 binder: Binder instance to modify 

1003 target_item: BinderItem being removed 

1004 parent_item: Parent of target item (None if at root level) 

1005 

1006 """ 

1007 self._logger.debug('Preparing to promote children') 

1008 children_to_promote = target_item.children.copy() 

1009 self._logger.debug('Promoting %d children of %s', len(children_to_promote), target_item.id) 

1010 

1011 if parent_item is None: 

1012 # Target is at root level, promote children to root 

1013 target_index = binder.roots.index(target_item) 

1014 # Insert children at the target's position 

1015 for i, child in enumerate(children_to_promote): 

1016 binder.roots.insert(target_index + i, child) 

1017 else: 

1018 # Target is under a parent, promote children to parent level 

1019 target_index = parent_item.children.index(target_item) 

1020 # Insert children at the target's position under parent 

1021 for i, child in enumerate(children_to_promote): 

1022 parent_item.children.insert(target_index + i, child) 

1023 

1024 def _remove_node_from_binder( 

1025 self, 

1026 binder: Binder, 

1027 target_item: BinderItem, 

1028 parent_item: BinderItem | None, 

1029 ) -> None: 

1030 """Remove the target node from the binder structure. 

1031 

1032 Args: 

1033 binder: Binder instance to modify 

1034 target_item: BinderItem to remove 

1035 parent_item: Parent of target item (None if at root level) 

1036 

1037 """ 

1038 self._logger.debug('Removing node from binder structure: %s', target_item.id) 

1039 

1040 if parent_item is None: 

1041 # Node is at root level 

1042 binder.roots.remove(target_item) 

1043 else: 

1044 # Node is under a parent 

1045 parent_item.children.remove(target_item) 

1046 

1047 

1048class WriteFreeform: 

1049 """Use case interactor for creating timestamped freewrite files. 

1050 

1051 Creates standalone markdown files with optional titles and UUIDv7 identifiers 

1052 outside the binder structure for frictionless writing. This interactor supports 

1053 spontaneous idea capture without structural constraints and can launch the 

1054 created file in the user's preferred editor. 

1055 

1056 The freewrite creation process: 

1057 1. Generates a unique timestamped filename with UUIDv7 identifier 

1058 2. Creates the file with optional title in YAML frontmatter 

1059 3. Opens the file in external editor for immediate writing 

1060 4. Logs the operation for reference and session tracking 

1061 5. Returns the filename for confirmation or further operations 

1062 

1063 Args: 

1064 daily_repo: Port for freewrite file creation and management 

1065 editor_port: Port for launching external editor 

1066 logger: Port for operational logging and audit trails 

1067 clock: Port for timestamp generation 

1068 

1069 Examples: 

1070 >>> # With dependency injection 

1071 >>> interactor = WriteFreeform( 

1072 ... daily_repo=filesystem_daily_repo, 

1073 ... editor_port=system_editor_port, 

1074 ... logger=production_logger, 

1075 ... clock=system_clock, 

1076 ... ) 

1077 >>> filename = interactor.execute(title='Morning Thoughts') 

1078 >>> print(filename) 

1079 "20250911T0830_01932c5a-7f3e-7000-8000-000000000001.md" 

1080 

1081 """ 

1082 

1083 def __init__( 

1084 self, 

1085 daily_repo: 'DailyRepo', 

1086 editor_port: 'EditorPort', 

1087 logger: 'Logger', 

1088 clock: 'Clock', 

1089 ) -> None: 

1090 """Initialize WriteFreeform with injected dependencies. 

1091 

1092 Args: 

1093 daily_repo: Port for freewrite file creation and management 

1094 editor_port: Port for launching external editor 

1095 logger: Port for operational logging and audit trails 

1096 clock: Port for timestamp generation 

1097 

1098 """ 

1099 self._daily_repo = daily_repo 

1100 self._editor_port = editor_port 

1101 self._logger = logger 

1102 self._clock = clock 

1103 

1104 def execute(self, title: str | None = None) -> str: 

1105 """Execute freewrite creation workflow. 

1106 

1107 Creates a new timestamped freewrite file with optional title, 

1108 opens it in the external editor, and returns the filename for 

1109 confirmation. Handles editor launch failures gracefully. 

1110 

1111 Args: 

1112 title: Optional title to include in the file's frontmatter. 

1113 If provided, will be added as a 'title' field in the 

1114 YAML frontmatter block. 

1115 

1116 Returns: 

1117 The filename of the created freewrite file, following the 

1118 format YYYYMMDDTHHMM_<uuid7>.md 

1119 

1120 Raises: 

1121 FileSystemError: If the file cannot be created due to I/O 

1122 errors, permission issues, or disk space 

1123 constraints (propagated from DailyRepo). 

1124 

1125 """ 

1126 # Log start of freewrite creation 

1127 if title: 

1128 self._logger.info('Starting freewrite creation with title: %s', title) 

1129 else: 

1130 self._logger.info('Starting freewrite creation without title') 

1131 

1132 try: 

1133 # Create the freewrite file 

1134 filename = self._daily_repo.write_freeform(title=title) 

1135 self._logger.info('Created freewrite file: %s', filename) 

1136 

1137 # Attempt to open in editor 

1138 try: 

1139 self._editor_port.open(filename) 

1140 self._logger.debug('Opened freewrite file in editor: %s', filename) 

1141 except EditorLaunchError as exc: 

1142 # Editor failure shouldn't prevent the freewrite from being created 

1143 self._logger.warning('Failed to open freewrite file in editor: %s (file still created)', str(exc)) 

1144 return filename 

1145 else: 

1146 return filename 

1147 

1148 except FileSystemError: 

1149 self._logger.exception('Failed to create freewrite file') 

1150 raise # Re-raise filesystem errors as they're critical 

1151 

1152 

1153class ShowStructure: 

1154 """Use case interactor for displaying the hierarchical structure of the binder. 

1155 

1156 Provides a read-only view of the binder hierarchy, supporting both full 

1157 structure display and subtree filtering. Formats the tree structure using 

1158 ASCII art for console display with proper indentation and tree characters. 

1159 

1160 The structure display process: 

1161 1. Loads the current binder structure from storage 

1162 2. Validates subtree root node exists when node_id is specified 

1163 3. Filters to subtree or shows full structure based on parameters 

1164 4. Formats the hierarchy using tree drawing characters (├─, └─, │) 

1165 5. Shows placeholders with distinctive visual markers 

1166 6. Returns formatted string representation for console output 

1167 7. Logs operation details for traceability and debugging 

1168 

1169 Placeholders (items without NodeId) are displayed with [Placeholder] 

1170 markers to distinguish them from actual nodes. The formatter uses 

1171 standard tree drawing characters for clear hierarchy visualization. 

1172 

1173 Args: 

1174 binder_repo: Port for binder persistence operations 

1175 logger: Port for operational logging and audit trails 

1176 

1177 Examples: 

1178 >>> # With dependency injection 

1179 >>> interactor = ShowStructure( 

1180 ... binder_repo=file_binder_repo, 

1181 ... logger=production_logger, 

1182 ... ) 

1183 >>> # Display full structure 

1184 >>> structure = interactor.execute() 

1185 >>> print(structure) 

1186 ├─ Part 1 

1187 │ ├─ Chapter 1 

1188 │ │ └─ Section 1.1 

1189 │ └─ Chapter 2 

1190 └─ Part 2 

1191 >>> 

1192 >>> # Display subtree from specific node 

1193 >>> subtree = interactor.execute(node_id=part1_id) 

1194 >>> print(subtree) 

1195 Part 1 

1196 ├─ Chapter 1 

1197 │ └─ Section 1.1 

1198 └─ Chapter 2 

1199 

1200 """ 

1201 

1202 def __init__( 

1203 self, 

1204 binder_repo: 'BinderRepo', 

1205 logger: 'Logger', 

1206 ) -> None: 

1207 """Initialize ShowStructure with injected dependencies. 

1208 

1209 Args: 

1210 binder_repo: Port for binder persistence operations 

1211 logger: Port for operational logging and audit trails 

1212 

1213 """ 

1214 self._binder_repo = binder_repo 

1215 self._logger = logger 

1216 

1217 def execute(self, node_id: NodeId | None = None) -> str: 

1218 """Execute structure display workflow. 

1219 

1220 Displays the binder hierarchy as a formatted tree structure. 

1221 When node_id is provided, shows only the subtree starting from 

1222 that node. When node_id is None, shows the complete binder structure. 

1223 

1224 Args: 

1225 node_id: Optional NodeId for subtree display (None = full structure) 

1226 

1227 Returns: 

1228 Formatted string representation of the tree structure using 

1229 ASCII art characters for hierarchy visualization 

1230 

1231 Raises: 

1232 NodeNotFoundError: If node_id is specified but doesn't exist in binder 

1233 FileSystemError: If binder cannot be loaded (propagated from ports) 

1234 

1235 """ 

1236 if node_id is None: 

1237 self._logger.info('Displaying full binder structure') 

1238 else: 

1239 self._logger.info('Displaying subtree structure for NodeId: %s', node_id) 

1240 

1241 # Load binder structure 

1242 binder = self._binder_repo.load() 

1243 

1244 if node_id is None: 

1245 # Display full structure 

1246 return self._format_full_structure(binder) 

1247 # Display subtree 

1248 return self._format_subtree_structure(binder, node_id) 

1249 

1250 def _format_full_structure(self, binder: Binder) -> str: 

1251 """Format the complete binder structure. 

1252 

1253 Args: 

1254 binder: Binder instance to format 

1255 

1256 Returns: 

1257 Formatted string representation of full structure 

1258 

1259 """ 

1260 if not binder.roots: 

1261 self._logger.debug('Binder is empty') 

1262 return 'Binder is empty - no nodes to display' 

1263 

1264 total_items = self._count_all_items(binder.roots) 

1265 placeholder_count = self._count_placeholders(binder.roots) 

1266 

1267 self._logger.debug('Found %d total items in binder', total_items) 

1268 if placeholder_count > 0: 

1269 self._logger.debug('Found %d placeholders in structure', placeholder_count) 

1270 

1271 # If there are multiple root items, they should have tree connectors 

1272 if len(binder.roots) > 1: 

1273 result = self._format_items_with_root_connectors(binder.roots) 

1274 else: 

1275 result = self._format_items(binder.roots, prefix='') 

1276 

1277 self._logger.info('Structure display completed successfully') 

1278 return result 

1279 

1280 def _format_subtree_structure(self, binder: Binder, node_id: NodeId) -> str: 

1281 """Format subtree structure starting from specified node. 

1282 

1283 Args: 

1284 binder: Binder instance to search 

1285 node_id: NodeId of subtree root 

1286 

1287 Returns: 

1288 Formatted string representation of subtree 

1289 

1290 Raises: 

1291 NodeNotFoundError: If node_id doesn't exist in binder 

1292 

1293 """ 

1294 # Find the target node in binder structure 

1295 target_item = binder.find_by_id(node_id) 

1296 if target_item is None: 

1297 self._logger.error('Node not found for subtree display: %s', node_id) 

1298 msg = 'Node not found for subtree display' 

1299 raise NodeNotFoundError(msg, str(node_id)) 

1300 

1301 self._logger.debug('Found subtree root: %s', target_item.display_title) 

1302 

1303 # Format the subtree starting from the target node 

1304 result = self._format_single_item( 

1305 target_item, 

1306 prefix='', 

1307 is_last=True, 

1308 show_children=True, 

1309 force_connector=False, 

1310 ) 

1311 

1312 self._logger.info('Structure display completed successfully') 

1313 return result 

1314 

1315 def _format_items(self, items: list[BinderItem], prefix: str) -> str: 

1316 """Format a list of BinderItems with tree structure. 

1317 

1318 Args: 

1319 items: List of BinderItems to format 

1320 prefix: Current indentation prefix 

1321 is_last_group: Whether this is the last group of siblings 

1322 

1323 Returns: 

1324 Formatted string representation 

1325 

1326 """ 

1327 if not items: 

1328 return '' 

1329 

1330 lines = [] 

1331 for i, item in enumerate(items): 

1332 is_last = i == len(items) - 1 

1333 line = self._format_single_item(item, prefix, is_last=is_last, show_children=True, force_connector=False) 

1334 lines.append(line) 

1335 

1336 return '\n'.join(lines) 

1337 

1338 def _format_items_with_root_connectors(self, items: list[BinderItem]) -> str: 

1339 """Format root items with tree connectors. 

1340 

1341 Args: 

1342 items: List of root BinderItems to format 

1343 

1344 Returns: 

1345 Formatted string representation with root connectors 

1346 

1347 """ 

1348 if not items: 

1349 return '' 

1350 

1351 lines = [] 

1352 for i, item in enumerate(items): 

1353 is_last = i == len(items) - 1 

1354 # Force connector even at root level 

1355 line = self._format_single_item(item, prefix='', is_last=is_last, show_children=True, force_connector=True) 

1356 lines.append(line) 

1357 

1358 return '\n'.join(lines) 

1359 

1360 def _format_single_item( 

1361 self, 

1362 item: BinderItem, 

1363 prefix: str, 

1364 *, 

1365 is_last: bool, 

1366 show_children: bool = True, 

1367 force_connector: bool = False, 

1368 ) -> str: 

1369 """Format a single BinderItem with proper tree characters. 

1370 

1371 Args: 

1372 item: BinderItem to format 

1373 prefix: Current indentation prefix 

1374 is_last: Whether this is the last sibling 

1375 show_children: Whether to recursively show children 

1376 force_connector: Whether to force tree connector even at root level 

1377 

1378 Returns: 

1379 Formatted string representation of item and its children 

1380 

1381 """ 

1382 # Choose tree connector 

1383 connector = '' if not prefix and not force_connector else '└─ ' if is_last else '├─ ' 

1384 

1385 # Format display title with node ID in parentheses 

1386 display_title = item.display_title 

1387 display_title = f'{display_title} ({item.id})' if item.id is not None else f'{display_title} [Placeholder]' 

1388 

1389 # Create the line for this item 

1390 line = f'{prefix}{connector}{display_title}' 

1391 

1392 if not show_children or not item.children: 

1393 return line 

1394 

1395 # Format children with appropriate prefix 

1396 lines = [line] 

1397 child_prefix = prefix + (' ' if is_last else '│ ') 

1398 

1399 for i, child in enumerate(item.children): 

1400 child_is_last = i == len(item.children) - 1 

1401 child_line = self._format_single_item( 

1402 child, 

1403 child_prefix, 

1404 is_last=child_is_last, 

1405 show_children=True, 

1406 force_connector=False, 

1407 ) 

1408 lines.append(child_line) 

1409 

1410 return '\n'.join(lines) 

1411 

1412 def _count_all_items(self, items: list[BinderItem]) -> int: 

1413 """Count total number of items in tree structure. 

1414 

1415 Args: 

1416 items: Root list of BinderItems 

1417 

1418 Returns: 

1419 Total count of all items including nested children 

1420 

1421 """ 

1422 count = len(items) 

1423 for item in items: 

1424 count += self._count_all_items(item.children) 

1425 return count 

1426 

1427 def _count_placeholders(self, items: list[BinderItem]) -> int: 

1428 """Count placeholder items (items without NodeId) in tree structure. 

1429 

1430 Args: 

1431 items: Root list of BinderItems 

1432 

1433 Returns: 

1434 Count of placeholder items including nested children 

1435 

1436 """ 

1437 count = sum(1 for item in items if item.id is None) 

1438 for item in items: 

1439 count += self._count_placeholders(item.children) 

1440 return count 

1441 

1442 

1443class MaterializeNode: # pragma: no cover 

1444 """Use case interactor for converting binder placeholders into actual nodes. 

1445 

1446 Orchestrates the materialization of placeholder items by generating unique 

1447 identifiers, creating node files, and updating the binder structure. 

1448 Follows hexagonal architecture principles with pure business logic that 

1449 delegates all I/O operations to injected port implementations. 

1450 

1451 The materialization process: 

1452 1. Locates placeholder by display title in binder structure 

1453 2. Validates that the item is indeed a placeholder (has None id) 

1454 3. Generates unique NodeId for the new node 

1455 4. Creates node files with proper frontmatter and content 

1456 5. Updates binder structure replacing placeholder with node reference 

1457 6. Saves updated binder to persistent storage 

1458 7. Logs all operations for audit trail 

1459 

1460 Args: 

1461 binder_repo: Port for binder persistence operations 

1462 node_repo: Port for node file creation and management 

1463 id_generator: Port for generating unique NodeId values 

1464 logger: Port for operational logging and audit trails 

1465 

1466 Examples: 

1467 >>> # With dependency injection 

1468 >>> interactor = MaterializeNode( 

1469 ... binder_repo=file_binder_repo, 

1470 ... node_repo=file_node_repo, 

1471 ... id_generator=uuid_generator, 

1472 ... logger=production_logger, 

1473 ... ) 

1474 >>> node_id = interactor.execute(display_title='Chapter One', synopsis='The beginning') 

1475 

1476 """ 

1477 

1478 def __init__( 

1479 self, 

1480 binder_repo: 'BinderRepo', 

1481 node_repo: 'NodeRepo', 

1482 id_generator: 'IdGenerator', 

1483 logger: 'Logger', 

1484 ) -> None: 

1485 """Initialize MaterializeNode with injected dependencies. 

1486 

1487 Args: 

1488 binder_repo: Port for binder persistence operations 

1489 node_repo: Port for node file creation and management 

1490 id_generator: Port for generating unique NodeId values 

1491 logger: Port for operational logging and audit trails 

1492 

1493 """ 

1494 self._binder_repo = binder_repo 

1495 self._node_repo = node_repo 

1496 self._id_generator = id_generator 

1497 self._logger = logger 

1498 

1499 def execute(self, display_title: str, synopsis: str | None) -> NodeId: 

1500 """Execute placeholder materialization workflow. 

1501 

1502 Converts a binder placeholder with the specified display title into 

1503 a concrete node with files and proper binder structure integration. 

1504 

1505 Args: 

1506 display_title: Display title of the placeholder to materialize 

1507 synopsis: Optional synopsis/summary for the new node 

1508 

1509 Returns: 

1510 NodeId of the materialized node 

1511 

1512 Raises: 

1513 PlaceholderNotFoundError: If no placeholder with display_title exists 

1514 AlreadyMaterializedError: If item with display_title already has NodeId 

1515 BinderIntegrityError: If binder integrity is violated after materialization 

1516 FileSystemError: If node files cannot be created (propagated from ports) 

1517 

1518 """ 

1519 self._logger.info('Starting placeholder materialization for display_title=%s', display_title) 

1520 

1521 # Discovery Phase - Find the placeholder in binder structure 

1522 binder = self._binder_repo.load() 

1523 placeholder = binder.find_placeholder_by_display_title(display_title) 

1524 

1525 if placeholder is None: 

1526 # Check if an item with this title already exists but is materialized 

1527 for root_item in binder.roots: 

1528 existing_item = self._find_item_by_title_recursive(root_item, display_title) 

1529 if existing_item is not None and existing_item.id is not None: 

1530 self._logger.error('Item with display_title already materialized: %s', display_title) 

1531 msg = 'Item already materialized' 

1532 raise AlreadyMaterializedError(msg, display_title, str(existing_item.id)) 

1533 

1534 # No item found at all 

1535 self._logger.error('Placeholder not found with display_title: %s', display_title) 

1536 msg = 'Placeholder not found' 

1537 raise PlaceholderNotFoundError(msg, display_title) 

1538 

1539 # Validation Phase - Ensure it's actually a placeholder 

1540 if placeholder.id is not None: # pragma: no cover 

1541 # This should never happen as find_placeholder_by_display_title only returns items with id=None 

1542 self._logger.error('Item with display_title already materialized: %s', display_title) # pragma: no cover 

1543 msg = 'Item already materialized' 

1544 raise AlreadyMaterializedError( 

1545 msg, 

1546 display_title, 

1547 str(placeholder.id), 

1548 ) # pragma: no cover 

1549 

1550 # Generation Phase - Create unique identity 

1551 node_id = self._id_generator.new() 

1552 self._logger.debug('Generated new NodeId for materialization: %s', node_id) 

1553 

1554 # Creation Phase - Set up node files with proper metadata 

1555 self._node_repo.create(node_id, display_title, synopsis) 

1556 self._logger.debug('Created node files for materialized NodeId: %s', node_id) 

1557 

1558 # Materialization Phase - Update placeholder to reference actual node 

1559 placeholder.node_id = node_id 

1560 self._binder_repo.save(binder) 

1561 self._logger.debug('Updated binder with materialized node: %s', node_id) 

1562 

1563 # Completion 

1564 self._logger.info('Placeholder materialization completed successfully for NodeId: %s', node_id) 

1565 return node_id 

1566 

1567 def _find_item_by_title_recursive(self, item: BinderItem, target_title: str) -> BinderItem | None: 

1568 """Recursively search for any item (placeholder or materialized) by display title. 

1569 

1570 Args: 

1571 item: Current item to check 

1572 target_title: Title to search for 

1573 

1574 Returns: 

1575 The BinderItem with matching display title, or None if not found 

1576 

1577 """ 

1578 if item.display_title == target_title: 

1579 return item 

1580 

1581 for child in item.children: 

1582 result = self._find_item_by_title_recursive(child, target_title) 

1583 if result is not None: # pragma: no branch 

1584 return result 

1585 

1586 return None 

1587 

1588 

1589class AuditBinder: 

1590 """Use case interactor for auditing binder consistency and integrity. 

1591 

1592 Provides comprehensive validation of binder integrity by detecting four 

1593 types of issues: PLACEHOLDER (no ID), MISSING (referenced but file doesn't 

1594 exist), ORPHAN (file exists but not in binder), and MISMATCH (frontmatter 

1595 ID ≠ filename). Follows hexagonal architecture principles with pure business 

1596 logic that delegates all I/O operations to injected port implementations. 

1597 

1598 The audit process: 

1599 1. Loads binder structure from BinderRepo 

1600 2. Scans project directory for existing node files via NodeRepo 

1601 3. Cross-references binder items with file system state 

1602 4. Validates frontmatter IDs match filenames for existing files 

1603 5. Categorizes and reports all discovered issues by type 

1604 6. Returns structured audit report with human-readable and JSON formats 

1605 

1606 Issue Types and Detection Logic: 

1607 - PLACEHOLDER: BinderItem.id is None (has display title but no NodeId) 

1608 - MISSING: Binder references NodeId but corresponding file doesn't exist 

1609 - ORPHAN: Node file exists but NodeId not found in binder structure 

1610 - MISMATCH: File exists but frontmatter.id ≠ filename NodeId 

1611 

1612 Args: 

1613 binder_repo: Port for binder persistence operations 

1614 node_repo: Port for node file scanning and validation 

1615 logger: Port for operational logging and audit trails 

1616 

1617 Examples: 

1618 >>> # With dependency injection 

1619 >>> interactor = AuditBinder( 

1620 ... binder_repo=file_binder_repo, 

1621 ... node_repo=file_node_repo, 

1622 ... logger=production_logger, 

1623 ... ) 

1624 >>> report = interactor.execute() 

1625 >>> if report.is_clean(): 

1626 ... print('✓ No issues found') 

1627 >>> else: 

1628 ... print(report.format_report()) 

1629 

1630 """ 

1631 

1632 def __init__( 

1633 self, 

1634 binder_repo: 'BinderRepo', 

1635 node_repo: 'NodeRepo', 

1636 logger: 'Logger', 

1637 ) -> None: 

1638 """Initialize AuditBinder with injected dependencies. 

1639 

1640 Args: 

1641 binder_repo: Port for binder persistence operations 

1642 node_repo: Port for node file scanning and validation 

1643 logger: Port for operational logging and audit trails 

1644 

1645 """ 

1646 self._binder_repo = binder_repo 

1647 self._node_repo = node_repo 

1648 self._logger = logger 

1649 

1650 def execute(self) -> AuditReport: 

1651 """Execute binder audit workflow. 

1652 

1653 Performs comprehensive audit of binder consistency by scanning the 

1654 binder structure and cross-referencing with the file system state. 

1655 Detects and categorizes all integrity issues. 

1656 

1657 Returns: 

1658 AuditReport containing all discovered issues organized by type 

1659 

1660 Raises: 

1661 BinderNotFoundError: If binder file doesn't exist 

1662 FileSystemError: If files cannot be read (propagated from ports) 

1663 

1664 """ 

1665 self._logger.info('Starting binder audit') 

1666 

1667 # Load binder structure 

1668 binder = self._binder_repo.load() 

1669 self._logger.debug('Loaded binder structure with %d root items', len(binder.roots)) 

1670 

1671 # Initialize report 

1672 report = AuditReport() 

1673 

1674 # Scan for placeholders 

1675 self._scan_placeholders(binder, report) 

1676 

1677 # Get all node IDs referenced in binder 

1678 binder_node_ids = binder.get_all_node_ids() 

1679 self._logger.debug('Found %d node IDs in binder', len(binder_node_ids)) 

1680 

1681 # Get all existing node files from file system 

1682 existing_files = self._get_existing_node_files() 

1683 self._logger.debug('Found %d existing node files', len(existing_files)) 

1684 

1685 # Cross-reference binder with file system 

1686 self._scan_missing_files(binder_node_ids, existing_files, report) 

1687 self._scan_missing_notes_files(binder_node_ids, report) 

1688 self._scan_orphaned_files(binder_node_ids, existing_files, report) 

1689 self._scan_orphaned_invalid_files(binder_node_ids, report) 

1690 self._scan_id_mismatches(existing_files, report) 

1691 

1692 # Log summary 

1693 total_issues = len(report.placeholders) + len(report.missing) + len(report.orphans) + len(report.mismatches) 

1694 self._logger.info('Binder audit completed: %d issues found', total_issues) 

1695 

1696 return report 

1697 

1698 def _scan_placeholders(self, binder: Binder, report: AuditReport) -> None: 

1699 """Scan binder structure for placeholder items. 

1700 

1701 Args: 

1702 binder: Binder instance to scan 

1703 report: AuditReport to populate with findings 

1704 

1705 """ 

1706 self._logger.debug('Scanning for placeholder items') 

1707 

1708 def _scan_item_recursive(item: BinderItem, path: list[int]) -> None: 

1709 """Recursively scan items and record placeholders.""" 

1710 if item.id is None: 

1711 position = '[' + ']['.join(map(str, path)) + ']' 

1712 placeholder_issue = PlaceholderIssue( 

1713 display_title=item.display_title, 

1714 position=position, 

1715 ) 

1716 report.placeholders.append(placeholder_issue) 

1717 self._logger.debug( 

1718 'Found placeholder: "%s" at position %s', 

1719 item.display_title, 

1720 position, 

1721 ) 

1722 

1723 # Scan children 

1724 for i, child in enumerate(item.children): 

1725 child_path = [*path, i] 

1726 _scan_item_recursive(child, child_path) 

1727 

1728 # Scan all root items 

1729 for i, root_item in enumerate(binder.roots): 

1730 _scan_item_recursive(root_item, [i]) 

1731 

1732 self._logger.debug('Found %d placeholder items', len(report.placeholders)) 

1733 

1734 def _get_existing_node_files(self) -> set[NodeId]: 

1735 """Get all existing node files from the file system. 

1736 

1737 Returns: 

1738 Set of NodeIds for files that exist on disk 

1739 

1740 """ 

1741 return self._node_repo.get_existing_files() 

1742 

1743 def _scan_missing_files( 

1744 self, 

1745 binder_node_ids: set[NodeId], 

1746 existing_files: set[NodeId], 

1747 report: AuditReport, 

1748 ) -> None: 

1749 """Scan for node IDs referenced in binder but missing from file system. 

1750 

1751 Args: 

1752 binder_node_ids: Set of NodeIds referenced in binder 

1753 existing_files: Set of NodeIds that exist as files 

1754 report: AuditReport to populate with findings 

1755 

1756 """ 

1757 self._logger.debug('Scanning for missing files') 

1758 

1759 missing_ids = binder_node_ids - existing_files 

1760 for node_id in missing_ids: 

1761 missing_issue = MissingIssue( 

1762 node_id=node_id, 

1763 expected_path=f'{node_id}.md', 

1764 ) 

1765 report.missing.append(missing_issue) 

1766 self._logger.debug('Found missing file: %s.md', node_id) 

1767 

1768 self._logger.debug('Found %d missing files', len(report.missing)) 

1769 

1770 def _scan_missing_notes_files( 

1771 self, 

1772 binder_node_ids: set[NodeId], 

1773 report: AuditReport, 

1774 ) -> None: 

1775 """Scan for node IDs that are missing their .notes.md files. 

1776 

1777 Args: 

1778 binder_node_ids: Set of NodeIds referenced in binder 

1779 report: AuditReport to populate with findings 

1780 

1781 """ 

1782 self._logger.debug('Scanning for missing notes files') 

1783 

1784 for node_id in binder_node_ids: 

1785 if not self._node_repo.file_exists(node_id, 'notes'): 

1786 missing_issue = MissingIssue( 

1787 node_id=node_id, 

1788 expected_path=f'{node_id}.notes.md', 

1789 ) 

1790 report.missing.append(missing_issue) 

1791 self._logger.debug('Found missing notes file: %s.notes.md', node_id) 

1792 

1793 notes_missing_count = sum(1 for m in report.missing if m.expected_path.endswith('.notes.md')) 

1794 self._logger.debug('Found %d missing notes files', notes_missing_count) 

1795 

1796 def _scan_orphaned_files( 

1797 self, 

1798 binder_node_ids: set[NodeId], 

1799 existing_files: set[NodeId], 

1800 report: AuditReport, 

1801 ) -> None: 

1802 """Scan for files that exist but aren't referenced in binder. 

1803 

1804 Args: 

1805 binder_node_ids: Set of NodeIds referenced in binder 

1806 existing_files: Set of NodeIds that exist as files 

1807 report: AuditReport to populate with findings 

1808 

1809 """ 

1810 self._logger.debug('Scanning for orphaned files') 

1811 

1812 orphaned_ids = existing_files - binder_node_ids 

1813 for node_id in orphaned_ids: 

1814 orphan_issue = OrphanIssue( 

1815 node_id=node_id, 

1816 file_path=f'{node_id}.md', 

1817 ) 

1818 report.orphans.append(orphan_issue) 

1819 self._logger.debug('Found orphaned file: %s.md', node_id) 

1820 

1821 self._logger.debug('Found %d orphaned files', len(report.orphans)) 

1822 

1823 def _scan_orphaned_invalid_files( 

1824 self, 

1825 _binder_node_ids: set[NodeId], 

1826 report: AuditReport, 

1827 ) -> None: 

1828 """Scan for files that look like node files but have invalid NodeIds. 

1829 

1830 Args: 

1831 _binder_node_ids: Set of NodeIds referenced in binder (currently unused) 

1832 report: AuditReport to populate with findings 

1833 

1834 """ 

1835 self._logger.debug('Scanning for orphaned files with invalid NodeIds') 

1836 

1837 # Get all potential node files, including those with invalid NodeIds 

1838 try: 

1839 # Scan project directory for .md files that look like node files 

1840 project_path = getattr(self._node_repo, 'project_path', None) 

1841 if project_path is None: 

1842 # For fake implementations, we can't scan the filesystem 

1843 return 

1844 

1845 from pathlib import Path 

1846 

1847 project_path = Path(project_path) 

1848 

1849 for md_file in project_path.glob('*.md'): 

1850 # Skip system files 

1851 if md_file.stem.startswith('_'): 

1852 continue 

1853 

1854 # Skip .notes.md files 

1855 if md_file.stem.endswith('.notes'): 

1856 continue 

1857 

1858 # Skip freeform files (pattern: YYYYMMDDTHHMM_<uuid>.md) 

1859 import re 

1860 

1861 if re.match(r'^\d{8}T\d{4}_[0-9a-f-]+$', md_file.stem): 

1862 continue # pragma: no cover 

1863 

1864 # Try to create a NodeId from the filename 

1865 try: 

1866 NodeId(md_file.stem) 

1867 # If successful, this is handled by regular orphan scanning 

1868 continue 

1869 except NodeIdentityError: 

1870 # This file has an invalid NodeId but looks like a node file 

1871 pass 

1872 

1873 # Check if this file might be a node file based on content 

1874 try: 

1875 content = md_file.read_text() 

1876 if content.startswith('---') and '\nid:' in content: 

1877 # This looks like a node file with frontmatter 

1878 # Create a dummy NodeId for reporting purposes 

1879 dummy_node_id = NodeId('00000000-0000-7000-8000-000000000000') # UUIDv7 format 

1880 orphan_issue = OrphanIssue( 

1881 node_id=dummy_node_id, 

1882 file_path=md_file.name, 

1883 ) 

1884 report.orphans.append(orphan_issue) 

1885 self._logger.debug('Found orphaned file with invalid NodeId: %s', md_file.name) 

1886 except (OSError, UnicodeDecodeError): # pragma: no cover 

1887 # Couldn't read the file or doesn't look like a node file 

1888 self._logger.debug('Could not read file %s, skipping', md_file.name) # pragma: no cover 

1889 continue # pragma: no cover 

1890 

1891 except (OSError, AttributeError) as exc: # pragma: no cover 

1892 self._logger.warning('Could not scan for orphaned invalid files: %s', exc) # pragma: no cover 

1893 

1894 invalid_orphan_count = sum(1 for o in report.orphans if o.file_path != f'{o.node_id}.md') 

1895 self._logger.debug('Found %d orphaned files with invalid NodeIds', invalid_orphan_count) 

1896 

1897 def _scan_id_mismatches(self, existing_files: set[NodeId], report: AuditReport) -> None: 

1898 """Scan for files where frontmatter ID doesn't match filename. 

1899 

1900 Args: 

1901 existing_files: Set of NodeIds that exist as files 

1902 report: AuditReport to populate with findings 

1903 

1904 """ 

1905 self._logger.debug('Scanning for ID mismatches') 

1906 

1907 for node_id in existing_files: 

1908 try: 

1909 frontmatter = self._node_repo.read_frontmatter(node_id) 

1910 frontmatter_id_str = frontmatter.get('id') 

1911 

1912 if frontmatter_id_str and frontmatter_id_str != str(node_id): 

1913 try: 

1914 actual_id = NodeId(frontmatter_id_str) 

1915 mismatch_issue = MismatchIssue( 

1916 file_path=f'{node_id}.md', 

1917 expected_id=node_id, 

1918 actual_id=actual_id, 

1919 ) 

1920 report.mismatches.append(mismatch_issue) 

1921 self._logger.debug( 

1922 'Found ID mismatch in %s.md: expected %s, found %s', 

1923 node_id, 

1924 node_id, 

1925 actual_id, 

1926 ) 

1927 except NodeIdentityError as e: 

1928 # Handle invalid frontmatter IDs as mismatches 

1929 self._logger.debug('Found invalid frontmatter ID %s: %s', frontmatter_id_str, e) 

1930 # Create a dummy NodeId for reporting purposes 

1931 dummy_actual_id = NodeId('00000000-0000-7000-8000-000000000001') # UUIDv7 format 

1932 mismatch_issue = MismatchIssue( 

1933 file_path=f'{node_id}.md (frontmatter id: {frontmatter_id_str})', 

1934 expected_id=node_id, 

1935 actual_id=dummy_actual_id, 

1936 ) 

1937 report.mismatches.append(mismatch_issue) 

1938 self._logger.debug( 

1939 'Found ID mismatch in %s.md: expected %s, found invalid %s', 

1940 node_id, 

1941 node_id, 

1942 frontmatter_id_str, 

1943 ) 

1944 except (OSError, KeyError, NodeNotFoundError) as e: 

1945 # Log and skip files that can't be read 

1946 self._logger.debug('Could not read file for node %s: %s', node_id, e) 

1947 continue 

1948 

1949 self._logger.debug('Found %d ID mismatches', len(report.mismatches))