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

1"""Command for validating Rhiza template configuration. 

2 

3This module provides functionality to validate template.yml files in the 

4.rhiza/template.yml location (new standard location after migration). 

5""" 

6 

7from pathlib import Path 

8from typing import Any 

9 

10import yaml # type: ignore[import-untyped] 

11from loguru import logger 

12 

13 

14def _check_git_repository(target: Path) -> bool: 

15 """Check if target is a git repository. 

16 

17 Args: 

18 target: Path to check. 

19 

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 

28 

29 

30def _check_project_structure(target: Path) -> None: 

31 """Check for standard project structure. 

32 

33 Args: 

34 target: Path to project. 

35 """ 

36 logger.debug("Validating project structure") 

37 src_dir = target / "src" 

38 tests_dir = target / "tests" 

39 

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

45 

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

51 

52 

53def _check_pyproject_toml(target: Path) -> bool: 

54 """Check for pyproject.toml file. 

55 

56 Args: 

57 target: Path to project. 

58 

59 Returns: 

60 True if pyproject.toml exists, False otherwise. 

61 """ 

62 logger.debug("Validating pyproject.toml") 

63 pyproject_file = target / "pyproject.toml" 

64 

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 

73 

74 

75def _check_template_file_exists(target: Path) -> tuple[bool, Path]: 

76 """Check if template file exists. 

77 

78 Args: 

79 target: Path to project. 

80 

81 Returns: 

82 Tuple of (exists, template_file_path). 

83 """ 

84 template_file = target / ".rhiza" / "template.yml" 

85 

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 

97 

98 logger.success(f"Template file exists: {template_file.relative_to(target)}") 

99 return True, template_file 

100 

101 

102def _parse_yaml_file(template_file: Path) -> tuple[bool, dict[str, Any] | None]: 

103 """Parse YAML file and return configuration. 

104 

105 Args: 

106 template_file: Path to template file. 

107 

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 

119 

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 

124 

125 logger.success("YAML syntax is valid") 

126 return True, config 

127 

128 

129def _validate_configuration_mode(config: dict[str, Any]) -> bool: 

130 """Validate that at least one of templates or include is specified. 

131 

132 Args: 

133 config: Configuration dictionary. 

134 

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

141 

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 

148 

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 

157 

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

165 

166 return True 

167 

168 

169def _validate_templates(config: dict[str, Any]) -> bool: 

170 """Validate templates field if present. 

171 

172 Args: 

173 config: Configuration dictionary. 

174 

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 

181 

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 

199 

200 

201def _validate_required_fields(config: dict[str, Any]) -> bool: 

202 """Validate required fields exist and have correct types. 

203 

204 Args: 

205 config: Configuration dictionary. 

206 

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 } 

216 

217 validation_passed = True 

218 

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

232 

233 return validation_passed 

234 

235 

236def _validate_repository_format(config: dict[str, Any]) -> bool: 

237 """Validate template-repository format. 

238 

239 Args: 

240 config: Configuration dictionary. 

241 

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 

248 

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 

261 

262 

263def _validate_include_paths(config: dict[str, Any]) -> bool: 

264 """Validate include paths. 

265 

266 Args: 

267 config: Configuration dictionary. 

268 

269 Returns: 

270 True if valid, False otherwise. 

271 """ 

272 logger.debug("Validating include paths") 

273 if "include" not in config: 

274 return True 

275 

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 

293 

294 

295def _validate_optional_fields(config: dict[str, Any]) -> None: 

296 """Validate optional fields if present. 

297 

298 Args: 

299 config: Configuration dictionary. 

300 """ 

301 logger.debug("Validating optional fields") 

302 

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

311 

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

323 

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

337 

338 

339def validate(target: Path) -> bool: 

340 """Validate template.yml configuration in the target repository. 

341 

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 

350 

351 Args: 

352 target: Path to the target Git repository directory. 

353 

354 Returns: 

355 True if validation passes, False otherwise. 

356 """ 

357 target = target.resolve() 

358 logger.info(f"Validating template configuration in: {target}") 

359 

360 # Check if target is a git repository 

361 if not _check_git_repository(target): 

362 return False 

363 

364 # Check for standard project structure 

365 _check_project_structure(target) 

366 

367 # Check for pyproject.toml 

368 if not _check_pyproject_toml(target): 

369 return False 

370 

371 # Check for template file 

372 exists, template_file = _check_template_file_exists(target) 

373 if not exists: 

374 return False 

375 

376 # Parse YAML file 

377 success, config = _parse_yaml_file(template_file) 

378 if not success or config is None: 

379 return False 

380 

381 # Validate configuration mode (templates OR include) 

382 if not _validate_configuration_mode(config): 

383 return False 

384 

385 # Validate required fields 

386 validation_passed = _validate_required_fields(config) 

387 

388 # Validate specific field formats 

389 if not _validate_repository_format(config): 

390 validation_passed = False 

391 

392 # Validate templates if present 

393 if config.get("templates"): 

394 if not _validate_templates(config): 

395 validation_passed = False 

396 

397 # Validate include if present 

398 if config.get("include"): 

399 if not _validate_include_paths(config): 

400 validation_passed = False 

401 

402 # Validate optional fields 

403 _validate_optional_fields(config) 

404 

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