base_commands.py 24 KB

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