Coverage for src/prosemark/cli/main.py: 100%
462 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-23 21:54 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-23 21:54 +0000
1"""Main CLI entry point for prosemark.
3This module provides the main command-line interface for the prosemark
4writing project manager. It uses Typer for type-safe CLI generation
5and delegates all business logic to use case interactors.
6"""
8# Standard library imports
9from collections.abc import Callable
10from pathlib import Path
11from typing import Annotated, Any, Protocol
13# Third-party imports
14import typer
16# Adapter imports
17from prosemark.adapters.binder_repo_fs import BinderRepoFs
18from prosemark.adapters.clock_system import ClockSystem
19from prosemark.adapters.console_pretty import ConsolePretty
20from prosemark.adapters.daily_repo_fs import DailyRepoFs
21from prosemark.adapters.editor_launcher_system import EditorLauncherSystem
22from prosemark.adapters.id_generator_uuid7 import IdGeneratorUuid7
23from prosemark.adapters.logger_stdout import LoggerStdout
24from prosemark.adapters.node_repo_fs import NodeRepoFs
26# Use case imports
27from prosemark.app.materialize_all_placeholders import MaterializeAllPlaceholders
28from prosemark.app.materialize_node import MaterializeNode
29from prosemark.app.use_cases import (
30 AddNode,
31 AuditBinder,
32 EditPart,
33 InitProject,
34 MoveNode,
35 RemoveNode,
36 ShowStructure,
37 WriteFreeform,
38)
39from prosemark.app.use_cases import MaterializeNode as MaterializeNodeUseCase
40from prosemark.domain.batch_materialize_result import BatchMaterializeResult
42# Domain model imports
43from prosemark.domain.binder import Item
44from prosemark.domain.models import BinderItem, NodeId
46# Exception imports
47from prosemark.exceptions import (
48 AlreadyMaterializedError,
49 BinderFormatError,
50 BinderIntegrityError,
51 BinderNotFoundError,
52 EditorLaunchError,
53 FileSystemError,
54 NodeNotFoundError,
55 PlaceholderNotFoundError,
56)
58# Port imports
59from prosemark.ports.config_port import ConfigPort, ProsemarkConfig
62# Protocol definitions
63class MaterializationResult(Protocol):
64 """Protocol for materialization process result objects.
66 Defines the expected interface for results of materialization operations,
67 capturing details about the process such as placeholders materialized,
68 failures encountered, and execution metadata.
69 """
71 total_placeholders: int
72 successful_materializations: list[Any]
73 failed_materializations: list[Any]
74 has_failures: bool
75 type: str | None
76 is_complete_success: bool
77 execution_time: float
78 message: str | None
79 summary_message: Callable[[], str]
82app = typer.Typer(
83 name='pmk',
84 help='Prosemark CLI - A hierarchical writing project manager',
85 add_completion=False,
86)
88# Alias for backward compatibility with tests
89cli = app
92class FileSystemConfigPort(ConfigPort):
93 """Temporary config port implementation."""
95 def create_default_config(self, config_path: Path) -> None:
96 """Create default configuration file."""
97 # For MVP, we don't need a config file
99 @staticmethod
100 def config_exists(config_path: Path) -> bool:
101 """Check if configuration file already exists."""
102 return config_path.exists()
104 @staticmethod
105 def get_default_config_values() -> ProsemarkConfig:
106 """Return default configuration values as dictionary."""
107 return {}
109 @staticmethod
110 def load_config(_config_path: Path) -> dict[str, Any]:
111 """Load configuration from file."""
112 return {}
115def _get_project_root() -> Path:
116 """Get the current project root directory."""
117 return Path.cwd()
120@app.command()
121def init(
122 title: Annotated[str, typer.Option('--title', '-t', help='Project title')],
123 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
124) -> None:
125 """Initialize a new prosemark project."""
126 try:
127 project_path = path or Path.cwd()
129 # Wire up dependencies
130 binder_repo = BinderRepoFs(project_path)
131 config_port = FileSystemConfigPort()
132 console_port = ConsolePretty()
133 logger = LoggerStdout()
134 clock = ClockSystem()
136 # Execute use case
137 interactor = InitProject(
138 binder_repo=binder_repo,
139 config_port=config_port,
140 console_port=console_port,
141 logger=logger,
142 clock=clock,
143 )
144 interactor.execute(project_path)
146 # Success output matching test expectations
147 typer.echo(f'Project "{title}" initialized successfully')
148 typer.echo('Created _binder.md with project structure')
150 except BinderIntegrityError:
151 typer.echo('Error: Directory already contains a prosemark project', err=True)
152 raise typer.Exit(1) from None
153 except FileSystemError as e:
154 typer.echo(f'Error: {e}', err=True)
155 raise typer.Exit(2) from e
156 except Exception as e:
157 typer.echo(f'Unexpected error: {e}', err=True)
158 raise typer.Exit(3) from e
161@app.command()
162def add(
163 title: Annotated[str, typer.Argument(help='Display title for the new node')],
164 parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID')] = None,
165 position: Annotated[int | None, typer.Option('--position', help="Position in parent's children")] = None,
166 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
167) -> None:
168 """Add a new node to the binder hierarchy."""
169 try:
170 project_root = path or _get_project_root()
172 # Wire up dependencies
173 binder_repo = BinderRepoFs(project_root)
174 clock = ClockSystem()
175 editor_port = EditorLauncherSystem()
176 node_repo = NodeRepoFs(project_root, editor_port, clock)
177 id_generator = IdGeneratorUuid7()
178 logger = LoggerStdout()
180 # Execute use case
181 interactor = AddNode(
182 binder_repo=binder_repo,
183 node_repo=node_repo,
184 id_generator=id_generator,
185 logger=logger,
186 clock=clock,
187 )
189 parent_id = NodeId(parent) if parent else None
190 node_id = interactor.execute(
191 title=title,
192 synopsis=None,
193 parent_id=parent_id,
194 position=position,
195 )
197 # Success output
198 typer.echo(f'Added "{title}" ({node_id})')
199 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
200 typer.echo('Updated binder structure')
202 except NodeNotFoundError:
203 typer.echo('Error: Parent node not found', err=True)
204 raise typer.Exit(1) from None
205 except ValueError:
206 typer.echo('Error: Invalid position index', err=True)
207 raise typer.Exit(2) from None
208 except FileSystemError as e:
209 typer.echo(f'Error: File creation failed - {e}', err=True)
210 raise typer.Exit(3) from e
213@app.command()
214def edit(
215 node_id: Annotated[str, typer.Argument(help='Node identifier')],
216 part: Annotated[str, typer.Option('--part', help='Content part to edit')] = 'draft',
217 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
218) -> None:
219 """Open node content in your preferred editor."""
220 try:
221 project_root = path or _get_project_root()
223 # Wire up dependencies
224 binder_repo = BinderRepoFs(project_root)
225 clock = ClockSystem()
226 editor_port = EditorLauncherSystem()
227 node_repo = NodeRepoFs(project_root, editor_port, clock)
228 logger = LoggerStdout()
230 # Execute use case
231 interactor = EditPart(
232 binder_repo=binder_repo,
233 node_repo=node_repo,
234 logger=logger,
235 )
237 interactor.execute(NodeId(node_id), part)
239 # Success output
240 if part == 'draft':
241 typer.echo(f'Opened {node_id}.md in editor')
242 elif part == 'notes':
243 typer.echo(f'Opened {node_id}.notes.md in editor')
244 else:
245 typer.echo(f'Opened {part} for {node_id} in editor')
247 except NodeNotFoundError:
248 typer.echo('Error: Node not found', err=True)
249 raise typer.Exit(1) from None
250 except EditorLaunchError:
251 typer.echo('Error: Editor not available', err=True)
252 raise typer.Exit(2) from None
253 except FileSystemError:
254 typer.echo('Error: File permission denied', err=True)
255 raise typer.Exit(3) from None
256 except ValueError as e:
257 typer.echo(f'Error: {e}', err=True)
258 raise typer.Exit(1) from e
261@app.command()
262def structure(
263 output_format: Annotated[str, typer.Option('--format', '-f', help='Output format')] = 'tree',
264 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
265) -> None:
266 """Display project hierarchy."""
267 try:
268 project_root = path or _get_project_root()
270 # Wire up dependencies
271 binder_repo = BinderRepoFs(project_root)
272 logger = LoggerStdout()
274 # Execute use case
275 interactor = ShowStructure(
276 binder_repo=binder_repo,
277 logger=logger,
278 )
280 structure_str = interactor.execute()
282 if output_format == 'tree':
283 typer.echo('Project Structure:')
284 typer.echo(structure_str)
285 elif output_format == 'json':
286 # For JSON format, we need to convert the tree to JSON
287 # This is a simplified version for MVP
288 import json
290 binder = binder_repo.load()
292 def item_to_dict(item: Item | BinderItem) -> dict[str, Any]:
293 result: dict[str, Any] = {
294 'display_title': item.display_title,
295 }
296 node_id = item.id if hasattr(item, 'id') else (item.node_id if hasattr(item, 'node_id') else None)
297 if node_id:
298 result['node_id'] = str(node_id)
299 item_children = item.children if hasattr(item, 'children') else []
300 if item_children:
301 result['children'] = [item_to_dict(child) for child in item_children]
302 return result
304 data: dict[str, list[dict[str, Any]]] = {'roots': [item_to_dict(item) for item in binder.roots]}
305 typer.echo(json.dumps(data, indent=2))
306 else:
307 typer.echo(f"Error: Unknown format '{output_format}'", err=True)
308 raise typer.Exit(1)
310 except FileSystemError as e:
311 typer.echo(f'Error: {e}', err=True)
312 raise typer.Exit(1) from e
315@app.command()
316def write(
317 title: Annotated[str | None, typer.Argument(help='Optional title for freeform content')] = None,
318 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
319) -> None:
320 """Create a timestamped freeform writing file."""
321 try:
322 project_root = path or _get_project_root()
324 # Wire up dependencies
325 clock = ClockSystem()
326 id_generator = IdGeneratorUuid7()
327 daily_repo = DailyRepoFs(project_root, id_generator=id_generator, clock=clock)
328 editor_port = EditorLauncherSystem()
329 logger = LoggerStdout()
331 # Execute use case
332 interactor = WriteFreeform(
333 daily_repo=daily_repo,
334 editor_port=editor_port,
335 logger=logger,
336 clock=clock,
337 )
339 filename = interactor.execute(title)
341 # Success output
342 typer.echo(f'Created freeform file: {filename}')
343 typer.echo('Opened in editor')
345 except FileSystemError:
346 typer.echo('Error: File creation failed', err=True)
347 raise typer.Exit(1) from None
348 except EditorLaunchError:
349 typer.echo('Error: Editor launch failed', err=True)
350 raise typer.Exit(2) from None
353def _validate_materialize_args(title: str | None, *, all_placeholders: bool) -> None:
354 """Validate mutual exclusion of materialize arguments."""
355 if title and all_placeholders:
356 typer.echo("Error: Cannot specify both 'title' and '--all' options", err=True)
357 raise typer.Exit(1) from None
359 if not title and not all_placeholders:
360 typer.echo("Error: Must specify either placeholder 'title' or '--all' flag", err=True)
361 raise typer.Exit(1) from None
364def _create_shared_dependencies(
365 project_root: Path,
366) -> tuple[BinderRepoFs, ClockSystem, EditorLauncherSystem, NodeRepoFs, IdGeneratorUuid7, LoggerStdout]:
367 """Create shared dependencies for materialization operations."""
368 binder_repo = BinderRepoFs(project_root)
369 clock = ClockSystem()
370 editor_port = EditorLauncherSystem()
371 node_repo = NodeRepoFs(project_root, editor_port, clock)
372 id_generator = IdGeneratorUuid7()
373 logger = LoggerStdout()
374 return binder_repo, clock, editor_port, node_repo, id_generator, logger
377def _generate_json_result(result: MaterializationResult | BatchMaterializeResult, output_type: str) -> dict[str, Any]:
378 """Generate JSON result dictionary for materialization process."""
379 json_result: dict[str, Any] = {
380 'type': output_type,
381 'total_placeholders': result.total_placeholders,
382 'successful_materializations': len(result.successful_materializations),
383 'failed_materializations': len(result.failed_materializations),
384 'execution_time': result.execution_time,
385 }
387 # Add overall message
388 if result.total_placeholders == 0:
389 json_result['message'] = 'No placeholders found in binder'
390 elif len(result.failed_materializations) == 0:
391 json_result['message'] = f'Successfully materialized all {result.total_placeholders} placeholders'
392 else:
393 success_count = len(result.successful_materializations)
394 failure_count = len(result.failed_materializations)
395 json_result['message'] = (
396 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
397 )
399 # Add results based on type
400 if output_type == 'batch_partial':
401 json_result['successes'] = [
402 {'placeholder_title': success.display_title, 'node_id': str(success.node_id.value)}
403 for success in result.successful_materializations
404 ]
405 json_result['failures'] = [
406 {
407 'placeholder_title': failure.display_title,
408 'error_type': failure.error_type,
409 'error_message': failure.error_message,
410 }
411 for failure in result.failed_materializations
412 ]
413 elif result.successful_materializations or result.failed_materializations:
414 details_list: list[dict[str, str]] = []
415 details_list.extend(
416 {
417 'placeholder_title': success.display_title,
418 'node_id': str(success.node_id.value),
419 'status': 'success',
420 }
421 for success in result.successful_materializations
422 )
424 details_list.extend(
425 {
426 'placeholder_title': failure.display_title,
427 'error_type': failure.error_type,
428 'error_message': failure.error_message,
429 'status': 'failed',
430 }
431 for failure in result.failed_materializations
432 )
434 json_result['details'] = details_list
436 return json_result
439def _check_result_failure_status(
440 result: MaterializationResult | BatchMaterializeResult, *, continue_on_error: bool = False
441) -> None:
442 """Check and handle result failure status."""
443 has_failures = len(result.failed_materializations) > 0
445 if has_failures:
446 if not continue_on_error:
447 raise typer.Exit(1) from None
448 if len(result.successful_materializations) == 0:
449 raise typer.Exit(1) from None
452def _report_materialization_progress(
453 result: MaterializationResult | BatchMaterializeResult,
454 *,
455 json_output: bool = False,
456 progress_messages: list[str] | None = None,
457) -> None:
458 """Report materialization progress with human-readable or JSON output."""
459 progress_messages = progress_messages or []
460 if not json_output and not progress_messages:
461 if result.total_placeholders == 0:
462 typer.echo('No placeholders found to materialize')
463 else:
464 typer.echo(f'Found {result.total_placeholders} placeholders to materialize')
465 for success in result.successful_materializations:
466 typer.echo(f"✓ Materialized '{success.display_title}'")
467 for failure in result.failed_materializations:
468 typer.echo(f"✗ Failed to materialize '{failure.display_title}'")
469 typer.echo(failure.error_message)
472def _materialize_all_placeholders(
473 project_root: Path,
474 binder_repo: BinderRepoFs,
475 node_repo: NodeRepoFs,
476 id_generator: IdGeneratorUuid7,
477 clock: ClockSystem,
478 logger: LoggerStdout,
479 *,
480 json_output: bool = False,
481 continue_on_error: bool = False,
482) -> None:
483 """Execute batch materialization of all placeholders."""
484 console = ConsolePretty()
486 # Create individual materialize use case for delegation
487 materialize_node_use_case = MaterializeNode(
488 binder_repo=binder_repo,
489 node_repo=node_repo,
490 id_generator=id_generator,
491 clock=clock,
492 console=console,
493 logger=logger,
494 )
496 # Create batch use case
497 batch_interactor = MaterializeAllPlaceholders(
498 materialize_node_use_case=materialize_node_use_case,
499 binder_repo=binder_repo,
500 node_repo=node_repo,
501 id_generator=id_generator,
502 clock=clock,
503 logger=logger,
504 )
506 # Execute with progress callback and track messages
507 progress_messages: list[str] = []
509 def progress_callback(message: str) -> None:
510 progress_messages.append(message)
511 typer.echo(message)
513 result = batch_interactor.execute(
514 project_path=project_root,
515 progress_callback=progress_callback,
516 )
518 # Report progress messages
519 _report_materialization_progress(result, json_output=json_output, progress_messages=progress_messages)
521 # Report final results
522 if json_output:
523 import json
525 # Determine type based on results
526 output_type: str = 'batch_partial' if result.failed_materializations else 'batch'
527 json_result = _generate_json_result(result, output_type)
528 typer.echo(json.dumps(json_result, indent=2))
530 # Check for specific interruption types
531 result_type: str | None = getattr(result, 'type', None)
532 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
533 raise typer.Exit(1) from None
535 # Handle failures
536 _check_result_failure_status(result, continue_on_error=continue_on_error)
537 else:
538 if result.total_placeholders == 0:
539 return
541 result_type = getattr(result, 'type', None)
542 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
543 raise typer.Exit(1) from None
545 success_count = len(result.successful_materializations)
546 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error)
549def _describe_materialization_result(
550 result: MaterializationResult | BatchMaterializeResult, success_count: int, *, continue_on_error: bool = False
551) -> None:
552 """Describe materialization results with appropriate messaging."""
553 is_complete_success = len(result.failed_materializations) == 0 and result.total_placeholders > 0
555 if is_complete_success:
556 typer.echo(f'Successfully materialized all {result.total_placeholders} placeholders')
557 else:
558 # Retrieve or generate summary message
559 summary_msg = _get_summary_message(result, success_count)
560 typer.echo(summary_msg)
562 # Check for interrupted operations
563 _check_result_failure_status(result, continue_on_error=continue_on_error)
566def _get_safe_attribute(
567 result: MaterializationResult | BatchMaterializeResult, attr_name: str, default: str = ''
568) -> str:
569 """Safely retrieve an attribute from a result object."""
570 try:
571 value = getattr(result, attr_name, default)
572 if callable(value):
573 value_result = value()
574 return value_result if isinstance(value_result, str) else default
575 return value if isinstance(value, str) else default
576 except (TypeError, ValueError, AttributeError):
577 return default
580def _get_summary_message(result: MaterializationResult | BatchMaterializeResult, success_count: int) -> str:
581 """Get summary message for materialization results."""
582 # First try standard message retrieval methods
583 summary_msg = _get_safe_attribute(result, 'message')
584 if not summary_msg:
585 summary_msg = _get_safe_attribute(result, 'summary_message')
587 # If no standard method works, generate a manual summary
588 if not summary_msg:
589 failure_count = len(result.failed_materializations)
590 if failure_count == 1:
591 summary_msg = (
592 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failure)'
593 )
594 else:
595 summary_msg = (
596 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
597 )
599 return summary_msg
602def _materialize_single_placeholder(
603 title: str,
604 binder_repo: BinderRepoFs,
605 node_repo: NodeRepoFs,
606 id_generator: IdGeneratorUuid7,
607 logger: LoggerStdout,
608) -> None:
609 """Execute single materialization."""
610 interactor = MaterializeNodeUseCase(
611 binder_repo=binder_repo,
612 node_repo=node_repo,
613 id_generator=id_generator,
614 logger=logger,
615 )
617 node_id = interactor.execute(display_title=title, synopsis=None)
619 # Success output
620 typer.echo(f'Materialized "{title}" ({node_id})')
621 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
622 typer.echo('Updated binder structure')
625@app.command()
626def materialize(
627 title: Annotated[str | None, typer.Argument(help='Display title of placeholder to materialize')] = None,
628 all_placeholders: Annotated[bool, typer.Option('--all', help='Materialize all placeholders in binder')] = False, # noqa: FBT002
629 _parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID to search within')] = None,
630 json_output: Annotated[bool, typer.Option('--json', help='Output results in JSON format')] = False, # noqa: FBT002
631 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
632) -> None:
633 """Convert placeholder(s) to actual nodes."""
634 try:
635 _validate_materialize_args(title, all_placeholders=all_placeholders)
636 project_root = path or _get_project_root()
637 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
639 if all_placeholders:
640 _materialize_all_placeholders(
641 project_root, binder_repo, node_repo, id_generator, clock, logger, json_output=json_output
642 )
643 else:
644 # Ensure title is not None for type safety
645 if title is None:
646 typer.echo('Error: Title is required for single materialization', err=True)
647 raise typer.Exit(1) from None
648 _materialize_single_placeholder(title, binder_repo, node_repo, id_generator, logger)
650 except PlaceholderNotFoundError:
651 typer.echo('Error: Placeholder not found', err=True)
652 raise typer.Exit(1) from None
653 except AlreadyMaterializedError:
654 typer.echo(f"Error: '{title}' is already materialized", err=True)
655 raise typer.Exit(1) from None
656 except BinderFormatError as e:
657 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
658 raise typer.Exit(1) from None
659 except BinderNotFoundError:
660 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
661 raise typer.Exit(1) from None
662 except FileSystemError:
663 typer.echo('Error: File creation failed', err=True)
664 raise typer.Exit(2) from None
667@app.command()
668def move(
669 node_id: Annotated[str, typer.Argument(help='Node to move')],
670 parent: Annotated[str | None, typer.Option('--parent', help='New parent node')] = None,
671 position: Annotated[int | None, typer.Option('--position', help="Position in new parent's children")] = None,
672 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
673) -> None:
674 """Reorganize binder hierarchy."""
675 try:
676 project_root = path or _get_project_root()
678 # Wire up dependencies
679 binder_repo = BinderRepoFs(project_root)
680 logger = LoggerStdout()
682 # Execute use case
683 interactor = MoveNode(
684 binder_repo=binder_repo,
685 logger=logger,
686 )
688 parent_id = NodeId(parent) if parent else None
689 interactor.execute(
690 node_id=NodeId(node_id),
691 parent_id=parent_id,
692 position=position,
693 )
695 # Success output
696 parent_str = 'root' if parent is None else f'parent {parent}'
697 position_str = f' at position {position}' if position is not None else ''
698 typer.echo(f'Moved node to {parent_str}{position_str}')
699 typer.echo('Updated binder structure')
701 except NodeNotFoundError as e:
702 typer.echo(f'Error: {e}', err=True)
703 raise typer.Exit(1) from e
704 except ValueError:
705 typer.echo('Error: Invalid parent or position', err=True)
706 raise typer.Exit(2) from None
707 except BinderIntegrityError:
708 typer.echo('Error: Would create circular reference', err=True)
709 raise typer.Exit(3) from None
712@app.command()
713def remove(
714 node_id: Annotated[str, typer.Argument(help='Node to remove')],
715 *,
716 delete_files: Annotated[bool, typer.Option('--delete-files', help='Also delete node files')] = False,
717 force: Annotated[bool, typer.Option('--force', '-f', help='Skip confirmation prompt')] = False,
718 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
719) -> None:
720 """Remove a node from the binder."""
721 try:
722 project_root = path or _get_project_root()
724 # Confirmation prompt if not forced
725 if not force and delete_files:
726 confirm = typer.confirm(f'Really delete node {node_id} and its files?')
727 if not confirm:
728 typer.echo('Operation cancelled')
729 raise typer.Exit(2)
731 # Wire up dependencies
732 binder_repo = BinderRepoFs(project_root)
733 clock = ClockSystem()
734 editor_port = EditorLauncherSystem()
735 node_repo = NodeRepoFs(project_root, editor_port, clock)
736 logger = LoggerStdout()
738 # Execute use case
739 interactor = RemoveNode(
740 binder_repo=binder_repo,
741 node_repo=node_repo,
742 logger=logger,
743 )
745 # Get node title for output
746 binder = binder_repo.load()
747 target_item = binder.find_by_id(NodeId(node_id))
748 title = target_item.display_title if target_item else node_id
750 interactor.execute(NodeId(node_id), delete_files=delete_files)
752 # Success output
753 typer.echo(f'Removed "{title}" from binder')
754 if delete_files:
755 typer.echo(f'Deleted files: {node_id}.md, {node_id}.notes.md')
756 else:
757 typer.echo(f'Files preserved: {node_id}.md, {node_id}.notes.md')
759 except NodeNotFoundError:
760 typer.echo('Error: Node not found', err=True)
761 raise typer.Exit(1) from None
762 except FileSystemError:
763 typer.echo('Error: File deletion failed', err=True)
764 raise typer.Exit(3) from None
767def _validate_materialize_all_options(
768 *,
769 dry_run: bool,
770 force: bool,
771 verbose: bool,
772 quiet: bool,
773 continue_on_error: bool,
774 batch_size: int,
775 timeout: int,
776) -> None:
777 """Validate options for materialize_all command."""
778 # Validate mutually exclusive options
779 if dry_run and force:
780 typer.echo('Error: Cannot use both --dry-run and --force options simultaneously', err=True)
781 raise typer.Exit(1)
783 if verbose and quiet:
784 typer.echo('Error: Cannot use both --verbose and --quiet options simultaneously', err=True)
785 raise typer.Exit(1)
787 if dry_run and continue_on_error:
788 typer.echo('Error: Cannot use --continue-on-error with --dry-run', err=True)
789 raise typer.Exit(1)
791 # Validate batch size
792 if batch_size <= 0:
793 typer.echo('Error: Batch size must be greater than zero', err=True)
794 raise typer.Exit(1)
796 # Validate timeout
797 if timeout <= 0:
798 typer.echo('Error: Timeout must be greater than zero', err=True)
799 raise typer.Exit(1)
802def _execute_dry_run_materialize(project_root: Path) -> None:
803 """Execute a dry run preview of placeholder materialization."""
804 binder_repo = BinderRepoFs(project_root)
805 binder = binder_repo.load()
806 placeholders = [item for item in binder.depth_first_traversal() if item.is_placeholder()]
808 if not placeholders:
809 typer.echo('No placeholders found in binder')
810 return
812 typer.echo(f'Would materialize {len(placeholders)} placeholders:')
813 for placeholder in placeholders:
814 typer.echo(f' - "{placeholder.display_title}"')
817def _execute_materialize_all(
818 project_root: Path,
819 *,
820 continue_on_error: bool = False,
821) -> None:
822 """Execute materialization of all placeholders."""
823 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
825 _materialize_all_placeholders(
826 project_root,
827 binder_repo,
828 node_repo,
829 id_generator,
830 clock,
831 logger,
832 json_output=False,
833 continue_on_error=continue_on_error,
834 )
837@app.command(name='materialize-all')
838def materialize_all(
839 *,
840 dry_run: Annotated[
841 bool, typer.Option('--dry-run', help='Preview what would be materialized without making changes')
842 ] = False,
843 force: Annotated[bool, typer.Option('--force', help='Force materialization even with warnings')] = False,
844 verbose: Annotated[bool, typer.Option('--verbose', help='Show detailed progress information')] = False,
845 quiet: Annotated[bool, typer.Option('--quiet', help='Suppress non-essential output')] = False,
846 continue_on_error: Annotated[
847 bool, typer.Option('--continue-on-error', help='Continue processing after errors')
848 ] = False,
849 batch_size: Annotated[
850 int, typer.Option('--batch-size', help='Number of placeholders to process in each batch')
851 ] = 10,
852 timeout: Annotated[int, typer.Option('--timeout', help='Timeout in seconds for each materialization')] = 60,
853 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
854) -> None:
855 """Materialize all placeholders in the binder."""
856 _validate_materialize_all_options(
857 dry_run=dry_run,
858 force=force,
859 verbose=verbose,
860 quiet=quiet,
861 continue_on_error=continue_on_error,
862 batch_size=batch_size,
863 timeout=timeout,
864 )
866 try:
867 project_root = path or _get_project_root()
869 # Validate that the path exists and is a directory
870 if not project_root.exists():
871 typer.echo('Error: Path does not exist', err=True)
872 raise typer.Exit(1)
874 if not project_root.is_dir():
875 typer.echo('Error: Path is not a directory', err=True)
876 raise typer.Exit(1)
878 if dry_run:
879 _execute_dry_run_materialize(project_root)
880 else:
881 _execute_materialize_all(project_root, continue_on_error=continue_on_error)
883 except PlaceholderNotFoundError:
884 typer.echo('Error: No placeholders found', err=True)
885 raise typer.Exit(1) from None
886 except RuntimeError as e:
887 typer.echo(f'Error: {e}', err=True)
888 raise typer.Exit(1) from None
889 except TimeoutError as e:
890 typer.echo(f'Error: Operation timed out - {e}', err=True)
891 raise typer.Exit(1) from None
892 except BinderFormatError as e:
893 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
894 raise typer.Exit(1) from None
895 except BinderNotFoundError:
896 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
897 raise typer.Exit(1) from None
898 except FileSystemError:
899 typer.echo('Error: File creation failed', err=True)
900 raise typer.Exit(2) from None
903@app.command()
904def audit( # noqa: C901, PLR0912
905 *,
906 fix: Annotated[bool, typer.Option('--fix', help='Attempt to fix discovered issues')] = False,
907 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
908) -> None:
909 """Check project integrity."""
910 try:
911 project_root = path or _get_project_root()
913 # Wire up dependencies
914 binder_repo = BinderRepoFs(project_root)
915 clock = ClockSystem()
916 editor_port = EditorLauncherSystem()
917 node_repo = NodeRepoFs(project_root, editor_port, clock)
918 logger = LoggerStdout()
920 # Execute use case
921 interactor = AuditBinder(
922 binder_repo=binder_repo,
923 node_repo=node_repo,
924 logger=logger,
925 )
927 report = interactor.execute()
929 # Always report placeholders if they exist (informational)
930 if report.placeholders:
931 for placeholder in report.placeholders:
932 typer.echo(f'⚠ PLACEHOLDER: "{placeholder.display_title}" (no associated files)')
934 # Report actual issues if they exist
935 has_real_issues = report.missing or report.orphans or report.mismatches
936 if has_real_issues:
937 if report.placeholders:
938 typer.echo('') # Add spacing after placeholders
939 typer.echo('Project integrity issues found:')
941 if report.missing:
942 for missing in report.missing:
943 typer.echo(f'⚠ MISSING: Node {missing.node_id} referenced but files not found')
945 if report.orphans:
946 for orphan in report.orphans:
947 typer.echo(f'⚠ ORPHAN: File {orphan.file_path} exists but not in binder')
949 if report.mismatches:
950 for mismatch in report.mismatches:
951 typer.echo(f'⚠ MISMATCH: File {mismatch.file_path} ID mismatch')
952 else:
953 # Show success messages for real issues when none exist
954 if report.placeholders:
955 typer.echo('') # Add spacing after placeholders
956 typer.echo('Project integrity check completed')
957 typer.echo('✓ All nodes have valid files')
958 typer.echo('✓ All references are consistent')
959 typer.echo('✓ No orphaned files found')
961 # Only exit with error code for real issues, not placeholders
962 if has_real_issues:
963 if fix:
964 typer.echo('\nNote: Auto-fix not implemented in MVP')
965 raise typer.Exit(2)
966 # Exit with code 1 when issues are found (standard audit behavior)
967 raise typer.Exit(1)
969 except FileSystemError as e:
970 typer.echo(f'Error: {e}', err=True)
971 raise typer.Exit(2) from e
974def main() -> None:
975 """Main entry point for the CLI."""
976 app()
979if __name__ == '__main__': # pragma: no cover
980 main()