Coverage for src/prosemark/cli/main.py: 100%
451 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""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.editor_launcher_system import EditorLauncherSystem
21from prosemark.adapters.id_generator_uuid7 import IdGeneratorUuid7
22from prosemark.adapters.logger_stdout import LoggerStdout
23from prosemark.adapters.node_repo_fs import NodeRepoFs
25# Use case imports
26from prosemark.app.materialize_all_placeholders import MaterializeAllPlaceholders
27from prosemark.app.materialize_node import MaterializeNode
28from prosemark.app.use_cases import (
29 AddNode,
30 AuditBinder,
31 EditPart,
32 InitProject,
33 MoveNode,
34 RemoveNode,
35 ShowStructure,
36)
37from prosemark.app.use_cases import MaterializeNode as MaterializeNodeUseCase
38from prosemark.domain.batch_materialize_result import BatchMaterializeResult
40# Domain model imports
41from prosemark.domain.binder import Item
42from prosemark.domain.models import BinderItem, NodeId
44# Exception imports
45from prosemark.exceptions import (
46 AlreadyMaterializedError,
47 BinderFormatError,
48 BinderIntegrityError,
49 BinderNotFoundError,
50 EditorLaunchError,
51 FileSystemError,
52 NodeNotFoundError,
53 PlaceholderNotFoundError,
54)
56# Freewriting imports
57from prosemark.freewriting.container import run_freewriting_session
59# Port imports
60from prosemark.ports.config_port import ConfigPort, ProsemarkConfig
63# Protocol definitions
64class MaterializationResult(Protocol):
65 """Protocol for materialization process result objects.
67 Defines the expected interface for results of materialization operations,
68 capturing details about the process such as placeholders materialized,
69 failures encountered, and execution metadata.
70 """
72 total_placeholders: int
73 successful_materializations: list[Any]
74 failed_materializations: list[Any]
75 has_failures: bool
76 type: str | None
77 is_complete_success: bool
78 execution_time: float
79 message: str | None
80 summary_message: Callable[[], str]
83app = typer.Typer(
84 name='pmk',
85 help='Prosemark CLI - A hierarchical writing project manager',
86 add_completion=False,
87)
89# Alias for backward compatibility with tests
90cli = app
93class FileSystemConfigPort(ConfigPort):
94 """Temporary config port implementation."""
96 def create_default_config(self, config_path: Path) -> None:
97 """Create default configuration file."""
98 # For MVP, we don't need a config file
100 @staticmethod
101 def config_exists(config_path: Path) -> bool:
102 """Check if configuration file already exists."""
103 return config_path.exists()
105 @staticmethod
106 def get_default_config_values() -> ProsemarkConfig:
107 """Return default configuration values as dictionary."""
108 return {}
110 @staticmethod
111 def load_config(_config_path: Path) -> dict[str, Any]:
112 """Load configuration from file."""
113 return {}
116def _get_project_root() -> Path:
117 """Get the current project root directory."""
118 return Path.cwd()
121@app.command()
122def init(
123 title: Annotated[str, typer.Option('--title', '-t', help='Project title')],
124 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
125) -> None:
126 """Initialize a new prosemark project."""
127 try:
128 project_path = path or Path.cwd()
130 # Wire up dependencies
131 binder_repo = BinderRepoFs(project_path)
132 config_port = FileSystemConfigPort()
133 console_port = ConsolePretty()
134 logger = LoggerStdout()
135 clock = ClockSystem()
137 # Execute use case
138 interactor = InitProject(
139 binder_repo=binder_repo,
140 config_port=config_port,
141 console_port=console_port,
142 logger=logger,
143 clock=clock,
144 )
145 interactor.execute(project_path)
147 # Success output matching test expectations
148 typer.echo(f'Project "{title}" initialized successfully')
149 typer.echo('Created _binder.md with project structure')
151 except BinderIntegrityError:
152 typer.echo('Error: Directory already contains a prosemark project', err=True)
153 raise typer.Exit(1) from None
154 except FileSystemError as e:
155 typer.echo(f'Error: {e}', err=True)
156 raise typer.Exit(2) from e
157 except Exception as e:
158 typer.echo(f'Unexpected error: {e}', err=True)
159 raise typer.Exit(3) from e
162@app.command()
163def add(
164 title: Annotated[str, typer.Argument(help='Display title for the new node')],
165 parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID')] = None,
166 position: Annotated[int | None, typer.Option('--position', help="Position in parent's children")] = None,
167 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
168) -> None:
169 """Add a new node to the binder hierarchy."""
170 try:
171 project_root = path or _get_project_root()
173 # Wire up dependencies
174 binder_repo = BinderRepoFs(project_root)
175 clock = ClockSystem()
176 editor_port = EditorLauncherSystem()
177 node_repo = NodeRepoFs(project_root, editor_port, clock)
178 id_generator = IdGeneratorUuid7()
179 logger = LoggerStdout()
181 # Execute use case
182 interactor = AddNode(
183 binder_repo=binder_repo,
184 node_repo=node_repo,
185 id_generator=id_generator,
186 logger=logger,
187 clock=clock,
188 )
190 parent_id = NodeId(parent) if parent else None
191 node_id = interactor.execute(
192 title=title,
193 synopsis=None,
194 parent_id=parent_id,
195 position=position,
196 )
198 # Success output
199 typer.echo(f'Added "{title}" ({node_id})')
200 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
201 typer.echo('Updated binder structure')
203 except NodeNotFoundError:
204 typer.echo('Error: Parent node not found', err=True)
205 raise typer.Exit(1) from None
206 except ValueError:
207 typer.echo('Error: Invalid position index', err=True)
208 raise typer.Exit(2) from None
209 except FileSystemError as e:
210 typer.echo(f'Error: File creation failed - {e}', err=True)
211 raise typer.Exit(3) from e
214@app.command()
215def edit(
216 node_id: Annotated[str, typer.Argument(help='Node identifier')],
217 part: Annotated[str, typer.Option('--part', help='Content part to edit')] = 'draft',
218 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
219) -> None:
220 """Open node content in your preferred editor."""
221 try:
222 project_root = path or _get_project_root()
224 # Wire up dependencies
225 binder_repo = BinderRepoFs(project_root)
226 clock = ClockSystem()
227 editor_port = EditorLauncherSystem()
228 node_repo = NodeRepoFs(project_root, editor_port, clock)
229 logger = LoggerStdout()
231 # Execute use case
232 interactor = EditPart(
233 binder_repo=binder_repo,
234 node_repo=node_repo,
235 logger=logger,
236 )
238 interactor.execute(NodeId(node_id), part)
240 # Success output
241 if part == 'draft':
242 typer.echo(f'Opened {node_id}.md in editor')
243 elif part == 'notes':
244 typer.echo(f'Opened {node_id}.notes.md in editor')
245 else:
246 typer.echo(f'Opened {part} for {node_id} in editor')
248 except NodeNotFoundError:
249 typer.echo('Error: Node not found', err=True)
250 raise typer.Exit(1) from None
251 except EditorLaunchError:
252 typer.echo('Error: Editor not available', err=True)
253 raise typer.Exit(2) from None
254 except FileSystemError:
255 typer.echo('Error: File permission denied', err=True)
256 raise typer.Exit(3) from None
257 except ValueError as e:
258 typer.echo(f'Error: {e}', err=True)
259 raise typer.Exit(1) from e
262@app.command()
263def structure(
264 output_format: Annotated[str, typer.Option('--format', '-f', help='Output format')] = 'tree',
265 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
266) -> None:
267 """Display project hierarchy."""
268 try:
269 project_root = path or _get_project_root()
271 # Wire up dependencies
272 binder_repo = BinderRepoFs(project_root)
273 logger = LoggerStdout()
275 # Execute use case
276 interactor = ShowStructure(
277 binder_repo=binder_repo,
278 logger=logger,
279 )
281 structure_str = interactor.execute()
283 if output_format == 'tree':
284 typer.echo('Project Structure:')
285 typer.echo(structure_str)
286 elif output_format == 'json':
287 # For JSON format, we need to convert the tree to JSON
288 # This is a simplified version for MVP
289 import json
291 binder = binder_repo.load()
293 def item_to_dict(item: Item | BinderItem) -> dict[str, Any]:
294 result: dict[str, Any] = {
295 'display_title': item.display_title,
296 }
297 node_id = item.id if hasattr(item, 'id') else (item.node_id if hasattr(item, 'node_id') else None)
298 if node_id:
299 result['node_id'] = str(node_id)
300 item_children = item.children if hasattr(item, 'children') else []
301 if item_children:
302 result['children'] = [item_to_dict(child) for child in item_children]
303 return result
305 data: dict[str, list[dict[str, Any]]] = {'roots': [item_to_dict(item) for item in binder.roots]}
306 typer.echo(json.dumps(data, indent=2))
307 else:
308 typer.echo(f"Error: Unknown format '{output_format}'", err=True)
309 raise typer.Exit(1)
311 except FileSystemError as e:
312 typer.echo(f'Error: {e}', err=True)
313 raise typer.Exit(1) from e
316@app.command()
317def write(
318 node_uuid: Annotated[str | None, typer.Argument(help='UUID of target node (optional)')] = None,
319 title: Annotated[str | None, typer.Option('--title', '-t', help='Session title')] = None,
320 word_count_goal: Annotated[int | None, typer.Option('--words', '-w', help='Word count goal')] = None,
321 time_limit: Annotated[int | None, typer.Option('--time', help='Time limit in minutes')] = None,
322 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
323) -> None:
324 """Start a freewriting session in a distraction-free TUI."""
325 try:
326 project_root = path or _get_project_root()
328 # Run freewriting session with dependency injection
329 run_freewriting_session(
330 node_uuid=node_uuid,
331 title=title,
332 word_count_goal=word_count_goal,
333 time_limit=time_limit,
334 project_path=project_root,
335 )
337 except Exception as e:
338 typer.echo(f'Error: {e}', err=True)
339 raise typer.Exit(1) from e
342def _validate_materialize_args(title: str | None, *, all_placeholders: bool) -> None:
343 """Validate mutual exclusion of materialize arguments."""
344 if title and all_placeholders:
345 typer.echo("Error: Cannot specify both 'title' and '--all' options", err=True)
346 raise typer.Exit(1) from None
348 if not title and not all_placeholders:
349 typer.echo("Error: Must specify either placeholder 'title' or '--all' flag", err=True)
350 raise typer.Exit(1) from None
353def _create_shared_dependencies(
354 project_root: Path,
355) -> tuple[BinderRepoFs, ClockSystem, EditorLauncherSystem, NodeRepoFs, IdGeneratorUuid7, LoggerStdout]:
356 """Create shared dependencies for materialization operations."""
357 binder_repo = BinderRepoFs(project_root)
358 clock = ClockSystem()
359 editor_port = EditorLauncherSystem()
360 node_repo = NodeRepoFs(project_root, editor_port, clock)
361 id_generator = IdGeneratorUuid7()
362 logger = LoggerStdout()
363 return binder_repo, clock, editor_port, node_repo, id_generator, logger
366def _generate_json_result(result: MaterializationResult | BatchMaterializeResult, output_type: str) -> dict[str, Any]:
367 """Generate JSON result dictionary for materialization process."""
368 json_result: dict[str, Any] = {
369 'type': output_type,
370 'total_placeholders': result.total_placeholders,
371 'successful_materializations': len(result.successful_materializations),
372 'failed_materializations': len(result.failed_materializations),
373 'execution_time': result.execution_time,
374 }
376 # Add overall message
377 if result.total_placeholders == 0:
378 json_result['message'] = 'No placeholders found in binder'
379 elif len(result.failed_materializations) == 0:
380 json_result['message'] = f'Successfully materialized all {result.total_placeholders} placeholders'
381 else:
382 success_count = len(result.successful_materializations)
383 failure_count = len(result.failed_materializations)
384 json_result['message'] = (
385 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
386 )
388 # Add results based on type
389 if output_type == 'batch_partial':
390 json_result['successes'] = [
391 {'placeholder_title': success.display_title, 'node_id': str(success.node_id.value)}
392 for success in result.successful_materializations
393 ]
394 json_result['failures'] = [
395 {
396 'placeholder_title': failure.display_title,
397 'error_type': failure.error_type,
398 'error_message': failure.error_message,
399 }
400 for failure in result.failed_materializations
401 ]
402 elif result.successful_materializations or result.failed_materializations:
403 details_list: list[dict[str, str]] = []
404 details_list.extend(
405 {
406 'placeholder_title': success.display_title,
407 'node_id': str(success.node_id.value),
408 'status': 'success',
409 }
410 for success in result.successful_materializations
411 )
413 details_list.extend(
414 {
415 'placeholder_title': failure.display_title,
416 'error_type': failure.error_type,
417 'error_message': failure.error_message,
418 'status': 'failed',
419 }
420 for failure in result.failed_materializations
421 )
423 json_result['details'] = details_list
425 return json_result
428def _check_result_failure_status(
429 result: MaterializationResult | BatchMaterializeResult, *, continue_on_error: bool = False
430) -> None:
431 """Check and handle result failure status."""
432 has_failures = len(result.failed_materializations) > 0
434 if has_failures:
435 if not continue_on_error:
436 raise typer.Exit(1) from None
437 if len(result.successful_materializations) == 0:
438 raise typer.Exit(1) from None
441def _report_materialization_progress(
442 result: MaterializationResult | BatchMaterializeResult,
443 *,
444 json_output: bool = False,
445 progress_messages: list[str] | None = None,
446) -> None:
447 """Report materialization progress with human-readable or JSON output."""
448 progress_messages = progress_messages or []
449 if not json_output and not progress_messages:
450 if result.total_placeholders == 0:
451 typer.echo('No placeholders found to materialize')
452 else:
453 typer.echo(f'Found {result.total_placeholders} placeholders to materialize')
454 for success in result.successful_materializations:
455 typer.echo(f"✓ Materialized '{success.display_title}'")
456 for failure in result.failed_materializations:
457 typer.echo(f"✗ Failed to materialize '{failure.display_title}'")
458 typer.echo(failure.error_message)
461def _materialize_all_placeholders(
462 project_root: Path,
463 binder_repo: BinderRepoFs,
464 node_repo: NodeRepoFs,
465 id_generator: IdGeneratorUuid7,
466 clock: ClockSystem,
467 logger: LoggerStdout,
468 *,
469 json_output: bool = False,
470 continue_on_error: bool = False,
471) -> None:
472 """Execute batch materialization of all placeholders."""
473 console = ConsolePretty()
475 # Create individual materialize use case for delegation
476 materialize_node_use_case = MaterializeNode(
477 binder_repo=binder_repo,
478 node_repo=node_repo,
479 id_generator=id_generator,
480 clock=clock,
481 console=console,
482 logger=logger,
483 )
485 # Create batch use case
486 batch_interactor = MaterializeAllPlaceholders(
487 materialize_node_use_case=materialize_node_use_case,
488 binder_repo=binder_repo,
489 node_repo=node_repo,
490 id_generator=id_generator,
491 clock=clock,
492 logger=logger,
493 )
495 # Execute with progress callback and track messages
496 progress_messages: list[str] = []
498 def progress_callback(message: str) -> None:
499 progress_messages.append(message)
500 typer.echo(message)
502 result = batch_interactor.execute(
503 project_path=project_root,
504 progress_callback=progress_callback,
505 )
507 # Report progress messages
508 _report_materialization_progress(result, json_output=json_output, progress_messages=progress_messages)
510 # Report final results
511 if json_output:
512 import json
514 # Determine type based on results
515 output_type: str = 'batch_partial' if result.failed_materializations else 'batch'
516 json_result = _generate_json_result(result, output_type)
517 typer.echo(json.dumps(json_result, indent=2))
519 # Check for specific interruption types
520 result_type: str | None = getattr(result, 'type', None)
521 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
522 raise typer.Exit(1) from None
524 # Handle failures
525 _check_result_failure_status(result, continue_on_error=continue_on_error)
526 else:
527 if result.total_placeholders == 0:
528 return
530 result_type = getattr(result, 'type', None)
531 if result_type in {'batch_interrupted', 'batch_critical_failure'}:
532 raise typer.Exit(1) from None
534 success_count = len(result.successful_materializations)
535 _describe_materialization_result(result, success_count, continue_on_error=continue_on_error)
538def _describe_materialization_result(
539 result: MaterializationResult | BatchMaterializeResult, success_count: int, *, continue_on_error: bool = False
540) -> None:
541 """Describe materialization results with appropriate messaging."""
542 is_complete_success = len(result.failed_materializations) == 0 and result.total_placeholders > 0
544 if is_complete_success:
545 typer.echo(f'Successfully materialized all {result.total_placeholders} placeholders')
546 else:
547 # Retrieve or generate summary message
548 summary_msg = _get_summary_message(result, success_count)
549 typer.echo(summary_msg)
551 # Check for interrupted operations
552 _check_result_failure_status(result, continue_on_error=continue_on_error)
555def _get_safe_attribute(
556 result: MaterializationResult | BatchMaterializeResult, attr_name: str, default: str = ''
557) -> str:
558 """Safely retrieve an attribute from a result object."""
559 try:
560 value = getattr(result, attr_name, default)
561 if callable(value):
562 value_result = value()
563 return value_result if isinstance(value_result, str) else default
564 return value if isinstance(value, str) else default
565 except (TypeError, ValueError, AttributeError):
566 return default
569def _get_summary_message(result: MaterializationResult | BatchMaterializeResult, success_count: int) -> str:
570 """Get summary message for materialization results."""
571 # First try standard message retrieval methods
572 summary_msg = _get_safe_attribute(result, 'message')
573 if not summary_msg:
574 summary_msg = _get_safe_attribute(result, 'summary_message')
576 # If no standard method works, generate a manual summary
577 if not summary_msg:
578 failure_count = len(result.failed_materializations)
579 if failure_count == 1:
580 summary_msg = (
581 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failure)'
582 )
583 else:
584 summary_msg = (
585 f'Materialized {success_count} of {result.total_placeholders} placeholders ({failure_count} failures)'
586 )
588 return summary_msg
591def _materialize_single_placeholder(
592 title: str,
593 binder_repo: BinderRepoFs,
594 node_repo: NodeRepoFs,
595 id_generator: IdGeneratorUuid7,
596 logger: LoggerStdout,
597) -> None:
598 """Execute single materialization."""
599 interactor = MaterializeNodeUseCase(
600 binder_repo=binder_repo,
601 node_repo=node_repo,
602 id_generator=id_generator,
603 logger=logger,
604 )
606 node_id = interactor.execute(display_title=title, synopsis=None)
608 # Success output
609 typer.echo(f'Materialized "{title}" ({node_id})')
610 typer.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
611 typer.echo('Updated binder structure')
614@app.command()
615def materialize(
616 title: Annotated[str | None, typer.Argument(help='Display title of placeholder to materialize')] = None,
617 all_placeholders: Annotated[bool, typer.Option('--all', help='Materialize all placeholders in binder')] = False, # noqa: FBT002
618 _parent: Annotated[str | None, typer.Option('--parent', help='Parent node ID to search within')] = None,
619 json_output: Annotated[bool, typer.Option('--json', help='Output results in JSON format')] = False, # noqa: FBT002
620 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
621) -> None:
622 """Convert placeholder(s) to actual nodes."""
623 try:
624 _validate_materialize_args(title, all_placeholders=all_placeholders)
625 project_root = path or _get_project_root()
626 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
628 if all_placeholders:
629 _materialize_all_placeholders(
630 project_root, binder_repo, node_repo, id_generator, clock, logger, json_output=json_output
631 )
632 else:
633 # Ensure title is not None for type safety
634 if title is None:
635 typer.echo('Error: Title is required for single materialization', err=True)
636 raise typer.Exit(1) from None
637 _materialize_single_placeholder(title, binder_repo, node_repo, id_generator, logger)
639 except PlaceholderNotFoundError:
640 typer.echo('Error: Placeholder not found', err=True)
641 raise typer.Exit(1) from None
642 except AlreadyMaterializedError:
643 typer.echo(f"Error: '{title}' is already materialized", err=True)
644 raise typer.Exit(1) from None
645 except BinderFormatError as e:
646 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
647 raise typer.Exit(1) from None
648 except BinderNotFoundError:
649 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
650 raise typer.Exit(1) from None
651 except FileSystemError:
652 typer.echo('Error: File creation failed', err=True)
653 raise typer.Exit(2) from None
656@app.command()
657def move(
658 node_id: Annotated[str, typer.Argument(help='Node to move')],
659 parent: Annotated[str | None, typer.Option('--parent', help='New parent node')] = None,
660 position: Annotated[int | None, typer.Option('--position', help="Position in new parent's children")] = None,
661 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
662) -> None:
663 """Reorganize binder hierarchy."""
664 try:
665 project_root = path or _get_project_root()
667 # Wire up dependencies
668 binder_repo = BinderRepoFs(project_root)
669 logger = LoggerStdout()
671 # Execute use case
672 interactor = MoveNode(
673 binder_repo=binder_repo,
674 logger=logger,
675 )
677 parent_id = NodeId(parent) if parent else None
678 interactor.execute(
679 node_id=NodeId(node_id),
680 parent_id=parent_id,
681 position=position,
682 )
684 # Success output
685 parent_str = 'root' if parent is None else f'parent {parent}'
686 position_str = f' at position {position}' if position is not None else ''
687 typer.echo(f'Moved node to {parent_str}{position_str}')
688 typer.echo('Updated binder structure')
690 except NodeNotFoundError as e:
691 typer.echo(f'Error: {e}', err=True)
692 raise typer.Exit(1) from e
693 except ValueError:
694 typer.echo('Error: Invalid parent or position', err=True)
695 raise typer.Exit(2) from None
696 except BinderIntegrityError:
697 typer.echo('Error: Would create circular reference', err=True)
698 raise typer.Exit(3) from None
701@app.command()
702def remove(
703 node_id: Annotated[str, typer.Argument(help='Node to remove')],
704 *,
705 delete_files: Annotated[bool, typer.Option('--delete-files', help='Also delete node files')] = False,
706 force: Annotated[bool, typer.Option('--force', '-f', help='Skip confirmation prompt')] = False,
707 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
708) -> None:
709 """Remove a node from the binder."""
710 try:
711 project_root = path or _get_project_root()
713 # Confirmation prompt if not forced
714 if not force and delete_files:
715 confirm = typer.confirm(f'Really delete node {node_id} and its files?')
716 if not confirm:
717 typer.echo('Operation cancelled')
718 raise typer.Exit(2)
720 # Wire up dependencies
721 binder_repo = BinderRepoFs(project_root)
722 clock = ClockSystem()
723 editor_port = EditorLauncherSystem()
724 node_repo = NodeRepoFs(project_root, editor_port, clock)
725 logger = LoggerStdout()
727 # Execute use case
728 interactor = RemoveNode(
729 binder_repo=binder_repo,
730 node_repo=node_repo,
731 logger=logger,
732 )
734 # Get node title for output
735 binder = binder_repo.load()
736 target_item = binder.find_by_id(NodeId(node_id))
737 title = target_item.display_title if target_item else node_id
739 interactor.execute(NodeId(node_id), delete_files=delete_files)
741 # Success output
742 typer.echo(f'Removed "{title}" from binder')
743 if delete_files:
744 typer.echo(f'Deleted files: {node_id}.md, {node_id}.notes.md')
745 else:
746 typer.echo(f'Files preserved: {node_id}.md, {node_id}.notes.md')
748 except NodeNotFoundError:
749 typer.echo('Error: Node not found', err=True)
750 raise typer.Exit(1) from None
751 except FileSystemError:
752 typer.echo('Error: File deletion failed', err=True)
753 raise typer.Exit(3) from None
756def _validate_materialize_all_options(
757 *,
758 dry_run: bool,
759 force: bool,
760 verbose: bool,
761 quiet: bool,
762 continue_on_error: bool,
763 batch_size: int,
764 timeout: int,
765) -> None:
766 """Validate options for materialize_all command."""
767 # Validate mutually exclusive options
768 if dry_run and force:
769 typer.echo('Error: Cannot use both --dry-run and --force options simultaneously', err=True)
770 raise typer.Exit(1)
772 if verbose and quiet:
773 typer.echo('Error: Cannot use both --verbose and --quiet options simultaneously', err=True)
774 raise typer.Exit(1)
776 if dry_run and continue_on_error:
777 typer.echo('Error: Cannot use --continue-on-error with --dry-run', err=True)
778 raise typer.Exit(1)
780 # Validate batch size
781 if batch_size <= 0:
782 typer.echo('Error: Batch size must be greater than zero', err=True)
783 raise typer.Exit(1)
785 # Validate timeout
786 if timeout <= 0:
787 typer.echo('Error: Timeout must be greater than zero', err=True)
788 raise typer.Exit(1)
791def _execute_dry_run_materialize(project_root: Path) -> None:
792 """Execute a dry run preview of placeholder materialization."""
793 binder_repo = BinderRepoFs(project_root)
794 binder = binder_repo.load()
795 placeholders = [item for item in binder.depth_first_traversal() if item.is_placeholder()]
797 if not placeholders:
798 typer.echo('No placeholders found in binder')
799 return
801 typer.echo(f'Would materialize {len(placeholders)} placeholders:')
802 for placeholder in placeholders:
803 typer.echo(f' - "{placeholder.display_title}"')
806def _execute_materialize_all(
807 project_root: Path,
808 *,
809 continue_on_error: bool = False,
810) -> None:
811 """Execute materialization of all placeholders."""
812 binder_repo, clock, _editor_port, node_repo, id_generator, logger = _create_shared_dependencies(project_root)
814 _materialize_all_placeholders(
815 project_root,
816 binder_repo,
817 node_repo,
818 id_generator,
819 clock,
820 logger,
821 json_output=False,
822 continue_on_error=continue_on_error,
823 )
826@app.command(name='materialize-all')
827def materialize_all(
828 *,
829 dry_run: Annotated[
830 bool, typer.Option('--dry-run', help='Preview what would be materialized without making changes')
831 ] = False,
832 force: Annotated[bool, typer.Option('--force', help='Force materialization even with warnings')] = False,
833 verbose: Annotated[bool, typer.Option('--verbose', help='Show detailed progress information')] = False,
834 quiet: Annotated[bool, typer.Option('--quiet', help='Suppress non-essential output')] = False,
835 continue_on_error: Annotated[
836 bool, typer.Option('--continue-on-error', help='Continue processing after errors')
837 ] = False,
838 batch_size: Annotated[
839 int, typer.Option('--batch-size', help='Number of placeholders to process in each batch')
840 ] = 10,
841 timeout: Annotated[int, typer.Option('--timeout', help='Timeout in seconds for each materialization')] = 60,
842 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
843) -> None:
844 """Materialize all placeholders in the binder."""
845 _validate_materialize_all_options(
846 dry_run=dry_run,
847 force=force,
848 verbose=verbose,
849 quiet=quiet,
850 continue_on_error=continue_on_error,
851 batch_size=batch_size,
852 timeout=timeout,
853 )
855 try:
856 project_root = path or _get_project_root()
858 # Validate that the path exists and is a directory
859 if not project_root.exists():
860 typer.echo('Error: Path does not exist', err=True)
861 raise typer.Exit(1)
863 if not project_root.is_dir():
864 typer.echo('Error: Path is not a directory', err=True)
865 raise typer.Exit(1)
867 if dry_run:
868 _execute_dry_run_materialize(project_root)
869 else:
870 _execute_materialize_all(project_root, continue_on_error=continue_on_error)
872 except PlaceholderNotFoundError:
873 typer.echo('Error: No placeholders found', err=True)
874 raise typer.Exit(1) from None
875 except RuntimeError as e:
876 typer.echo(f'Error: {e}', err=True)
877 raise typer.Exit(1) from None
878 except TimeoutError as e:
879 typer.echo(f'Error: Operation timed out - {e}', err=True)
880 raise typer.Exit(1) from None
881 except BinderFormatError as e:
882 typer.echo(f'Error: Malformed binder structure - {e}', err=True)
883 raise typer.Exit(1) from None
884 except BinderNotFoundError:
885 typer.echo('Error: Binder file not found - No _binder.md file in directory', err=True)
886 raise typer.Exit(1) from None
887 except FileSystemError:
888 typer.echo('Error: File creation failed', err=True)
889 raise typer.Exit(2) from None
892@app.command()
893def audit( # noqa: C901, PLR0912
894 *,
895 fix: Annotated[bool, typer.Option('--fix', help='Attempt to fix discovered issues')] = False,
896 path: Annotated[Path | None, typer.Option('--path', '-p', help='Project directory')] = None,
897) -> None:
898 """Check project integrity."""
899 try:
900 project_root = path or _get_project_root()
902 # Wire up dependencies
903 binder_repo = BinderRepoFs(project_root)
904 clock = ClockSystem()
905 editor_port = EditorLauncherSystem()
906 node_repo = NodeRepoFs(project_root, editor_port, clock)
907 logger = LoggerStdout()
909 # Execute use case
910 interactor = AuditBinder(
911 binder_repo=binder_repo,
912 node_repo=node_repo,
913 logger=logger,
914 )
916 report = interactor.execute()
918 # Always report placeholders if they exist (informational)
919 if report.placeholders:
920 for placeholder in report.placeholders:
921 typer.echo(f'⚠ PLACEHOLDER: "{placeholder.display_title}" (no associated files)')
923 # Report actual issues if they exist
924 has_real_issues = report.missing or report.orphans or report.mismatches
925 if has_real_issues:
926 if report.placeholders:
927 typer.echo('') # Add spacing after placeholders
928 typer.echo('Project integrity issues found:')
930 if report.missing:
931 for missing in report.missing:
932 typer.echo(f'⚠ MISSING: Node {missing.node_id} referenced but files not found')
934 if report.orphans:
935 for orphan in report.orphans:
936 typer.echo(f'⚠ ORPHAN: File {orphan.file_path} exists but not in binder')
938 if report.mismatches:
939 for mismatch in report.mismatches:
940 typer.echo(f'⚠ MISMATCH: File {mismatch.file_path} ID mismatch')
941 else:
942 # Show success messages for real issues when none exist
943 if report.placeholders:
944 typer.echo('') # Add spacing after placeholders
945 typer.echo('Project integrity check completed')
946 typer.echo('✓ All nodes have valid files')
947 typer.echo('✓ All references are consistent')
948 typer.echo('✓ No orphaned files found')
950 # Only exit with error code for real issues, not placeholders
951 if has_real_issues:
952 if fix:
953 typer.echo('\nNote: Auto-fix not implemented in MVP')
954 raise typer.Exit(2)
955 # Exit with code 1 when issues are found (standard audit behavior)
956 raise typer.Exit(1)
958 except FileSystemError as e:
959 typer.echo(f'Error: {e}', err=True)
960 raise typer.Exit(2) from e
963def main() -> None:
964 """Main entry point for the CLI."""
965 app()
968if __name__ == '__main__': # pragma: no cover
969 main()