Coverage for src / rhiza / commands / validate.py: 94%
212 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +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 # type: ignore[import-untyped]
11from loguru import logger
14def _check_git_repository(target: Path) -> bool:
15 """Check if target is a git repository.
17 Args:
18 target: Path to check.
20 Returns:
21 True if valid git repository, False otherwise.
22 """
23 if not (target / ".git").is_dir():
24 logger.error(f"Target directory is not a git repository: {target}")
25 logger.error("Initialize a git repository with 'git init' first")
26 return False
27 return True
30def _check_project_structure(target: Path) -> None:
31 """Check for standard project structure.
33 Args:
34 target: Path to project.
35 """
36 logger.debug("Validating project structure")
37 src_dir = target / "src"
38 tests_dir = target / "tests"
40 if not src_dir.exists():
41 logger.warning(f"Standard 'src' folder not found: {src_dir}")
42 logger.warning("Consider creating a 'src' directory for source code")
43 else:
44 logger.success(f"'src' folder exists: {src_dir}")
46 if not tests_dir.exists():
47 logger.warning(f"Standard 'tests' folder not found: {tests_dir}")
48 logger.warning("Consider creating a 'tests' directory for test files")
49 else:
50 logger.success(f"'tests' folder exists: {tests_dir}")
53def _check_pyproject_toml(target: Path) -> bool:
54 """Check for pyproject.toml file.
56 Args:
57 target: Path to project.
59 Returns:
60 True if pyproject.toml exists, False otherwise.
61 """
62 logger.debug("Validating pyproject.toml")
63 pyproject_file = target / "pyproject.toml"
65 if not pyproject_file.exists():
66 logger.error(f"pyproject.toml not found: {pyproject_file}")
67 logger.error("pyproject.toml is required for Python projects")
68 logger.info("Run 'rhiza init' to create a default pyproject.toml")
69 return False
70 else:
71 logger.success(f"pyproject.toml exists: {pyproject_file}")
72 return True
75def _check_template_file_exists(target: Path) -> tuple[bool, Path]:
76 """Check if template file exists.
78 Args:
79 target: Path to project.
81 Returns:
82 Tuple of (exists, template_file_path).
83 """
84 template_file = target / ".rhiza" / "template.yml"
86 if not template_file.exists():
87 logger.error(f"No template file found at: {template_file.relative_to(target)}")
88 logger.error("The template configuration must be in the .rhiza folder.")
89 logger.info("")
90 logger.info("To fix this:")
91 logger.info(" • If you're starting fresh, run: rhiza init")
92 logger.info(" • If you have an existing configuration, run: rhiza migrate")
93 logger.info("")
94 logger.info("The 'rhiza migrate' command will move your configuration from")
95 logger.info(" the old location → .rhiza/template.yml")
96 return False, template_file
98 logger.success(f"Template file exists: {template_file.relative_to(target)}")
99 return True, template_file
102def _parse_yaml_file(template_file: Path) -> tuple[bool, dict[str, Any] | None]:
103 """Parse YAML file and return configuration.
105 Args:
106 template_file: Path to template file.
108 Returns:
109 Tuple of (success, config_dict).
110 """
111 logger.debug(f"Parsing YAML file: {template_file}")
112 try:
113 with open(template_file) as f:
114 config = yaml.safe_load(f)
115 except yaml.YAMLError as e:
116 logger.error(f"Invalid YAML syntax in template.yml: {e}")
117 logger.error("Fix the YAML syntax errors and try again")
118 return False, None
120 if config is None:
121 logger.error("template.yml is empty")
122 logger.error("Add configuration to template.yml or run 'rhiza init' to generate defaults")
123 return False, None
125 logger.success("YAML syntax is valid")
126 return True, config
129def _validate_configuration_mode(config: dict[str, Any]) -> bool:
130 """Validate that at least one of templates or include is specified.
132 Args:
133 config: Configuration dictionary.
135 Returns:
136 True if configuration mode is valid, False otherwise.
137 """
138 logger.debug("Validating configuration mode")
139 has_templates = "templates" in config and config["templates"]
140 has_include = "include" in config and config["include"]
142 # Error if old "bundles" field is used
143 if "bundles" in config:
144 logger.error("Field 'bundles' has been renamed to 'templates'")
145 logger.error("Update your .rhiza/template.yml:")
146 logger.error(" bundles: [...] → templates: [...]")
147 return False
149 # Require at least one of templates or include
150 if not has_templates and not has_include:
151 logger.error("Must specify at least one of 'templates' or 'include' in template.yml")
152 logger.error("Options:")
153 logger.error(" • Template-based: templates: [core, tests, github]")
154 logger.error(" • Path-based: include: [.rhiza, .github, ...]")
155 logger.error(" • Hybrid: specify both templates and include")
156 return False
158 # Log what mode is being used
159 if has_templates and has_include:
160 logger.success("Using hybrid mode (templates + include)")
161 elif has_templates:
162 logger.success("Using template-based mode")
163 else:
164 logger.success("Using path-based mode")
166 return True
169def _validate_templates(config: dict[str, Any]) -> bool:
170 """Validate templates field if present.
172 Args:
173 config: Configuration dictionary.
175 Returns:
176 True if templates field is valid, False otherwise.
177 """
178 logger.debug("Validating templates field")
179 if "templates" not in config:
180 return True
182 templates = config["templates"]
183 if not isinstance(templates, list):
184 logger.error(f"templates must be a list, got {type(templates).__name__}")
185 logger.error("Example: templates: [core, tests, github]")
186 return False
187 elif len(templates) == 0:
188 logger.error("templates list cannot be empty")
189 logger.error("Add at least one template to materialize")
190 return False
191 else:
192 logger.success(f"templates list has {len(templates)} template(s)")
193 for template in templates:
194 if not isinstance(template, str):
195 logger.warning(f"template name should be a string, got {type(template).__name__}: {template}")
196 else:
197 logger.info(f" - {template}")
198 return True
201def _validate_required_fields(config: dict[str, Any]) -> bool:
202 """Validate required fields exist and have correct types.
204 Args:
205 config: Configuration dictionary.
207 Returns:
208 True if all validations pass, False otherwise.
209 """
210 logger.debug("Validating required fields")
211 # template-repository is required
212 # include or bundles is required (validated separately)
213 required_fields = {
214 "template-repository": str,
215 }
217 validation_passed = True
219 for field, expected_type in required_fields.items():
220 if field not in config:
221 logger.error(f"Missing required field: {field}")
222 logger.error(f"Add '{field}' to your template.yml")
223 validation_passed = False
224 elif not isinstance(config[field], expected_type):
225 logger.error(
226 f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}"
227 )
228 logger.error(f"Fix the type of '{field}' in template.yml")
229 validation_passed = False
230 else:
231 logger.success(f"Field '{field}' is present and valid")
233 return validation_passed
236def _validate_repository_format(config: dict[str, Any]) -> bool:
237 """Validate template-repository format.
239 Args:
240 config: Configuration dictionary.
242 Returns:
243 True if valid, False otherwise.
244 """
245 logger.debug("Validating template-repository format")
246 if "template-repository" not in config:
247 return True
249 repo = config["template-repository"]
250 if not isinstance(repo, str):
251 logger.error(f"template-repository must be a string, got {type(repo).__name__}")
252 logger.error("Example: 'owner/repository'")
253 return False
254 elif "/" not in repo:
255 logger.error(f"template-repository must be in format 'owner/repo', got: {repo}")
256 logger.error("Example: 'jebel-quant/rhiza'")
257 return False
258 else:
259 logger.success(f"template-repository format is valid: {repo}")
260 return True
263def _validate_include_paths(config: dict[str, Any]) -> bool:
264 """Validate include paths.
266 Args:
267 config: Configuration dictionary.
269 Returns:
270 True if valid, False otherwise.
271 """
272 logger.debug("Validating include paths")
273 if "include" not in config:
274 return True
276 include = config["include"]
277 if not isinstance(include, list):
278 logger.error(f"include must be a list, got {type(include).__name__}")
279 logger.error("Example: include: ['.github', '.gitignore']")
280 return False
281 elif len(include) == 0:
282 logger.error("include list cannot be empty")
283 logger.error("Add at least one path to materialize")
284 return False
285 else:
286 logger.success(f"include list has {len(include)} path(s)")
287 for path in include:
288 if not isinstance(path, str):
289 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
290 else:
291 logger.info(f" - {path}")
292 return True
295def _validate_optional_fields(config: dict[str, Any]) -> None:
296 """Validate optional fields if present.
298 Args:
299 config: Configuration dictionary.
300 """
301 logger.debug("Validating optional fields")
303 # template-branch
304 if "template-branch" in config:
305 branch = config["template-branch"]
306 if not isinstance(branch, str):
307 logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}")
308 logger.warning("Example: 'main' or 'develop'")
309 else:
310 logger.success(f"template-branch is valid: {branch}")
312 # template-host
313 if "template-host" in config:
314 host = config["template-host"]
315 if not isinstance(host, str):
316 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
317 logger.warning("Must be 'github' or 'gitlab'")
318 elif host not in ("github", "gitlab"):
319 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
320 logger.warning("Other hosts are not currently supported")
321 else:
322 logger.success(f"template-host is valid: {host}")
324 # exclude
325 if "exclude" in config:
326 exclude = config["exclude"]
327 if not isinstance(exclude, list):
328 logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
329 logger.warning("Example: exclude: ['.github/workflows/ci.yml']")
330 else:
331 logger.success(f"exclude list has {len(exclude)} path(s)")
332 for path in exclude:
333 if not isinstance(path, str):
334 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
335 else:
336 logger.info(f" - {path}")
339def validate(target: Path) -> bool:
340 """Validate template.yml configuration in the target repository.
342 Performs authoritative validation of the template configuration:
343 - Checks if target is a git repository
344 - Checks for standard project structure (src and tests folders)
345 - Checks for pyproject.toml (required)
346 - Checks if template.yml exists
347 - Validates YAML syntax
348 - Validates required fields
349 - Validates field values are appropriate
351 Args:
352 target: Path to the target Git repository directory.
354 Returns:
355 True if validation passes, False otherwise.
356 """
357 target = target.resolve()
358 logger.info(f"Validating template configuration in: {target}")
360 # Check if target is a git repository
361 if not _check_git_repository(target):
362 return False
364 # Check for standard project structure
365 _check_project_structure(target)
367 # Check for pyproject.toml
368 if not _check_pyproject_toml(target):
369 return False
371 # Check for template file
372 exists, template_file = _check_template_file_exists(target)
373 if not exists:
374 return False
376 # Parse YAML file
377 success, config = _parse_yaml_file(template_file)
378 if not success or config is None:
379 return False
381 # Validate configuration mode (templates OR include)
382 if not _validate_configuration_mode(config):
383 return False
385 # Validate required fields
386 validation_passed = _validate_required_fields(config)
388 # Validate specific field formats
389 if not _validate_repository_format(config):
390 validation_passed = False
392 # Validate templates if present
393 if config.get("templates"):
394 if not _validate_templates(config):
395 validation_passed = False
397 # Validate include if present
398 if config.get("include"):
399 if not _validate_include_paths(config):
400 validation_passed = False
402 # Validate optional fields
403 _validate_optional_fields(config)
405 # Final verdict
406 logger.debug("Validation complete, determining final result")
407 if validation_passed:
408 logger.success("✓ Validation passed: template.yml is valid")
409 return True
410 else:
411 logger.error("✗ Validation failed: template.yml has errors")
412 logger.error("Fix the errors above and run 'rhiza validate' again")
413 return False