base_commands.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. """Base commands for module: list, search, show, validate, generate."""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. from pathlib import Path
  6. from rich.prompt import Confirm
  7. from typer import Exit
  8. from ..config import ConfigManager
  9. from ..display import DisplayManager
  10. from ..exceptions import (
  11. TemplateRenderError,
  12. TemplateSyntaxError,
  13. TemplateValidationError,
  14. )
  15. from ..template import Template
  16. from ..validators import get_validator_registry
  17. from .helpers import (
  18. apply_cli_overrides,
  19. apply_var_file,
  20. apply_variable_defaults,
  21. collect_variable_values,
  22. )
  23. logger = logging.getLogger(__name__)
  24. # File size thresholds for display formatting
  25. BYTES_PER_KB = 1024
  26. BYTES_PER_MB = 1024 * 1024
  27. def list_templates(module_instance, raw: bool = False) -> list:
  28. """List all templates."""
  29. logger.debug(f"Listing templates for module '{module_instance.name}'")
  30. # Load all templates using centralized helper
  31. filtered_templates = module_instance._load_all_templates()
  32. if filtered_templates:
  33. if raw:
  34. # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
  35. # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
  36. for template in filtered_templates:
  37. name = template.metadata.name or "Unnamed Template"
  38. tags_list = template.metadata.tags or []
  39. tags = ",".join(tags_list) if tags_list else "-"
  40. version = (
  41. str(template.metadata.version) if template.metadata.version else "-"
  42. )
  43. library = template.metadata.library or "-"
  44. print(f"{template.id}\t{name}\t{tags}\t{version}\t{library}")
  45. else:
  46. # Output rich table format
  47. module_instance.display.display_templates_table(
  48. filtered_templates,
  49. module_instance.name,
  50. f"{module_instance.name.capitalize()} templates",
  51. )
  52. else:
  53. logger.info(f"No templates found for module '{module_instance.name}'")
  54. return filtered_templates
  55. def search_templates(module_instance, query: str) -> list:
  56. """Search for templates by ID containing the search string."""
  57. logger.debug(
  58. f"Searching templates for module '{module_instance.name}' with query='{query}'"
  59. )
  60. # Load templates with search filter using centralized helper
  61. filtered_templates = module_instance._load_all_templates(
  62. lambda t: query.lower() in t.id.lower()
  63. )
  64. if filtered_templates:
  65. logger.info(
  66. f"Found {len(filtered_templates)} templates matching '{query}' for module '{module_instance.name}'"
  67. )
  68. module_instance.display.display_templates_table(
  69. filtered_templates,
  70. module_instance.name,
  71. f"{module_instance.name.capitalize()} templates matching '{query}'",
  72. )
  73. else:
  74. logger.info(
  75. f"No templates found matching '{query}' for module '{module_instance.name}'"
  76. )
  77. module_instance.display.display_warning(
  78. f"No templates found matching '{query}'",
  79. context=f"module '{module_instance.name}'",
  80. )
  81. return filtered_templates
  82. def show_template(
  83. module_instance, id: str, var: list[str] | None = None, var_file: str | None = None
  84. ) -> None:
  85. """Show template details with optional variable overrides."""
  86. logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
  87. template = module_instance._load_template_by_id(id)
  88. if not template:
  89. module_instance.display.display_error(
  90. f"Template '{id}' not found", context=f"module '{module_instance.name}'"
  91. )
  92. return
  93. # Apply defaults and overrides (same precedence as generate command)
  94. if template.variables:
  95. config = ConfigManager()
  96. apply_variable_defaults(template, config, module_instance.name)
  97. apply_var_file(template, var_file, module_instance.display)
  98. apply_cli_overrides(template, var)
  99. # Re-sort sections after applying overrides (toggle values may have changed)
  100. template.variables.sort_sections()
  101. # Reset disabled bool variables to False to prevent confusion
  102. reset_vars = template.variables.reset_disabled_bool_variables()
  103. if reset_vars:
  104. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  105. module_instance.display.display_template(template, id)
  106. def check_output_directory(
  107. output_dir: Path,
  108. rendered_files: dict[str, str],
  109. interactive: bool,
  110. display: DisplayManager,
  111. ) -> list[Path] | None:
  112. """Check output directory for conflicts and get user confirmation if needed."""
  113. dir_exists = output_dir.exists()
  114. dir_not_empty = dir_exists and any(output_dir.iterdir())
  115. # Check which files already exist
  116. existing_files = []
  117. if dir_exists:
  118. for file_path in rendered_files:
  119. full_path = output_dir / file_path
  120. if full_path.exists():
  121. existing_files.append(full_path)
  122. # Warn if directory is not empty
  123. if dir_not_empty:
  124. if interactive:
  125. details = []
  126. if existing_files:
  127. details.append(f"{len(existing_files)} file(s) will be overwritten.")
  128. if not display.display_warning_with_confirmation(
  129. f"Directory '{output_dir}' is not empty.",
  130. details if details else None,
  131. default=False,
  132. ):
  133. display.display_info("Generation cancelled")
  134. return None
  135. else:
  136. # Non-interactive mode: show warning but continue
  137. logger.warning(f"Directory '{output_dir}' is not empty")
  138. if existing_files:
  139. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  140. return existing_files
  141. def get_generation_confirmation(
  142. output_dir: Path,
  143. rendered_files: dict[str, str],
  144. existing_files: list[Path] | None,
  145. dir_not_empty: bool,
  146. dry_run: bool,
  147. interactive: bool,
  148. display: DisplayManager,
  149. ) -> bool:
  150. """Display file generation confirmation and get user approval."""
  151. if not interactive:
  152. return True
  153. display.display_file_generation_confirmation(
  154. output_dir, rendered_files, existing_files if existing_files else None
  155. )
  156. # Final confirmation (only if we didn't already ask about overwriting)
  157. if (
  158. not dir_not_empty
  159. and not dry_run
  160. and not Confirm.ask("Generate these files?", default=True)
  161. ):
  162. display.display_info("Generation cancelled")
  163. return False
  164. return True
  165. def _check_directory_permissions(output_dir: Path, display: DisplayManager) -> None:
  166. """Check directory existence and write permissions."""
  167. if output_dir.exists():
  168. display.display_success(f"Output directory exists: [cyan]{output_dir}[/cyan]")
  169. if os.access(output_dir, os.W_OK):
  170. display.display_success("Write permission verified")
  171. else:
  172. display.display_warning("Write permission may be denied")
  173. else:
  174. display.display_info(
  175. f" [dim]→[/dim] Would create output directory: [cyan]{output_dir}[/cyan]"
  176. )
  177. parent = output_dir.parent
  178. if parent.exists() and os.access(parent, os.W_OK):
  179. display.display_success("Parent directory writable")
  180. else:
  181. display.display_warning("Parent directory may not be writable")
  182. def _collect_subdirectories(rendered_files: dict[str, str]) -> set[Path]:
  183. """Collect unique subdirectories from file paths."""
  184. subdirs = set()
  185. for file_path in rendered_files:
  186. parts = Path(file_path).parts
  187. for i in range(1, len(parts)):
  188. subdirs.add(Path(*parts[:i]))
  189. return subdirs
  190. def _analyze_file_operations(
  191. output_dir: Path, rendered_files: dict[str, str]
  192. ) -> tuple[list[tuple[str, int, str]], int, int, int]:
  193. """Analyze file operations and return statistics."""
  194. total_size = 0
  195. new_files = 0
  196. overwrite_files = 0
  197. file_operations = []
  198. for file_path, content in sorted(rendered_files.items()):
  199. full_path = output_dir / file_path
  200. file_size = len(content.encode("utf-8"))
  201. total_size += file_size
  202. if full_path.exists():
  203. status = "Overwrite"
  204. overwrite_files += 1
  205. else:
  206. status = "Create"
  207. new_files += 1
  208. file_operations.append((file_path, file_size, status))
  209. return file_operations, total_size, new_files, overwrite_files
  210. def _format_size(total_size: int) -> str:
  211. """Format byte size into human-readable string."""
  212. if total_size < BYTES_PER_KB:
  213. return f"{total_size}B"
  214. elif total_size < BYTES_PER_MB:
  215. return f"{total_size / BYTES_PER_KB:.1f}KB"
  216. else:
  217. return f"{total_size / BYTES_PER_MB:.1f}MB"
  218. def execute_dry_run(
  219. id: str,
  220. output_dir: Path,
  221. rendered_files: dict[str, str],
  222. show_files: bool,
  223. display: DisplayManager,
  224. ) -> None:
  225. """Execute dry run mode with comprehensive simulation."""
  226. display.display_info("")
  227. display.display_info(
  228. "[bold cyan]Dry Run Mode - Simulating File Generation[/bold cyan]"
  229. )
  230. display.display_info("")
  231. # Simulate directory creation
  232. display.heading("Directory Operations")
  233. _check_directory_permissions(output_dir, display)
  234. # Collect and display subdirectories
  235. subdirs = _collect_subdirectories(rendered_files)
  236. if subdirs:
  237. display.display_info(
  238. f" [dim]→[/dim] Would create {len(subdirs)} subdirectory(ies)"
  239. )
  240. for subdir in sorted(subdirs):
  241. display.display_info(f" [dim]📁[/dim] {subdir}/")
  242. display.display_info("")
  243. # Display file operations in a table
  244. display.heading("File Operations")
  245. file_operations, total_size, new_files, overwrite_files = _analyze_file_operations(
  246. output_dir, rendered_files
  247. )
  248. display.display_file_operation_table(file_operations)
  249. display.display_info("")
  250. # Summary statistics
  251. size_str = _format_size(total_size)
  252. summary_items = {
  253. "Total files:": str(len(rendered_files)),
  254. "New files:": str(new_files),
  255. "Files to overwrite:": str(overwrite_files),
  256. "Total size:": size_str,
  257. }
  258. display.display_summary_table("Summary", summary_items)
  259. display.display_info("")
  260. # Show file contents if requested
  261. if show_files:
  262. display.display_info("[bold cyan]Generated File Contents:[/bold cyan]")
  263. display.display_info("")
  264. for file_path, content in sorted(rendered_files.items()):
  265. display.display_info(f"[cyan]File:[/cyan] {file_path}")
  266. display.display_info(f"{'─' * 80}")
  267. display.display_info(content)
  268. display.display_info("") # Add blank line after content
  269. display.display_info("")
  270. display.display_success("Dry run complete - no files were written")
  271. display.display_info(
  272. f"[dim]Files would have been generated in '{output_dir}'[/dim]"
  273. )
  274. logger.info(
  275. f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes"
  276. )
  277. def write_generated_files(
  278. output_dir: Path,
  279. rendered_files: dict[str, str],
  280. quiet: bool,
  281. display: DisplayManager,
  282. ) -> None:
  283. """Write rendered files to the output directory."""
  284. output_dir.mkdir(parents=True, exist_ok=True)
  285. for file_path, content in rendered_files.items():
  286. full_path = output_dir / file_path
  287. full_path.parent.mkdir(parents=True, exist_ok=True)
  288. with open(full_path, "w", encoding="utf-8") as f:
  289. f.write(content)
  290. if not quiet:
  291. display.display_success(f"Generated file: {file_path}")
  292. if not quiet:
  293. display.display_success(f"Template generated successfully in '{output_dir}'")
  294. logger.info(f"Template written to directory: {output_dir}")
  295. def _prepare_template(
  296. module_instance,
  297. id: str,
  298. var_file: str | None,
  299. var: list[str] | None,
  300. display: DisplayManager,
  301. ):
  302. """Load template and apply all defaults/overrides."""
  303. template = module_instance._load_template_by_id(id)
  304. config = ConfigManager()
  305. apply_variable_defaults(template, config, module_instance.name)
  306. apply_var_file(template, var_file, display)
  307. apply_cli_overrides(template, var)
  308. if template.variables:
  309. template.variables.sort_sections()
  310. reset_vars = template.variables.reset_disabled_bool_variables()
  311. if reset_vars:
  312. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  313. return template
  314. def _render_template(template, id: str, display: DisplayManager, interactive: bool):
  315. """Validate, render template and collect variable values."""
  316. variable_values = collect_variable_values(template, interactive)
  317. if template.variables:
  318. template.variables.validate_all()
  319. debug_mode = logger.isEnabledFor(logging.DEBUG)
  320. rendered_files, variable_values = template.render(
  321. template.variables, debug=debug_mode
  322. )
  323. if not rendered_files:
  324. display.display_error(
  325. "Template rendering returned no files",
  326. context="template generation",
  327. )
  328. raise Exit(code=1)
  329. logger.info(f"Successfully rendered template '{id}'")
  330. return rendered_files, variable_values
  331. def _determine_output_dir(directory: str | None, id: str) -> Path:
  332. """Determine and normalize output directory path."""
  333. if directory:
  334. output_dir = Path(directory)
  335. if not output_dir.is_absolute() and str(output_dir).startswith(
  336. ("Users/", "home/", "usr/", "opt/", "var/", "tmp/")
  337. ):
  338. output_dir = Path("/") / output_dir
  339. logger.debug(f"Normalized relative-looking absolute path to: {output_dir}")
  340. else:
  341. output_dir = Path(id)
  342. return output_dir
  343. def generate_template(
  344. module_instance,
  345. id: str,
  346. directory: str | None,
  347. interactive: bool,
  348. var: list[str] | None,
  349. var_file: str | None,
  350. dry_run: bool,
  351. show_files: bool,
  352. quiet: bool,
  353. ) -> None:
  354. """Generate from template."""
  355. logger.info(
  356. f"Starting generation for template '{id}' from module '{module_instance.name}'"
  357. )
  358. display = DisplayManager(quiet=quiet) if quiet else module_instance.display
  359. template = _prepare_template(module_instance, id, var_file, var, display)
  360. if not quiet:
  361. module_instance.display.display_template(template, id)
  362. module_instance.display.display_info("")
  363. try:
  364. rendered_files, variable_values = _render_template(
  365. template, id, display, interactive
  366. )
  367. output_dir = _determine_output_dir(directory, id)
  368. # Check for conflicts and get confirmation (skip in quiet mode)
  369. if not quiet:
  370. existing_files = check_output_directory(
  371. output_dir, rendered_files, interactive, display
  372. )
  373. if existing_files is None:
  374. return # User cancelled
  375. dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
  376. if not get_generation_confirmation(
  377. output_dir,
  378. rendered_files,
  379. existing_files,
  380. dir_not_empty,
  381. dry_run,
  382. interactive,
  383. display,
  384. ):
  385. return # User cancelled
  386. # Execute generation (dry run or actual)
  387. if dry_run:
  388. if not quiet:
  389. execute_dry_run(id, output_dir, rendered_files, show_files, display)
  390. else:
  391. write_generated_files(output_dir, rendered_files, quiet, display)
  392. # Display next steps (not in quiet mode)
  393. if template.metadata.next_steps and not quiet:
  394. display.display_next_steps(template.metadata.next_steps, variable_values)
  395. except TemplateRenderError as e:
  396. display.display_template_render_error(e, context=f"template '{id}'")
  397. raise Exit(code=1) from None
  398. except Exception as e:
  399. display.display_error(str(e), context=f"generating template '{id}'")
  400. raise Exit(code=1) from None
  401. def validate_templates(
  402. module_instance,
  403. template_id: str,
  404. path: str | None,
  405. verbose: bool,
  406. semantic: bool,
  407. ) -> None:
  408. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
  409. # Load template based on input
  410. template = _load_template_for_validation(module_instance, template_id, path)
  411. if template:
  412. _validate_single_template(
  413. module_instance, template, template_id, verbose, semantic
  414. )
  415. else:
  416. _validate_all_templates(module_instance, verbose)
  417. def _load_template_for_validation(module_instance, template_id: str, path: str | None):
  418. """Load a template from path or ID for validation."""
  419. if path:
  420. template_path = Path(path).resolve()
  421. if not template_path.exists():
  422. module_instance.display.display_error(f"Path does not exist: {path}")
  423. raise Exit(code=1) from None
  424. if not template_path.is_dir():
  425. module_instance.display.display_error(f"Path is not a directory: {path}")
  426. raise Exit(code=1) from None
  427. module_instance.display.display_info(
  428. f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]"
  429. )
  430. try:
  431. return Template(template_path, library_name="local")
  432. except Exception as e:
  433. module_instance.display.display_error(
  434. f"Failed to load template from path '{path}': {e}"
  435. )
  436. raise Exit(code=1) from None
  437. if template_id:
  438. try:
  439. template = module_instance._load_template_by_id(template_id)
  440. module_instance.display.display_info(
  441. f"[bold]Validating template:[/bold] [cyan]{template_id}[/cyan]"
  442. )
  443. return template
  444. except Exception as e:
  445. module_instance.display.display_error(
  446. f"Failed to load template '{template_id}': {e}"
  447. )
  448. raise Exit(code=1) from None
  449. return None
  450. def _validate_single_template(
  451. module_instance, template, template_id: str, verbose: bool, semantic: bool
  452. ) -> None:
  453. """Validate a single template."""
  454. try:
  455. # Jinja2 validation
  456. _ = template.used_variables
  457. _ = template.variables
  458. module_instance.display.display_success("Jinja2 validation passed")
  459. # Semantic validation
  460. if semantic:
  461. _run_semantic_validation(module_instance, template, verbose)
  462. # Verbose output
  463. if verbose:
  464. _display_validation_details(module_instance, template, semantic)
  465. except TemplateRenderError as e:
  466. module_instance.display.display_template_render_error(
  467. e, context=f"template '{template_id}'"
  468. )
  469. raise Exit(code=1) from None
  470. except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
  471. module_instance.display.display_error(f"Validation failed for '{template_id}':")
  472. module_instance.display.display_info(f"\n{e}")
  473. raise Exit(code=1) from None
  474. except Exception as e:
  475. module_instance.display.display_error(
  476. f"Unexpected error validating '{template_id}': {e}"
  477. )
  478. raise Exit(code=1) from None
  479. def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
  480. """Run semantic validation on rendered template files."""
  481. module_instance.display.display_info("")
  482. module_instance.display.display_info(
  483. "[bold cyan]Running semantic validation...[/bold cyan]"
  484. )
  485. registry = get_validator_registry()
  486. debug_mode = logger.isEnabledFor(logging.DEBUG)
  487. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  488. has_semantic_errors = False
  489. for file_path, content in rendered_files.items():
  490. result = registry.validate_file(content, file_path)
  491. if result.errors or result.warnings or (verbose and result.info):
  492. module_instance.display.display_info(f"\n[cyan]File:[/cyan] {file_path}")
  493. result.display(f"{file_path}")
  494. if result.errors:
  495. has_semantic_errors = True
  496. if has_semantic_errors:
  497. module_instance.display.display_error("Semantic validation found errors")
  498. raise Exit(code=1) from None
  499. module_instance.display.display_success("Semantic validation passed")
  500. def _display_validation_details(module_instance, template, semantic: bool) -> None:
  501. """Display verbose validation details."""
  502. module_instance.display.display_info(
  503. f"\n[dim]Template path: {template.template_dir}[/dim]"
  504. )
  505. module_instance.display.display_info(
  506. f"[dim]Found {len(template.used_variables)} variables[/dim]"
  507. )
  508. if semantic:
  509. debug_mode = logger.isEnabledFor(logging.DEBUG)
  510. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  511. module_instance.display.display_info(
  512. f"[dim]Generated {len(rendered_files)} files[/dim]"
  513. )
  514. def _validate_all_templates(module_instance, verbose: bool) -> None:
  515. """Validate all templates in the module."""
  516. module_instance.display.display_info(
  517. f"[bold]Validating all {module_instance.name} templates...[/bold]"
  518. )
  519. valid_count = 0
  520. invalid_count = 0
  521. errors = []
  522. all_templates = module_instance._load_all_templates()
  523. total = len(all_templates)
  524. for template in all_templates:
  525. try:
  526. _ = template.used_variables
  527. _ = template.variables
  528. valid_count += 1
  529. if verbose:
  530. module_instance.display.display_success(template.id)
  531. except ValueError as e:
  532. invalid_count += 1
  533. errors.append((template.id, str(e)))
  534. if verbose:
  535. module_instance.display.display_error(template.id)
  536. except Exception as e:
  537. invalid_count += 1
  538. errors.append((template.id, f"Load error: {e}"))
  539. if verbose:
  540. module_instance.display.display_warning(template.id)
  541. # Display summary
  542. summary_items = {
  543. "Total templates:": str(total),
  544. "[green]Valid:[/green]": str(valid_count),
  545. "[red]Invalid:[/red]": str(invalid_count),
  546. }
  547. module_instance.display.display_summary_table("Validation Summary", summary_items)
  548. if errors:
  549. module_instance.display.display_info("")
  550. module_instance.display.display_error("Validation Errors:")
  551. for template_id, error_msg in errors:
  552. module_instance.display.display_info(
  553. f"\n[yellow]Template:[/yellow] [cyan]{template_id}[/cyan]"
  554. )
  555. module_instance.display.display_info(f"[dim]{error_msg}[/dim]")
  556. raise Exit(code=1)
  557. module_instance.display.display_success("All templates are valid!")