Coverage for src/prosemark/cli/add.py: 100%
216 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-10-01 00:05 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-10-01 00:05 +0000
1"""CLI command for adding nodes to the binder."""
3from pathlib import Path
5import click
7from prosemark.adapters.binder_repo_fs import BinderRepoFs
8from prosemark.adapters.clock_system import ClockSystem
9from prosemark.adapters.console_pretty import ConsolePretty
10from prosemark.adapters.editor_launcher_system import EditorLauncherSystem
11from prosemark.adapters.id_generator_uuid7 import IdGeneratorUuid7
12from prosemark.adapters.logger_stdout import LoggerStdout
13from prosemark.adapters.node_repo_fs import NodeRepoFs
14from prosemark.app.use_cases import AddNode, InitProject
15from prosemark.domain.models import NodeId
16from prosemark.exceptions import FileSystemError, NodeNotFoundError
17from prosemark.templates.container import TemplatesContainer
18from prosemark.templates.domain.exceptions.template_exceptions import (
19 TemplateNotFoundError as TemplateError,
20)
21from prosemark.templates.domain.exceptions.template_exceptions import (
22 TemplateValidationError,
23 UserCancelledError,
24)
26# Error handling constants
27_INVALID_PARENT_EXIT_CODE = 1
28_INVALID_POSITION_EXIT_CODE = 2
29_FILE_SYSTEM_ERROR_EXIT_CODE = 3
32@click.command()
33@click.argument('title')
34@click.option('--parent', help='Parent node ID')
35@click.option('--position', type=int, help="Position in parent's children")
36@click.option('--path', '-p', type=click.Path(path_type=Path), help='Project directory')
37@click.option('--template', help='Create node from template')
38@click.option('--list-templates', is_flag=True, help='List available templates')
39def add_command(
40 title: str,
41 *,
42 parent: str | None = None,
43 position: int | None = None,
44 path: Path | None = None,
45 template: str | None = None,
46 list_templates: bool = False,
47) -> None:
48 """Add a new node to the binder hierarchy, optionally from a template."""
49 try:
50 project_root = path or Path.cwd()
52 # Handle template listing
53 if list_templates:
54 _handle_list_templates(project_root)
55 return
57 # Handle template creation
58 if template:
59 _handle_template_creation(template, title, parent, position, project_root)
60 return
62 # Auto-initialize project if it doesn't exist
63 _ensure_project_initialized(project_root)
65 # Execute use case
66 interactor = _create_add_node_interactor(project_root)
68 # Validate position if provided
69 if position is not None and position < 0:
70 _handle_invalid_position_error()
72 parent_id = None
73 if parent:
74 try:
75 parent_id = NodeId(parent)
76 except ValueError as err:
77 # Invalid parent ID format, treat as "parent not found"
78 _handle_invalid_parent_error(err)
79 node_id = interactor.execute(
80 title=title,
81 synopsis=None,
82 parent_id=parent_id,
83 position=position,
84 )
86 # Success output
87 click.echo(f'Added "{title}" ({node_id})')
88 click.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
89 click.echo('Updated binder structure')
91 except NodeNotFoundError as err:
92 _handle_node_not_found_error(err)
93 except ValueError as err:
94 _handle_invalid_position_error(err)
95 except FileSystemError as err:
96 _handle_file_system_error(err)
99def _handle_list_templates(project_root: Path) -> None:
100 """Handle listing available templates."""
101 templates_dir = project_root / 'templates'
102 if not templates_dir.exists():
103 click.echo("No templates directory found. Create './templates' directory and add template files.")
104 return
106 try:
107 container = TemplatesContainer(templates_dir)
108 use_case = container.list_templates_use_case
109 result = use_case.list_all_templates()
111 if result['success']:
112 total = result['total_templates']
113 if total == 0:
114 click.echo('No templates found in ./templates directory')
115 return
117 click.echo(f'Found {total} template(s):')
119 # Single templates
120 single_templates = result['single_templates']
121 if single_templates['count'] > 0:
122 click.echo('\nSingle templates:')
123 for name in single_templates['names']:
124 click.echo(f' - {name}')
126 # Directory templates
127 directory_templates = result['directory_templates']
128 if directory_templates['count'] > 0:
129 click.echo('\nDirectory templates:')
130 for name in directory_templates['names']:
131 click.echo(f' - {name}')
132 else:
133 _handle_template_listing_error(result.get('error', 'Unknown error'))
135 except (TemplateError, TemplateValidationError, FileSystemError) as err:
136 _handle_template_access_error(str(err))
139def _handle_template_creation(
140 template_name: str, title: str, parent: str | None, position: int | None, project_root: Path
141) -> None:
142 """Handle creating node from template."""
143 templates_dir = project_root / 'templates'
144 if not templates_dir.exists():
145 click.echo("No templates directory found. Create './templates' directory and add template files.", err=True)
146 raise SystemExit(1)
148 try:
149 # Initialize template system
150 container = TemplatesContainer(templates_dir)
151 create_use_case = container.create_from_template_use_case
153 # Try single template first
154 result = create_use_case.create_single_template(template_name)
156 if result['success']:
157 content = result['content']
159 # Create node with template content
160 _create_node_with_content(title, content, parent, position, project_root)
162 click.echo(f'Created "{title}" from template "{template_name}"')
163 else:
164 # Try directory template
165 result = create_use_case.create_directory_template(template_name)
167 if result['success']:
168 content_map = result['content']
169 file_count = result['file_count']
171 # Create multiple nodes from directory template
172 _create_nodes_from_directory_template(title, content_map, parent, position, project_root)
174 click.echo(f'Created "{title}" with {file_count} files from directory template "{template_name}"')
175 else:
176 error_type = result.get('error_type', 'Unknown')
177 error_msg = result.get('error', 'Unknown error')
178 _handle_template_creation_error(error_type, error_msg)
180 except TemplateError:
181 _handle_template_not_found_error(template_name)
182 except TemplateValidationError as err:
183 _handle_template_validation_error(str(err))
184 except UserCancelledError:
185 _handle_user_cancelled_error()
186 except FileSystemError as err:
187 _handle_template_processing_error(str(err))
190def _create_node_with_content(
191 title: str, content: str, parent: str | None, position: int | None, project_root: Path
192) -> None:
193 """Create a node with templated content."""
194 # Auto-initialize project if it doesn't exist
195 _ensure_project_initialized(project_root)
197 # Wire up dependencies
198 interactor = _create_add_node_interactor_with_console(project_root)
200 # Validate position if provided
201 if position is not None and position < 0:
202 _handle_invalid_position_error()
204 parent_id = None
205 if parent:
206 try:
207 parent_id = NodeId(parent)
208 except ValueError as err:
209 _handle_invalid_parent_error(err)
211 # Create node normally first
212 node_id = interactor.execute(
213 title=title,
214 synopsis=None,
215 parent_id=parent_id,
216 position=position,
217 )
219 # Now write the template content to the node file
220 _write_template_content_to_node(node_id, content, project_root)
222 click.echo(f'Created files: {node_id}.md, {node_id}.notes.md')
223 click.echo('Updated binder structure')
226def _create_nodes_from_directory_template(
227 title: str, content_map: dict[str, str], parent: str | None, position: int | None, project_root: Path
228) -> None:
229 """Create multiple nodes from directory template."""
230 # This is a simplified implementation - creates main node with first file's content
231 # In a full implementation, you might create child nodes for each file
233 if content_map:
234 first_content = next(iter(content_map.values()))
235 _create_node_with_content(title, first_content, parent, position, project_root)
237 # Could extend to create child nodes for additional files in content_map
238 content_count = len(content_map)
239 single_file = 1
240 if content_count > single_file:
241 click.echo(f'Note: Directory template had {content_count} files. Only first file used for node content.')
244def _write_template_content_to_node(node_id: NodeId, content: str, project_root: Path) -> None:
245 """Write template content to an existing node file."""
246 node_file = project_root / f'{node_id}.md'
248 try:
249 # Read existing content to preserve frontmatter
250 existing_content = node_file.read_text(encoding='utf-8')
252 # Split into frontmatter and body
253 frontmatter_separator = '---'
254 frontmatter_parts_count = 3
255 if existing_content.startswith(frontmatter_separator):
256 parts = existing_content.split(frontmatter_separator, 2)
257 if len(parts) >= frontmatter_parts_count:
258 frontmatter = f'---{parts[1]}---'
259 # Replace body with template content
260 new_content = f'{frontmatter}\n\n{content}'
261 else:
262 # Malformed frontmatter, just append
263 new_content = f'{existing_content}\n\n{content}'
264 else:
265 # No frontmatter, just replace content
266 new_content = content
268 # Write back to file
269 node_file.write_text(new_content, encoding='utf-8')
271 except (FileSystemError, OSError) as err:
272 _handle_template_content_write_error(str(err))
275# Utility functions to reduce local variables (PLR0914)
278def _ensure_project_initialized(project_root: Path) -> None:
279 """Ensure project is initialized, create if it doesn't exist."""
280 binder_path = project_root / '_binder.md'
281 if not binder_path.exists():
282 from prosemark.cli.init import FileSystemConfigPort
284 binder_repo_init = BinderRepoFs(project_root)
285 config_port = FileSystemConfigPort()
286 console_port = ConsolePretty()
287 logger_init = LoggerStdout()
288 clock_init = ClockSystem()
290 init_interactor = InitProject(
291 binder_repo=binder_repo_init,
292 config_port=config_port,
293 console_port=console_port,
294 logger=logger_init,
295 clock=clock_init,
296 )
297 init_interactor.execute(project_root)
300def _create_add_node_interactor(project_root: Path) -> AddNode:
301 """Create AddNode interactor with dependencies."""
302 binder_repo = BinderRepoFs(project_root)
303 clock = ClockSystem()
304 editor = EditorLauncherSystem()
305 node_repo = NodeRepoFs(project_root, editor, clock)
306 id_generator = IdGeneratorUuid7()
307 logger = LoggerStdout()
309 return AddNode(
310 binder_repo=binder_repo,
311 node_repo=node_repo,
312 id_generator=id_generator,
313 logger=logger,
314 clock=clock,
315 )
318def _create_add_node_interactor_with_console(project_root: Path) -> AddNode:
319 """Create AddNode interactor with dependencies (console not needed)."""
320 binder_repo = BinderRepoFs(project_root)
321 clock = ClockSystem()
322 editor = EditorLauncherSystem()
323 node_repo = NodeRepoFs(project_root, editor, clock)
324 id_generator = IdGeneratorUuid7()
325 logger = LoggerStdout()
327 return AddNode(
328 binder_repo=binder_repo,
329 node_repo=node_repo,
330 id_generator=id_generator,
331 logger=logger,
332 clock=clock,
333 )
336# Error handling helper functions to address TRY301 and B904 issues
339def _handle_invalid_position_error(err: ValueError | None = None) -> None:
340 """Handle invalid position error."""
341 click.echo('Error: Invalid position index', err=True)
342 if err is not None:
343 raise SystemExit(_INVALID_POSITION_EXIT_CODE) from err
344 raise SystemExit(_INVALID_POSITION_EXIT_CODE)
347def _handle_invalid_parent_error(err: ValueError) -> None:
348 """Handle invalid parent node error."""
349 click.echo('Error: Parent node not found', err=True)
350 raise SystemExit(_INVALID_PARENT_EXIT_CODE) from err
353def _handle_node_not_found_error(err: NodeNotFoundError) -> None:
354 """Handle node not found error."""
355 click.echo('Error: Parent node not found', err=True)
356 raise SystemExit(_INVALID_PARENT_EXIT_CODE) from err
359def _handle_file_system_error(err: FileSystemError) -> None:
360 """Handle file system error."""
361 click.echo(f'Error: File creation failed - {err}', err=True)
362 raise SystemExit(_FILE_SYSTEM_ERROR_EXIT_CODE) from err
365def _handle_template_listing_error(error_msg: str) -> None:
366 """Handle template listing error."""
367 click.echo(f'Error listing templates: {error_msg}', err=True)
368 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
371def _handle_template_access_error(error_msg: str) -> None:
372 """Handle template access error."""
373 click.echo(f'Error accessing templates: {error_msg}', err=True)
374 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
377def _handle_template_creation_error(error_type: str, error_msg: str) -> None:
378 """Handle template creation error."""
379 click.echo(f'Template error ({error_type}): {error_msg}', err=True)
380 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
383def _handle_template_not_found_error(template_name: str) -> None:
384 """Handle template not found error."""
385 click.echo(f'Template "{template_name}" not found', err=True)
386 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
389def _handle_template_validation_error(error_msg: str) -> None:
390 """Handle template validation error."""
391 click.echo(f'Template validation error: {error_msg}', err=True)
392 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
395def _handle_user_cancelled_error() -> None:
396 """Handle user cancelled error."""
397 click.echo('Template creation cancelled by user')
398 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
401def _handle_template_processing_error(error_msg: str) -> None:
402 """Handle template processing error."""
403 click.echo(f'Template processing error: {error_msg}', err=True)
404 raise SystemExit(_INVALID_PARENT_EXIT_CODE)
407def _handle_template_content_write_error(error_msg: str) -> None:
408 """Handle template content write error."""
409 click.echo(f'Error writing template content: {error_msg}', err=True)
410 raise SystemExit(_INVALID_PARENT_EXIT_CODE)