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

1#!/usr/bin/env python3 

2"""Check that .rhiza/template.yml is valid and well-formed.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import sys 

8from dataclasses import dataclass 

9from pathlib import Path 

10from typing import Any 

11 

12from rhiza_hooks._config_schema import KEY_ALIASES, REQUIRED_KEYS, VALID_KEYS, normalize_config 

13from rhiza_hooks._yaml import YamlError, YamlFailure, load_yaml_mapping 

14 

15 

16def _load_config(filepath: Path) -> dict[str, Any] | list[str]: 

17 """Load configuration from YAML file. 

18 

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 

25 

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]] 

33 

34 

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 

42 

43 

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 

53 

54 

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 [] 

60 

61 

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 

72 

73 

74@dataclass(frozen=True) 

75class _FieldRule: 

76 """Declarative rule for a presence-conditional field. 

77 

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 """ 

83 

84 key: str 

85 types: tuple[type, ...] 

86 type_error: str 

87 empty_error: str | None = None 

88 

89 

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) 

98 

99 

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 [] 

110 

111 

112def validate_rhiza_config(filepath: Path) -> list[str]: 

113 """Validate a rhiza configuration file. 

114 

115 Args: 

116 filepath: Path to the .rhiza/template.yml file 

117 

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 

125 

126 # Validate unknown keys on raw config (before normalization) 

127 errors = [] 

128 errors.extend(_validate_unknown_keys(raw_config)) 

129 

130 # Normalize aliases for subsequent validation 

131 config = normalize_config(raw_config) 

132 

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)) 

139 

140 return errors 

141 

142 

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) 

152 

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 

162 

163 return retval 

164 

165 

166if __name__ == "__main__": # pragma: no mutate 

167 sys.exit(main())