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

1"""CLI command for adding nodes to the binder.""" 

2 

3from pathlib import Path 

4 

5import click 

6 

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) 

25 

26# Error handling constants 

27_INVALID_PARENT_EXIT_CODE = 1 

28_INVALID_POSITION_EXIT_CODE = 2 

29_FILE_SYSTEM_ERROR_EXIT_CODE = 3 

30 

31 

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() 

51 

52 # Handle template listing 

53 if list_templates: 

54 _handle_list_templates(project_root) 

55 return 

56 

57 # Handle template creation 

58 if template: 

59 _handle_template_creation(template, title, parent, position, project_root) 

60 return 

61 

62 # Auto-initialize project if it doesn't exist 

63 _ensure_project_initialized(project_root) 

64 

65 # Execute use case 

66 interactor = _create_add_node_interactor(project_root) 

67 

68 # Validate position if provided 

69 if position is not None and position < 0: 

70 _handle_invalid_position_error() 

71 

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 ) 

85 

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

90 

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) 

97 

98 

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 

105 

106 try: 

107 container = TemplatesContainer(templates_dir) 

108 use_case = container.list_templates_use_case 

109 result = use_case.list_all_templates() 

110 

111 if result['success']: 

112 total = result['total_templates'] 

113 if total == 0: 

114 click.echo('No templates found in ./templates directory') 

115 return 

116 

117 click.echo(f'Found {total} template(s):') 

118 

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

125 

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

134 

135 except (TemplateError, TemplateValidationError, FileSystemError) as err: 

136 _handle_template_access_error(str(err)) 

137 

138 

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) 

147 

148 try: 

149 # Initialize template system 

150 container = TemplatesContainer(templates_dir) 

151 create_use_case = container.create_from_template_use_case 

152 

153 # Try single template first 

154 result = create_use_case.create_single_template(template_name) 

155 

156 if result['success']: 

157 content = result['content'] 

158 

159 # Create node with template content 

160 _create_node_with_content(title, content, parent, position, project_root) 

161 

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) 

166 

167 if result['success']: 

168 content_map = result['content'] 

169 file_count = result['file_count'] 

170 

171 # Create multiple nodes from directory template 

172 _create_nodes_from_directory_template(title, content_map, parent, position, project_root) 

173 

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) 

179 

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

188 

189 

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) 

196 

197 # Wire up dependencies 

198 interactor = _create_add_node_interactor_with_console(project_root) 

199 

200 # Validate position if provided 

201 if position is not None and position < 0: 

202 _handle_invalid_position_error() 

203 

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) 

210 

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 ) 

218 

219 # Now write the template content to the node file 

220 _write_template_content_to_node(node_id, content, project_root) 

221 

222 click.echo(f'Created files: {node_id}.md, {node_id}.notes.md') 

223 click.echo('Updated binder structure') 

224 

225 

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 

232 

233 if content_map: 

234 first_content = next(iter(content_map.values())) 

235 _create_node_with_content(title, first_content, parent, position, project_root) 

236 

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

242 

243 

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' 

247 

248 try: 

249 # Read existing content to preserve frontmatter 

250 existing_content = node_file.read_text(encoding='utf-8') 

251 

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 

267 

268 # Write back to file 

269 node_file.write_text(new_content, encoding='utf-8') 

270 

271 except (FileSystemError, OSError) as err: 

272 _handle_template_content_write_error(str(err)) 

273 

274 

275# Utility functions to reduce local variables (PLR0914) 

276 

277 

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 

283 

284 binder_repo_init = BinderRepoFs(project_root) 

285 config_port = FileSystemConfigPort() 

286 console_port = ConsolePretty() 

287 logger_init = LoggerStdout() 

288 clock_init = ClockSystem() 

289 

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) 

298 

299 

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() 

308 

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 ) 

316 

317 

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() 

326 

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 ) 

334 

335 

336# Error handling helper functions to address TRY301 and B904 issues 

337 

338 

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) 

345 

346 

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 

351 

352 

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 

357 

358 

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 

363 

364 

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) 

369 

370 

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) 

375 

376 

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) 

381 

382 

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) 

387 

388 

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) 

393 

394 

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) 

399 

400 

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) 

405 

406 

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)