base_commands.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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(module_instance, id: str) -> None:
  83. """Show template details."""
  84. logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
  85. template = module_instance._load_template_by_id(id)
  86. if not template:
  87. module_instance.display.display_error(
  88. f"Template '{id}' not found", context=f"module '{module_instance.name}'"
  89. )
  90. return
  91. # Apply config defaults (same as in generate)
  92. # This ensures the display shows the actual defaults that will be used
  93. if template.variables:
  94. config = ConfigManager()
  95. config_defaults = config.get_defaults(module_instance.name)
  96. if config_defaults:
  97. logger.debug(f"Loading config defaults for module '{module_instance.name}'")
  98. # Apply config defaults (this respects the variable types and validation)
  99. successful = template.variables.apply_defaults(config_defaults, "config")
  100. if successful:
  101. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  102. # Re-sort sections after applying config (toggle values may have changed)
  103. template.variables.sort_sections()
  104. # Reset disabled bool variables to False to prevent confusion
  105. reset_vars = template.variables.reset_disabled_bool_variables()
  106. if reset_vars:
  107. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  108. module_instance.display.display_template(template, id)
  109. def check_output_directory(
  110. output_dir: Path,
  111. rendered_files: dict[str, str],
  112. interactive: bool,
  113. display: DisplayManager,
  114. ) -> list[Path] | None:
  115. """Check output directory for conflicts and get user confirmation if needed."""
  116. dir_exists = output_dir.exists()
  117. dir_not_empty = dir_exists and any(output_dir.iterdir())
  118. # Check which files already exist
  119. existing_files = []
  120. if dir_exists:
  121. for file_path in rendered_files:
  122. full_path = output_dir / file_path
  123. if full_path.exists():
  124. existing_files.append(full_path)
  125. # Warn if directory is not empty
  126. if dir_not_empty:
  127. if interactive:
  128. details = []
  129. if existing_files:
  130. details.append(f"{len(existing_files)} file(s) will be overwritten.")
  131. if not display.display_warning_with_confirmation(
  132. f"Directory '{output_dir}' is not empty.",
  133. details if details else None,
  134. default=False,
  135. ):
  136. display.display_info("Generation cancelled")
  137. return None
  138. else:
  139. # Non-interactive mode: show warning but continue
  140. logger.warning(f"Directory '{output_dir}' is not empty")
  141. if existing_files:
  142. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  143. return existing_files
  144. def get_generation_confirmation(
  145. output_dir: Path,
  146. rendered_files: dict[str, str],
  147. existing_files: list[Path] | None,
  148. dir_not_empty: bool,
  149. dry_run: bool,
  150. interactive: bool,
  151. display: DisplayManager,
  152. ) -> bool:
  153. """Display file generation confirmation and get user approval."""
  154. if not interactive:
  155. return True
  156. display.display_file_generation_confirmation(
  157. output_dir, rendered_files, existing_files if existing_files else None
  158. )
  159. # Final confirmation (only if we didn't already ask about overwriting)
  160. if (
  161. not dir_not_empty
  162. and not dry_run
  163. and not Confirm.ask("Generate these files?", default=True)
  164. ):
  165. display.display_info("Generation cancelled")
  166. return False
  167. return True
  168. def execute_dry_run(
  169. id: str,
  170. output_dir: Path,
  171. rendered_files: dict[str, str],
  172. show_files: bool,
  173. display: DisplayManager,
  174. ) -> None:
  175. """Execute dry run mode with comprehensive simulation."""
  176. display.display_info("")
  177. display.display_info(
  178. "[bold cyan]Dry Run Mode - Simulating File Generation[/bold cyan]"
  179. )
  180. display.display_info("")
  181. # Simulate directory creation
  182. display.heading("Directory Operations")
  183. # Check if output directory exists
  184. if output_dir.exists():
  185. display.display_success(f"Output directory exists: [cyan]{output_dir}[/cyan]")
  186. # Check if we have write permissions
  187. if os.access(output_dir, os.W_OK):
  188. display.display_success("Write permission verified")
  189. else:
  190. display.display_warning("Write permission may be denied")
  191. else:
  192. display.display_info(
  193. f" [dim]→[/dim] Would create output directory: [cyan]{output_dir}[/cyan]"
  194. )
  195. # Check if parent directory exists and is writable
  196. parent = output_dir.parent
  197. if parent.exists() and os.access(parent, os.W_OK):
  198. display.display_success("Parent directory writable")
  199. else:
  200. display.display_warning("Parent directory may not be writable")
  201. # Collect unique subdirectories that would be created
  202. subdirs = set()
  203. for file_path in rendered_files:
  204. parts = Path(file_path).parts
  205. for i in range(1, len(parts)):
  206. subdirs.add(Path(*parts[:i]))
  207. if subdirs:
  208. display.display_info(
  209. f" [dim]→[/dim] Would create {len(subdirs)} subdirectory(ies)"
  210. )
  211. for subdir in sorted(subdirs):
  212. display.display_info(f" [dim]📁[/dim] {subdir}/")
  213. display.display_info("")
  214. # Display file operations in a table
  215. display.heading("File Operations")
  216. total_size = 0
  217. new_files = 0
  218. overwrite_files = 0
  219. file_operations = []
  220. for file_path, content in sorted(rendered_files.items()):
  221. full_path = output_dir / file_path
  222. file_size = len(content.encode("utf-8"))
  223. total_size += file_size
  224. # Determine status
  225. if full_path.exists():
  226. status = "Overwrite"
  227. overwrite_files += 1
  228. else:
  229. status = "Create"
  230. new_files += 1
  231. file_operations.append((file_path, file_size, status))
  232. display.display_file_operation_table(file_operations)
  233. display.display_info("")
  234. # Summary statistics
  235. if total_size < BYTES_PER_KB:
  236. size_str = f"{total_size}B"
  237. elif total_size < BYTES_PER_MB:
  238. size_str = f"{total_size / BYTES_PER_KB:.1f}KB"
  239. else:
  240. size_str = f"{total_size / BYTES_PER_MB:.1f}MB"
  241. summary_items = {
  242. "Total files:": str(len(rendered_files)),
  243. "New files:": str(new_files),
  244. "Files to overwrite:": str(overwrite_files),
  245. "Total size:": size_str,
  246. }
  247. display.display_summary_table("Summary", summary_items)
  248. display.display_info("")
  249. # Show file contents if requested
  250. if show_files:
  251. display.display_info("[bold cyan]Generated File Contents:[/bold cyan]")
  252. display.display_info("")
  253. for file_path, content in sorted(rendered_files.items()):
  254. display.display_info(f"[cyan]File:[/cyan] {file_path}")
  255. display.display_info(f"{'─' * 80}")
  256. display.display_info(content)
  257. display.display_info("") # Add blank line after content
  258. display.display_info("")
  259. display.display_success("Dry run complete - no files were written")
  260. display.display_info(
  261. f"[dim]Files would have been generated in '{output_dir}'[/dim]"
  262. )
  263. logger.info(
  264. f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes"
  265. )
  266. def write_generated_files(
  267. output_dir: Path,
  268. rendered_files: dict[str, str],
  269. quiet: bool,
  270. display: DisplayManager,
  271. ) -> None:
  272. """Write rendered files to the output directory."""
  273. output_dir.mkdir(parents=True, exist_ok=True)
  274. for file_path, content in rendered_files.items():
  275. full_path = output_dir / file_path
  276. full_path.parent.mkdir(parents=True, exist_ok=True)
  277. with open(full_path, "w", encoding="utf-8") as f:
  278. f.write(content)
  279. if not quiet:
  280. display.display_success(f"Generated file: {file_path}")
  281. if not quiet:
  282. display.display_success(f"Template generated successfully in '{output_dir}'")
  283. logger.info(f"Template written to directory: {output_dir}")
  284. def generate_template(
  285. module_instance,
  286. id: str,
  287. directory: str | None,
  288. interactive: bool,
  289. var: list[str] | None,
  290. var_file: str | None,
  291. dry_run: bool,
  292. show_files: bool,
  293. quiet: bool,
  294. ) -> None:
  295. """Generate from template."""
  296. logger.info(
  297. f"Starting generation for template '{id}' from module '{module_instance.name}'"
  298. )
  299. # Create a display manager with quiet mode if needed
  300. display = DisplayManager(quiet=quiet) if quiet else module_instance.display
  301. template = module_instance._load_template_by_id(id)
  302. # Apply defaults and overrides (in precedence order)
  303. config = ConfigManager()
  304. apply_variable_defaults(template, config, module_instance.name)
  305. apply_var_file(template, var_file, display)
  306. apply_cli_overrides(template, var)
  307. # Re-sort sections after all overrides (toggle values may have changed)
  308. if template.variables:
  309. template.variables.sort_sections()
  310. # Reset disabled bool variables to False to prevent confusion
  311. reset_vars = template.variables.reset_disabled_bool_variables()
  312. if reset_vars:
  313. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  314. if not quiet:
  315. module_instance.display.display_template(template, id)
  316. module_instance.display.display_info("")
  317. # Collect variable values
  318. variable_values = collect_variable_values(template, interactive)
  319. try:
  320. # Validate and render template
  321. if template.variables:
  322. template.variables.validate_all()
  323. # Check if we're in debug mode (logger level is DEBUG)
  324. debug_mode = logger.isEnabledFor(logging.DEBUG)
  325. rendered_files, variable_values = template.render(
  326. template.variables, debug=debug_mode
  327. )
  328. if not rendered_files:
  329. display.display_error(
  330. "Template rendering returned no files",
  331. context="template generation",
  332. )
  333. raise Exit(code=1)
  334. logger.info(f"Successfully rendered template '{id}'")
  335. # Determine output directory
  336. if directory:
  337. output_dir = Path(directory)
  338. # Check if path looks like an absolute path but is missing the leading slash
  339. if not output_dir.is_absolute() and str(output_dir).startswith(
  340. ("Users/", "home/", "usr/", "opt/", "var/", "tmp/")
  341. ):
  342. output_dir = Path("/") / output_dir
  343. logger.debug(
  344. f"Normalized relative-looking absolute path to: {output_dir}"
  345. )
  346. else:
  347. output_dir = Path(id)
  348. # Check for conflicts and get confirmation (skip in quiet mode)
  349. if not quiet:
  350. existing_files = check_output_directory(
  351. output_dir, rendered_files, interactive, display
  352. )
  353. if existing_files is None:
  354. return # User cancelled
  355. # Get final confirmation for generation
  356. dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
  357. if not get_generation_confirmation(
  358. output_dir,
  359. rendered_files,
  360. existing_files,
  361. dir_not_empty,
  362. dry_run,
  363. interactive,
  364. display,
  365. ):
  366. return # User cancelled
  367. else:
  368. # In quiet mode, just check for existing files without prompts
  369. existing_files = []
  370. # Execute generation (dry run or actual)
  371. if dry_run:
  372. if not quiet:
  373. execute_dry_run(id, output_dir, rendered_files, show_files, display)
  374. else:
  375. write_generated_files(output_dir, rendered_files, quiet, display)
  376. # Display next steps (not in quiet mode)
  377. if template.metadata.next_steps and not quiet:
  378. display.display_next_steps(template.metadata.next_steps, variable_values)
  379. except TemplateRenderError as e:
  380. # Display enhanced error information for template rendering errors (always show errors)
  381. display.display_template_render_error(e, context=f"template '{id}'")
  382. raise Exit(code=1) from None
  383. except Exception as e:
  384. display.display_error(str(e), context=f"generating template '{id}'")
  385. raise Exit(code=1) from None
  386. def validate_templates(
  387. module_instance,
  388. template_id: str,
  389. path: str | None,
  390. verbose: bool,
  391. semantic: bool,
  392. ) -> None:
  393. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
  394. # Load template based on input
  395. template = _load_template_for_validation(module_instance, template_id, path)
  396. if template:
  397. _validate_single_template(module_instance, template, template_id, verbose, semantic)
  398. else:
  399. _validate_all_templates(module_instance, verbose)
  400. def _load_template_for_validation(module_instance, template_id: str, path: str | None):
  401. """Load a template from path or ID for validation."""
  402. if path:
  403. template_path = Path(path).resolve()
  404. if not template_path.exists():
  405. module_instance.display.display_error(f"Path does not exist: {path}")
  406. raise Exit(code=1) from None
  407. if not template_path.is_dir():
  408. module_instance.display.display_error(f"Path is not a directory: {path}")
  409. raise Exit(code=1) from None
  410. module_instance.display.display_info(
  411. f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]"
  412. )
  413. try:
  414. return Template(template_path, library_name="local")
  415. except Exception as e:
  416. module_instance.display.display_error(
  417. f"Failed to load template from path '{path}': {e}"
  418. )
  419. raise Exit(code=1) from None
  420. if template_id:
  421. try:
  422. template = module_instance._load_template_by_id(template_id)
  423. module_instance.display.display_info(
  424. f"[bold]Validating template:[/bold] [cyan]{template_id}[/cyan]"
  425. )
  426. return template
  427. except Exception as e:
  428. module_instance.display.display_error(f"Failed to load template '{template_id}': {e}")
  429. raise Exit(code=1) from None
  430. return None
  431. def _validate_single_template(module_instance, template, template_id: str, verbose: bool, semantic: bool) -> None:
  432. """Validate a single template."""
  433. try:
  434. # Jinja2 validation
  435. _ = template.used_variables
  436. _ = template.variables
  437. module_instance.display.display_success("Jinja2 validation passed")
  438. # Semantic validation
  439. if semantic:
  440. _run_semantic_validation(module_instance, template, verbose)
  441. # Verbose output
  442. if verbose:
  443. _display_validation_details(module_instance, template, semantic)
  444. except TemplateRenderError as e:
  445. module_instance.display.display_template_render_error(
  446. e, context=f"template '{template_id}'"
  447. )
  448. raise Exit(code=1) from None
  449. except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
  450. module_instance.display.display_error(f"Validation failed for '{template_id}':")
  451. module_instance.display.display_info(f"\n{e}")
  452. raise Exit(code=1) from None
  453. except Exception as e:
  454. module_instance.display.display_error(f"Unexpected error validating '{template_id}': {e}")
  455. raise Exit(code=1) from None
  456. def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
  457. """Run semantic validation on rendered template files."""
  458. module_instance.display.display_info("")
  459. module_instance.display.display_info("[bold cyan]Running semantic validation...[/bold cyan]")
  460. registry = get_validator_registry()
  461. debug_mode = logger.isEnabledFor(logging.DEBUG)
  462. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  463. has_semantic_errors = False
  464. for file_path, content in rendered_files.items():
  465. result = registry.validate_file(content, file_path)
  466. if result.errors or result.warnings or (verbose and result.info):
  467. module_instance.display.display_info(f"\n[cyan]File:[/cyan] {file_path}")
  468. result.display(f"{file_path}")
  469. if result.errors:
  470. has_semantic_errors = True
  471. if has_semantic_errors:
  472. module_instance.display.display_error("Semantic validation found errors")
  473. raise Exit(code=1) from None
  474. module_instance.display.display_success("Semantic validation passed")
  475. def _display_validation_details(module_instance, template, semantic: bool) -> None:
  476. """Display verbose validation details."""
  477. module_instance.display.display_info(f"\n[dim]Template path: {template.template_dir}[/dim]")
  478. module_instance.display.display_info(f"[dim]Found {len(template.used_variables)} variables[/dim]")
  479. if semantic:
  480. debug_mode = logger.isEnabledFor(logging.DEBUG)
  481. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  482. module_instance.display.display_info(f"[dim]Generated {len(rendered_files)} files[/dim]")
  483. def _validate_all_templates(module_instance, verbose: bool) -> None:
  484. """Validate all templates in the module."""
  485. module_instance.display.display_info(
  486. f"[bold]Validating all {module_instance.name} templates...[/bold]"
  487. )
  488. valid_count = 0
  489. invalid_count = 0
  490. errors = []
  491. all_templates = module_instance._load_all_templates()
  492. total = len(all_templates)
  493. for template in all_templates:
  494. try:
  495. _ = template.used_variables
  496. _ = template.variables
  497. valid_count += 1
  498. if verbose:
  499. module_instance.display.display_success(template.id)
  500. except ValueError as e:
  501. invalid_count += 1
  502. errors.append((template.id, str(e)))
  503. if verbose:
  504. module_instance.display.display_error(template.id)
  505. except Exception as e:
  506. invalid_count += 1
  507. errors.append((template.id, f"Load error: {e}"))
  508. if verbose:
  509. module_instance.display.display_warning(template.id)
  510. # Display summary
  511. summary_items = {
  512. "Total templates:": str(total),
  513. "[green]Valid:[/green]": str(valid_count),
  514. "[red]Invalid:[/red]": str(invalid_count),
  515. }
  516. module_instance.display.display_summary_table("Validation Summary", summary_items)
  517. if errors:
  518. module_instance.display.display_info("")
  519. module_instance.display.display_error("Validation Errors:")
  520. for template_id, error_msg in errors:
  521. module_instance.display.display_info(
  522. f"\n[yellow]Template:[/yellow] [cyan]{template_id}[/cyan]"
  523. )
  524. module_instance.display.display_info(f"[dim]{error_msg}[/dim]")
  525. raise Exit(code=1)
  526. module_instance.display.display_success("All templates are valid!")