Coverage for src/rhiza_hooks/check_rhiza_config.py: 100%
85 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
1#!/usr/bin/env python3
2"""Check that .rhiza/template.yml is valid and well-formed."""
4from __future__ import annotations
6import argparse
7import sys
8from dataclasses import dataclass
9from pathlib import Path
10from typing import Any
12from rhiza_hooks._config_schema import KEY_ALIASES, REQUIRED_KEYS, VALID_KEYS, normalize_config
13from rhiza_hooks._yaml import YamlError, YamlFailure, load_yaml_mapping
16def _load_config(filepath: Path) -> dict[str, Any] | list[str]:
17 """Load configuration from YAML file.
19 Returns:
20 Config dict on success, or list of error messages on failure
21 """
22 result = load_yaml_mapping(filepath)
23 if not isinstance(result, YamlFailure):
24 return result
26 messages = {
27 YamlError.NOT_FOUND: f"File not found: {filepath}",
28 YamlError.INVALID: f"Invalid YAML: {result.detail}",
29 YamlError.EMPTY: "Configuration file is empty",
30 YamlError.NOT_MAPPING: "Configuration must be a YAML mapping",
31 }
32 return [messages[result.kind]]
35def _validate_required_keys(config: dict[str, Any]) -> list[str]:
36 """Validate required keys are present."""
37 errors = []
38 for key in REQUIRED_KEYS:
39 if key not in config:
40 errors.append(f"Missing required key: {key}")
41 return errors
44def _validate_unknown_keys(config: dict[str, Any]) -> list[str]:
45 """Check for unknown keys."""
46 errors = []
47 # Accept both canonical keys and their aliases
48 all_valid_keys = VALID_KEYS | set(KEY_ALIASES.keys())
49 for key in config:
50 if key not in all_valid_keys:
51 errors.append(f"Unknown key: {key}")
52 return errors
55def _validate_include_or_templates(config: dict[str, Any]) -> list[str]:
56 """Ensure at least one of 'include' or 'templates' is present."""
57 if "include" not in config and "templates" not in config:
58 return ["At least one of 'include' or 'templates' must be present"]
59 return []
62def _validate_template_repository(config: dict[str, Any]) -> list[str]:
63 """Validate template-repository field."""
64 errors = []
65 if "template-repository" in config:
66 repo = config["template-repository"]
67 if not isinstance(repo, str):
68 errors.append("template-repository must be a string")
69 elif "/" not in repo:
70 errors.append(f"template-repository should be in 'owner/repo' format, got: {repo}")
71 return errors
74@dataclass(frozen=True)
75class _FieldRule:
76 """Declarative rule for a presence-conditional field.
78 When the field is present its value must be an instance of one of ``types``,
79 otherwise ``type_error`` is reported. If ``empty_error`` is set, a falsy value
80 (empty list, empty string) is rejected with that message. ``exclude`` permits
81 ``None`` by including ``type(None)`` in its ``types``.
82 """
84 key: str
85 types: tuple[type, ...]
86 type_error: str
87 empty_error: str | None = None
90# Per-field rules sharing the "optional, type-checked, optionally non-empty" shape.
91# template-repository is validated separately because it needs an owner/repo format check.
92_FIELD_RULES: tuple[_FieldRule, ...] = (
93 _FieldRule("template-branch", (str,), "template-branch must be a string", "template-branch cannot be empty"),
94 _FieldRule("include", (list,), "include must be a list", "include list cannot be empty"),
95 _FieldRule("templates", (list,), "templates must be a list", "templates list cannot be empty"),
96 _FieldRule("exclude", (list, type(None)), "exclude must be a list or null"),
97)
100def _validate_field(config: dict[str, Any], rule: _FieldRule) -> list[str]:
101 """Validate a single field against its rule (a no-op when the field is absent)."""
102 if rule.key not in config:
103 return []
104 value = config[rule.key]
105 if not isinstance(value, rule.types):
106 return [rule.type_error]
107 if rule.empty_error is not None and not value:
108 return [rule.empty_error]
109 return []
112def validate_rhiza_config(filepath: Path) -> list[str]:
113 """Validate a rhiza configuration file.
115 Args:
116 filepath: Path to the .rhiza/template.yml file
118 Returns:
119 List of error messages (empty if valid)
120 """
121 # Load configuration
122 raw_config = _load_config(filepath)
123 if isinstance(raw_config, list):
124 return raw_config
126 # Validate unknown keys on raw config (before normalization)
127 errors = []
128 errors.extend(_validate_unknown_keys(raw_config))
130 # Normalize aliases for subsequent validation
131 config = normalize_config(raw_config)
133 # Validate all aspects
134 errors.extend(_validate_required_keys(config))
135 errors.extend(_validate_include_or_templates(config))
136 errors.extend(_validate_template_repository(config))
137 for rule in _FIELD_RULES:
138 errors.extend(_validate_field(config, rule))
140 return errors
143def main(argv: list[str] | None = None) -> int:
144 """Main entry point for the hook."""
145 parser = argparse.ArgumentParser(description="Validate .rhiza/template.yml configuration")
146 parser.add_argument(
147 "filenames",
148 nargs="*",
149 help="Filenames to check",
150 )
151 args = parser.parse_args(argv)
153 retval = 0
154 for filename in args.filenames:
155 filepath = Path(filename)
156 errors = validate_rhiza_config(filepath)
157 if errors:
158 print(f"{filename}:")
159 for error in errors:
160 print(f" - {error}")
161 retval = 1
163 return retval
166if __name__ == "__main__": # pragma: no mutate
167 sys.exit(main())