Coverage for src / rhiza / commands / validate.py: 100%
243 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
1"""Command for validating Rhiza template configuration.
3This module provides functionality to validate template.yml files in the
4.rhiza/template.yml location (new standard location after migration).
5"""
7from pathlib import Path
8from typing import Any
10import yaml
11from loguru import logger
13from rhiza.language_validators import get_validator_registry
14from rhiza.models.template import GitHost
17def _check_git_repository(target: Path) -> bool:
18 """Check if target is a git repository.
20 Args:
21 target: Path to check.
23 Returns:
24 True if valid git repository, False otherwise.
25 """
26 if not (target / ".git").is_dir():
27 logger.error(f"Target directory is not a git repository: {target}")
28 logger.error("Initialize a git repository with 'git init' first")
29 return False
30 return True
33def _check_project_structure(target: Path, language: str) -> bool:
34 """Check for language-specific project structure.
36 Args:
37 target: Path to project.
38 language: The programming language of the project.
40 Returns:
41 True if validation passes, False otherwise.
42 """
43 logger.debug(f"Validating project structure for language: {language}")
45 # Get the appropriate validator for the language
46 registry = get_validator_registry()
47 validator = registry.get_validator(language)
49 if validator is None:
50 logger.warning(f"No validator found for language '{language}'")
51 logger.warning(f"Supported languages: {', '.join(registry.get_supported_languages())}")
52 logger.warning("Skipping project structure validation")
53 return True # Don't fail validation if language is not supported
55 return validator.validate_project_structure(target)
58def _check_template_file_exists(target: Path, template_file: Path | None = None) -> tuple[bool, Path]:
59 """Check if template file exists.
61 Args:
62 target: Path to project.
63 template_file: Optional explicit path to the template file. When
64 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
66 Returns:
67 Tuple of (exists, template_file_path).
68 """
69 if template_file is None:
70 template_file = target / ".rhiza" / "template.yml"
72 if not template_file.exists():
73 try:
74 display_path = template_file.relative_to(target)
75 except ValueError:
76 display_path = template_file
77 logger.error(f"No template file found at: {display_path}")
78 logger.error("The template configuration must be in the .rhiza folder.")
79 logger.info("")
80 logger.info("To fix this:")
81 logger.info(" • If you're starting fresh, run: rhiza init")
82 logger.info(" • If you have an existing configuration, run: rhiza migrate")
83 logger.info("")
84 logger.info("The 'rhiza migrate' command will move your configuration from")
85 logger.info(" the old location → .rhiza/template.yml")
86 return False, template_file
88 try:
89 display_path = template_file.relative_to(target)
90 except ValueError:
91 display_path = template_file
92 logger.success(f"Template file exists: {display_path}")
93 return True, template_file
96def _parse_yaml_file(template_file: Path) -> tuple[bool, dict[str, Any] | None]:
97 """Parse YAML file and return configuration.
99 Args:
100 template_file: Path to template file.
102 Returns:
103 Tuple of (success, config_dict).
104 """
105 logger.debug(f"Parsing YAML file: {template_file}")
106 try:
107 with open(template_file) as f:
108 config = yaml.safe_load(f)
109 except yaml.YAMLError as e:
110 logger.error(f"Invalid YAML syntax in template.yml: {e}")
111 logger.error("Fix the YAML syntax errors and try again")
112 return False, None
114 if config is None:
115 logger.error("template.yml is empty")
116 logger.error("Add configuration to template.yml or run 'rhiza init' to generate defaults")
117 return False, None
119 logger.success("YAML syntax is valid")
120 return True, config
123def _validate_configuration_mode(config: dict[str, Any]) -> bool:
124 """Validate that at least one of templates or include is specified.
126 Args:
127 config: Configuration dictionary.
129 Returns:
130 True if configuration mode is valid, False otherwise.
131 """
132 logger.debug("Validating configuration mode")
133 has_templates = "templates" in config and config["templates"]
134 has_include = "include" in config and config["include"]
136 # Error if old "bundles" field is used
137 if "bundles" in config:
138 logger.error("Field 'bundles' has been renamed to 'templates'")
139 logger.error("Update your .rhiza/template.yml:")
140 logger.error(" bundles: [...] → templates: [...]")
141 return False
143 # Require at least one of templates or include
144 if not has_templates and not has_include:
145 logger.error("Must specify at least one of 'templates' or 'include' in template.yml")
146 logger.error("Options:")
147 logger.error(" • Template-based: templates: [core, tests, github]")
148 logger.error(" • Path-based: include: [.rhiza, .github, ...]")
149 logger.error(" • Hybrid: specify both templates and include")
150 return False
152 # Log what mode is being used
153 if has_templates and has_include:
154 logger.success("Using hybrid mode (templates + include)")
155 elif has_templates:
156 logger.success("Using template-based mode")
157 else:
158 logger.success("Using path-based mode")
160 return True
163def _validate_templates(config: dict[str, Any]) -> bool:
164 """Validate templates field if present.
166 Args:
167 config: Configuration dictionary.
169 Returns:
170 True if templates field is valid, False otherwise.
171 """
172 logger.debug("Validating templates field")
173 if "templates" not in config:
174 return True
176 templates = config["templates"]
177 if not isinstance(templates, list):
178 logger.error(f"templates must be a list, got {type(templates).__name__}")
179 logger.error("Example: templates: [core, tests, github]")
180 return False
181 elif len(templates) == 0:
182 logger.error("templates list cannot be empty")
183 logger.error("Add at least one template to materialize")
184 return False
185 else:
186 logger.success(f"templates list has {len(templates)} template(s)")
187 for template in templates:
188 if not isinstance(template, str):
189 logger.warning(f"template name should be a string, got {type(template).__name__}: {template}")
190 else:
191 logger.info(f" - {template}")
192 return True
195def _validate_required_fields(config: dict[str, Any]) -> bool:
196 """Validate required fields exist and have correct types.
198 Args:
199 config: Configuration dictionary.
201 Returns:
202 True if all validations pass, False otherwise.
203 """
204 logger.debug("Validating required fields")
205 # template-repository (or repository) is required
206 # include or bundles is required (validated separately)
208 validation_passed = True
210 # Check for template-repository or repository
211 has_template_repo = "template-repository" in config
212 has_repo = "repository" in config
214 if not has_template_repo and not has_repo:
215 logger.error("Missing required field: 'template-repository' or 'repository'")
216 logger.error("Add 'template-repository' or 'repository' to your template.yml")
217 validation_passed = False
218 else:
219 # Check the type of whichever field is present (prefer template-repository)
220 repo_field = "template-repository" if has_template_repo else "repository"
221 repo_value = config[repo_field]
223 if not isinstance(repo_value, str):
224 logger.error(f"Field '{repo_field}' must be of type str, got {type(repo_value).__name__}")
225 logger.error(f"Fix the type of '{repo_field}' in template.yml")
226 validation_passed = False
227 else:
228 logger.success(f"Field '{repo_field}' is present and valid")
230 return validation_passed
233def _validate_repository_format(config: dict[str, Any]) -> bool:
234 """Validate template-repository or repository format.
236 Args:
237 config: Configuration dictionary.
239 Returns:
240 True if valid, False otherwise.
241 """
242 logger.debug("Validating repository format")
244 # Check for either template-repository or repository
245 repo_field = None
246 if "template-repository" in config:
247 repo_field = "template-repository"
248 elif "repository" in config:
249 repo_field = "repository"
250 else:
251 return True # No repository field found, will be caught by _validate_required_fields
253 repo = config[repo_field]
254 if not isinstance(repo, str):
255 logger.error(f"{repo_field} must be a string, got {type(repo).__name__}")
256 logger.error("Example: 'owner/repository'")
257 return False
258 elif "/" not in repo:
259 logger.error(f"{repo_field} must be in format 'owner/repo', got: {repo}")
260 logger.error("Example: 'jebel-quant/rhiza'")
261 return False
262 else:
263 logger.success(f"{repo_field} format is valid: {repo}")
264 return True
267def _validate_include_paths(config: dict[str, Any]) -> bool:
268 """Validate include paths.
270 Args:
271 config: Configuration dictionary.
273 Returns:
274 True if valid, False otherwise.
275 """
276 logger.debug("Validating include paths")
277 if "include" not in config:
278 return True
280 include = config["include"]
281 if not isinstance(include, list):
282 logger.error(f"include must be a list, got {type(include).__name__}")
283 logger.error("Example: include: ['.github', '.gitignore']")
284 return False
285 elif len(include) == 0:
286 logger.error("include list cannot be empty")
287 logger.error("Add at least one path to materialize")
288 return False
289 else:
290 logger.success(f"include list has {len(include)} path(s)")
291 for path in include:
292 if not isinstance(path, str):
293 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
294 else:
295 logger.info(f" - {path}")
296 return True
299def _validate_branch_field(config: dict[str, Any]) -> None:
300 """Validate template-branch or ref field.
302 Args:
303 config: Configuration dictionary.
304 """
305 branch_field = None
306 if "template-branch" in config:
307 branch_field = "template-branch"
308 elif "ref" in config:
309 branch_field = "ref"
311 if branch_field:
312 branch = config[branch_field]
313 if not isinstance(branch, str):
314 logger.warning(f"{branch_field} should be a string, got {type(branch).__name__}: {branch}")
315 logger.warning("Example: 'main' or 'develop'")
316 else:
317 logger.success(f"{branch_field} is valid: {branch}")
320def _validate_host_field(config: dict[str, Any]) -> None:
321 """Validate template-host field.
323 Args:
324 config: Configuration dictionary.
325 """
326 if "template-host" not in config:
327 return
329 host = config["template-host"]
330 if not isinstance(host, str):
331 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
332 logger.warning("Must be 'github' or 'gitlab'")
333 elif host not in GitHost._value2member_map_:
334 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
335 logger.warning("Other hosts are not currently supported")
336 else:
337 logger.success(f"template-host is valid: {host}")
340def _validate_language_field(config: dict[str, Any]) -> None:
341 """Validate language field.
343 Args:
344 config: Configuration dictionary.
345 """
346 if "language" not in config:
347 return
349 language = config["language"]
350 if not isinstance(language, str):
351 logger.warning(f"language should be a string, got {type(language).__name__}: {language}")
352 logger.warning("Example: 'python', 'go'")
353 else:
354 registry = get_validator_registry()
355 supported_languages = registry.get_supported_languages()
356 if language.lower() not in supported_languages:
357 logger.warning(f"language '{language}' is not recognized")
358 logger.warning(f"Supported languages: {', '.join(supported_languages)}")
359 else:
360 logger.success(f"language is valid: {language}")
363def _validate_exclude_field(config: dict[str, Any]) -> None:
364 """Validate exclude field.
366 Args:
367 config: Configuration dictionary.
368 """
369 if "exclude" not in config:
370 return
372 exclude = config["exclude"]
373 if not isinstance(exclude, list):
374 logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
375 logger.warning("Example: exclude: ['.github/workflows/ci.yml']")
376 else:
377 logger.success(f"exclude list has {len(exclude)} path(s)")
378 for path in exclude:
379 if not isinstance(path, str):
380 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
381 else:
382 logger.info(f" - {path}")
385def _validate_optional_fields(config: dict[str, Any]) -> None:
386 """Validate optional fields if present.
388 Args:
389 config: Configuration dictionary.
390 """
391 logger.debug("Validating optional fields")
392 _validate_branch_field(config)
393 _validate_host_field(config)
394 _validate_language_field(config)
395 _validate_exclude_field(config)
398def validate(target: Path, template_file: Path | None = None) -> bool:
399 """Validate template.yml configuration in the target repository.
401 Performs authoritative validation of the template configuration:
402 - Checks if target is a git repository
403 - Checks for language-specific project structure
404 - Checks if template.yml exists
405 - Validates YAML syntax
406 - Validates required fields
407 - Validates field values are appropriate
409 Args:
410 target: Path to the target Git repository directory.
411 template_file: Optional explicit path to the template file. When
412 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
414 Returns:
415 True if validation passes, False otherwise.
416 """
417 target = target.resolve()
418 logger.info(f"Validating template configuration in: {target}")
420 # Check if target is a git repository
421 if not _check_git_repository(target):
422 return False
424 # Check for template file first to get the language
425 exists, template_file = _check_template_file_exists(target, template_file)
426 if not exists:
427 return False
429 # Parse YAML file
430 success, config = _parse_yaml_file(template_file)
431 if not success or config is None:
432 return False
434 # Get the language from config (default to "python" for backward compatibility)
435 language = config.get("language", "python")
436 logger.info(f"Project language: {language}")
438 # Check for language-specific project structure
439 if not _check_project_structure(target, language):
440 return False
442 # Validate configuration mode (templates OR include)
443 if not _validate_configuration_mode(config):
444 return False
446 # Validate required fields
447 validation_passed = _validate_required_fields(config)
449 # Validate specific field formats
450 if not _validate_repository_format(config):
451 validation_passed = False
453 # Validate templates if present
454 if config.get("templates") and not _validate_templates(config):
455 validation_passed = False
457 # Validate include if present
458 if config.get("include") and not _validate_include_paths(config):
459 validation_passed = False
461 # Validate optional fields
462 _validate_optional_fields(config)
464 # Final verdict
465 logger.debug("Validation complete, determining final result")
466 if validation_passed:
467 logger.success("✓ Validation passed: template.yml is valid")
468 return True
469 else:
470 logger.error("✗ Validation failed: template.yml has errors")
471 logger.error("Fix the errors above and run 'rhiza validate' again")
472 return False