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

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 

11from loguru import logger 

12 

13from rhiza.language_validators import get_validator_registry 

14from rhiza.models.template import GitHost 

15 

16 

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

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

19 

20 Args: 

21 target: Path to check. 

22 

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 

31 

32 

33def _check_project_structure(target: Path, language: str) -> bool: 

34 """Check for language-specific project structure. 

35 

36 Args: 

37 target: Path to project. 

38 language: The programming language of the project. 

39 

40 Returns: 

41 True if validation passes, False otherwise. 

42 """ 

43 logger.debug(f"Validating project structure for language: {language}") 

44 

45 # Get the appropriate validator for the language 

46 registry = get_validator_registry() 

47 validator = registry.get_validator(language) 

48 

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 

54 

55 return validator.validate_project_structure(target) 

56 

57 

58def _check_template_file_exists(target: Path, template_file: Path | None = None) -> tuple[bool, Path]: 

59 """Check if template file exists. 

60 

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. 

65 

66 Returns: 

67 Tuple of (exists, template_file_path). 

68 """ 

69 if template_file is None: 

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

71 

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 

87 

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 

94 

95 

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

97 """Parse YAML file and return configuration. 

98 

99 Args: 

100 template_file: Path to template file. 

101 

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 

113 

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 

118 

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

120 return True, config 

121 

122 

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

124 """Validate that at least one of profile, templates, or include is specified. 

125 

126 Args: 

127 config: Configuration dictionary. 

128 

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 

136 

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 

152 

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 

159 

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 

169 

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

179 

180 return True 

181 

182 

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

184 """Validate templates field if present. 

185 

186 Args: 

187 config: Configuration dictionary. 

188 

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 

195 

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 

213 

214 

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

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

217 

218 Args: 

219 config: Configuration dictionary. 

220 

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) 

227 

228 validation_passed = True 

229 

230 # Check for template-repository or repository 

231 has_template_repo = "template-repository" in config 

232 has_repo = "repository" in config 

233 

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] 

242 

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

249 

250 return validation_passed 

251 

252 

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

254 """Validate template-repository or repository format. 

255 

256 Args: 

257 config: Configuration dictionary. 

258 

259 Returns: 

260 True if valid, False otherwise. 

261 """ 

262 logger.debug("Validating repository format") 

263 

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 

272 

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 

285 

286 

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

288 """Validate include paths. 

289 

290 Args: 

291 config: Configuration dictionary. 

292 

293 Returns: 

294 True if valid, False otherwise. 

295 """ 

296 logger.debug("Validating include paths") 

297 if "include" not in config: 

298 return True 

299 

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 

317 

318 

319def _validate_branch_field(config: dict[str, Any]) -> None: 

320 """Validate template-branch or ref field. 

321 

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" 

330 

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

338 

339 

340def _validate_host_field(config: dict[str, Any]) -> None: 

341 """Validate template-host field. 

342 

343 Args: 

344 config: Configuration dictionary. 

345 """ 

346 if "template-host" not in config: 

347 return 

348 

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

358 

359 

360def _validate_language_field(config: dict[str, Any]) -> None: 

361 """Validate language field. 

362 

363 Args: 

364 config: Configuration dictionary. 

365 """ 

366 if "language" not in config: 

367 return 

368 

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

381 

382 

383def _validate_exclude_field(config: dict[str, Any]) -> None: 

384 """Validate exclude field. 

385 

386 Args: 

387 config: Configuration dictionary. 

388 """ 

389 if "exclude" not in config: 

390 return 

391 

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

403 

404 

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

406 """Validate optional fields if present. 

407 

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) 

416 

417 

418def validate(target: Path, template_file: Path | None = None) -> bool: 

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

420 

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 

428 

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. 

433 

434 Returns: 

435 True if validation passes, False otherwise. 

436 """ 

437 target = target.resolve() 

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

439 

440 # Check if target is a git repository 

441 if not _check_git_repository(target): 

442 return False 

443 

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 

448 

449 # Parse YAML file 

450 success, config = _parse_yaml_file(template_file) 

451 if not success or config is None: 

452 return False 

453 

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

457 

458 # Check for language-specific project structure 

459 if not _check_project_structure(target, language): 

460 return False 

461 

462 # Validate configuration mode (templates OR include) 

463 if not _validate_configuration_mode(config): 

464 return False 

465 

466 # Validate required fields 

467 validation_passed = _validate_required_fields(config) 

468 

469 # Validate specific field formats 

470 if not _validate_repository_format(config): 

471 validation_passed = False 

472 

473 # Validate templates if present 

474 if config.get("templates") and not _validate_templates(config): 

475 validation_passed = False 

476 

477 # Validate include if present 

478 if config.get("include") and not _validate_include_paths(config): 

479 validation_passed = False 

480 

481 # Validate optional fields 

482 _validate_optional_fields(config) 

483 

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