Coverage for src / rhiza / commands / validate.py: 100%
262 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +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 profile, 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"]
135 has_profiles = False
137 if "profiles" in config:
138 profiles = config["profiles"]
139 if not isinstance(profiles, list):
140 logger.error(f"profiles must be a list, got {type(profiles).__name__}")
141 logger.error("Example: profiles: [github-project]")
142 return False
143 if not profiles:
144 logger.error("profiles list cannot be empty")
145 logger.error("Example: profiles: [github-project]")
146 return False
147 for p in profiles:
148 if not isinstance(p, str) or not p.strip():
149 logger.error(f"Each entry in profiles must be a non-empty string, got: {p!r}")
150 return False
151 has_profiles = True
153 # Error if old "bundles" field is used
154 if "bundles" in config:
155 logger.error("Field 'bundles' has been renamed to 'templates'")
156 logger.error("Update your .rhiza/template.yml:")
157 logger.error(" bundles: [...] → templates: [...]")
158 return False
160 # Require at least one of profiles, templates, or include
161 if not has_profiles and not has_templates and not has_include:
162 logger.error("Must specify at least one of 'profiles', 'templates', or 'include' in template.yml")
163 logger.error("Options:")
164 logger.error(" • Profile-based: profiles: [github-project]")
165 logger.error(" • Template-based: templates: [core, tests, github]")
166 logger.error(" • Path-based: include: [.rhiza, .github, ...]")
167 logger.error(" • Hybrid: specify both templates and include")
168 return False
170 # Log what mode is being used
171 if has_profiles:
172 logger.success(f"Using profile mode (profiles: {config['profiles']})")
173 elif has_templates and has_include:
174 logger.success("Using hybrid mode (templates + include)")
175 elif has_templates:
176 logger.success("Using template-based mode")
177 else:
178 logger.success("Using path-based mode")
180 return True
183def _validate_templates(config: dict[str, Any]) -> bool:
184 """Validate templates field if present.
186 Args:
187 config: Configuration dictionary.
189 Returns:
190 True if templates field is valid, False otherwise.
191 """
192 logger.debug("Validating templates field")
193 if "templates" not in config:
194 return True
196 templates = config["templates"]
197 if not isinstance(templates, list):
198 logger.error(f"templates must be a list, got {type(templates).__name__}")
199 logger.error("Example: templates: [core, tests, github]")
200 return False
201 elif len(templates) == 0:
202 logger.error("templates list cannot be empty")
203 logger.error("Add at least one template to materialize")
204 return False
205 else:
206 logger.success(f"templates list has {len(templates)} template(s)")
207 for template in templates:
208 if not isinstance(template, str):
209 logger.warning(f"template name should be a string, got {type(template).__name__}: {template}")
210 else:
211 logger.info(f" - {template}")
212 return True
215def _validate_required_fields(config: dict[str, Any]) -> bool:
216 """Validate required fields exist and have correct types.
218 Args:
219 config: Configuration dictionary.
221 Returns:
222 True if all validations pass, False otherwise.
223 """
224 logger.debug("Validating required fields")
225 # template-repository (or repository) is required
226 # include or bundles is required (validated separately)
228 validation_passed = True
230 # Check for template-repository or repository
231 has_template_repo = "template-repository" in config
232 has_repo = "repository" in config
234 if not has_template_repo and not has_repo:
235 logger.error("Missing required field: 'template-repository' or 'repository'")
236 logger.error("Add 'template-repository' or 'repository' to your template.yml")
237 validation_passed = False
238 else:
239 # Check the type of whichever field is present (prefer template-repository)
240 repo_field = "template-repository" if has_template_repo else "repository"
241 repo_value = config[repo_field]
243 if not isinstance(repo_value, str):
244 logger.error(f"Field '{repo_field}' must be of type str, got {type(repo_value).__name__}")
245 logger.error(f"Fix the type of '{repo_field}' in template.yml")
246 validation_passed = False
247 else:
248 logger.success(f"Field '{repo_field}' is present and valid")
250 return validation_passed
253def _validate_repository_format(config: dict[str, Any]) -> bool:
254 """Validate template-repository or repository format.
256 Args:
257 config: Configuration dictionary.
259 Returns:
260 True if valid, False otherwise.
261 """
262 logger.debug("Validating repository format")
264 # Check for either template-repository or repository
265 repo_field = None
266 if "template-repository" in config:
267 repo_field = "template-repository"
268 elif "repository" in config:
269 repo_field = "repository"
270 else:
271 return True # No repository field found, will be caught by _validate_required_fields
273 repo = config[repo_field]
274 if not isinstance(repo, str):
275 logger.error(f"{repo_field} must be a string, got {type(repo).__name__}")
276 logger.error("Example: 'owner/repository'")
277 return False
278 elif "/" not in repo:
279 logger.error(f"{repo_field} must be in format 'owner/repo', got: {repo}")
280 logger.error("Example: 'jebel-quant/rhiza'")
281 return False
282 else:
283 logger.success(f"{repo_field} format is valid: {repo}")
284 return True
287def _validate_include_paths(config: dict[str, Any]) -> bool:
288 """Validate include paths.
290 Args:
291 config: Configuration dictionary.
293 Returns:
294 True if valid, False otherwise.
295 """
296 logger.debug("Validating include paths")
297 if "include" not in config:
298 return True
300 include = config["include"]
301 if not isinstance(include, list):
302 logger.error(f"include must be a list, got {type(include).__name__}")
303 logger.error("Example: include: ['.github', '.gitignore']")
304 return False
305 elif len(include) == 0:
306 logger.error("include list cannot be empty")
307 logger.error("Add at least one path to materialize")
308 return False
309 else:
310 logger.success(f"include list has {len(include)} path(s)")
311 for path in include:
312 if not isinstance(path, str):
313 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
314 else:
315 logger.info(f" - {path}")
316 return True
319def _validate_branch_field(config: dict[str, Any]) -> None:
320 """Validate template-branch or ref field.
322 Args:
323 config: Configuration dictionary.
324 """
325 branch_field = None
326 if "template-branch" in config:
327 branch_field = "template-branch"
328 elif "ref" in config:
329 branch_field = "ref"
331 if branch_field:
332 branch = config[branch_field]
333 if not isinstance(branch, str):
334 logger.warning(f"{branch_field} should be a string, got {type(branch).__name__}: {branch}")
335 logger.warning("Example: 'main' or 'develop'")
336 else:
337 logger.success(f"{branch_field} is valid: {branch}")
340def _validate_host_field(config: dict[str, Any]) -> None:
341 """Validate template-host field.
343 Args:
344 config: Configuration dictionary.
345 """
346 if "template-host" not in config:
347 return
349 host = config["template-host"]
350 if not isinstance(host, str):
351 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
352 logger.warning("Must be 'github' or 'gitlab'")
353 elif host not in GitHost._value2member_map_:
354 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
355 logger.warning("Other hosts are not currently supported")
356 else:
357 logger.success(f"template-host is valid: {host}")
360def _validate_language_field(config: dict[str, Any]) -> None:
361 """Validate language field.
363 Args:
364 config: Configuration dictionary.
365 """
366 if "language" not in config:
367 return
369 language = config["language"]
370 if not isinstance(language, str):
371 logger.warning(f"language should be a string, got {type(language).__name__}: {language}")
372 logger.warning("Example: 'python', 'go'")
373 else:
374 registry = get_validator_registry()
375 supported_languages = registry.get_supported_languages()
376 if language.lower() not in supported_languages:
377 logger.warning(f"language '{language}' is not recognized")
378 logger.warning(f"Supported languages: {', '.join(supported_languages)}")
379 else:
380 logger.success(f"language is valid: {language}")
383def _validate_exclude_field(config: dict[str, Any]) -> None:
384 """Validate exclude field.
386 Args:
387 config: Configuration dictionary.
388 """
389 if "exclude" not in config:
390 return
392 exclude = config["exclude"]
393 if not isinstance(exclude, list):
394 logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
395 logger.warning("Example: exclude: ['.github/workflows/ci.yml']")
396 else:
397 logger.success(f"exclude list has {len(exclude)} path(s)")
398 for path in exclude:
399 if not isinstance(path, str):
400 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
401 else:
402 logger.info(f" - {path}")
405def _validate_optional_fields(config: dict[str, Any]) -> None:
406 """Validate optional fields if present.
408 Args:
409 config: Configuration dictionary.
410 """
411 logger.debug("Validating optional fields")
412 _validate_branch_field(config)
413 _validate_host_field(config)
414 _validate_language_field(config)
415 _validate_exclude_field(config)
418def validate(target: Path, template_file: Path | None = None) -> bool:
419 """Validate template.yml configuration in the target repository.
421 Performs authoritative validation of the template configuration:
422 - Checks if target is a git repository
423 - Checks for language-specific project structure
424 - Checks if template.yml exists
425 - Validates YAML syntax
426 - Validates required fields
427 - Validates field values are appropriate
429 Args:
430 target: Path to the target Git repository directory.
431 template_file: Optional explicit path to the template file. When
432 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
434 Returns:
435 True if validation passes, False otherwise.
436 """
437 target = target.resolve()
438 logger.info(f"Validating template configuration in: {target}")
440 # Check if target is a git repository
441 if not _check_git_repository(target):
442 return False
444 # Check for template file first to get the language
445 exists, template_file = _check_template_file_exists(target, template_file)
446 if not exists:
447 return False
449 # Parse YAML file
450 success, config = _parse_yaml_file(template_file)
451 if not success or config is None:
452 return False
454 # Get the language from config (default to "python" for backward compatibility)
455 language = config.get("language", "python")
456 logger.info(f"Project language: {language}")
458 # Check for language-specific project structure
459 if not _check_project_structure(target, language):
460 return False
462 # Validate configuration mode (templates OR include)
463 if not _validate_configuration_mode(config):
464 return False
466 # Validate required fields
467 validation_passed = _validate_required_fields(config)
469 # Validate specific field formats
470 if not _validate_repository_format(config):
471 validation_passed = False
473 # Validate templates if present
474 if config.get("templates") and not _validate_templates(config):
475 validation_passed = False
477 # Validate include if present
478 if config.get("include") and not _validate_include_paths(config):
479 validation_passed = False
481 # Validate optional fields
482 _validate_optional_fields(config)
484 # Final verdict
485 logger.debug("Validation complete, determining final result")
486 if validation_passed:
487 logger.success("✓ Validation passed: template.yml is valid")
488 return True
489 else:
490 logger.error("✗ Validation failed: template.yml has errors")
491 logger.error("Fix the errors above and run 'rhiza validate' again")
492 return False