Coverage for src / rhiza / commands / validate.py: 100%

243 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 07:04 +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 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 

136 # Error if old "bundles" field is used 

137 if "bundles" in config: 

138 logger.error("Field 'bundles' has been renamed to 'templates'") 

139 logger.error("Update your .rhiza/template.yml:") 

140 logger.error(" bundles: [...] → templates: [...]") 

141 return False 

142 

143 # Require at least one of templates or include 

144 if not has_templates and not has_include: 

145 logger.error("Must specify at least one of 'templates' or 'include' in template.yml") 

146 logger.error("Options:") 

147 logger.error(" • Template-based: templates: [core, tests, github]") 

148 logger.error(" • Path-based: include: [.rhiza, .github, ...]") 

149 logger.error(" • Hybrid: specify both templates and include") 

150 return False 

151 

152 # Log what mode is being used 

153 if has_templates and has_include: 

154 logger.success("Using hybrid mode (templates + include)") 

155 elif has_templates: 

156 logger.success("Using template-based mode") 

157 else: 

158 logger.success("Using path-based mode") 

159 

160 return True 

161 

162 

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

164 """Validate templates field if present. 

165 

166 Args: 

167 config: Configuration dictionary. 

168 

169 Returns: 

170 True if templates field is valid, False otherwise. 

171 """ 

172 logger.debug("Validating templates field") 

173 if "templates" not in config: 

174 return True 

175 

176 templates = config["templates"] 

177 if not isinstance(templates, list): 

178 logger.error(f"templates must be a list, got {type(templates).__name__}") 

179 logger.error("Example: templates: [core, tests, github]") 

180 return False 

181 elif len(templates) == 0: 

182 logger.error("templates list cannot be empty") 

183 logger.error("Add at least one template to materialize") 

184 return False 

185 else: 

186 logger.success(f"templates list has {len(templates)} template(s)") 

187 for template in templates: 

188 if not isinstance(template, str): 

189 logger.warning(f"template name should be a string, got {type(template).__name__}: {template}") 

190 else: 

191 logger.info(f" - {template}") 

192 return True 

193 

194 

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

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

197 

198 Args: 

199 config: Configuration dictionary. 

200 

201 Returns: 

202 True if all validations pass, False otherwise. 

203 """ 

204 logger.debug("Validating required fields") 

205 # template-repository (or repository) is required 

206 # include or bundles is required (validated separately) 

207 

208 validation_passed = True 

209 

210 # Check for template-repository or repository 

211 has_template_repo = "template-repository" in config 

212 has_repo = "repository" in config 

213 

214 if not has_template_repo and not has_repo: 

215 logger.error("Missing required field: 'template-repository' or 'repository'") 

216 logger.error("Add 'template-repository' or 'repository' to your template.yml") 

217 validation_passed = False 

218 else: 

219 # Check the type of whichever field is present (prefer template-repository) 

220 repo_field = "template-repository" if has_template_repo else "repository" 

221 repo_value = config[repo_field] 

222 

223 if not isinstance(repo_value, str): 

224 logger.error(f"Field '{repo_field}' must be of type str, got {type(repo_value).__name__}") 

225 logger.error(f"Fix the type of '{repo_field}' in template.yml") 

226 validation_passed = False 

227 else: 

228 logger.success(f"Field '{repo_field}' is present and valid") 

229 

230 return validation_passed 

231 

232 

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

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

235 

236 Args: 

237 config: Configuration dictionary. 

238 

239 Returns: 

240 True if valid, False otherwise. 

241 """ 

242 logger.debug("Validating repository format") 

243 

244 # Check for either template-repository or repository 

245 repo_field = None 

246 if "template-repository" in config: 

247 repo_field = "template-repository" 

248 elif "repository" in config: 

249 repo_field = "repository" 

250 else: 

251 return True # No repository field found, will be caught by _validate_required_fields 

252 

253 repo = config[repo_field] 

254 if not isinstance(repo, str): 

255 logger.error(f"{repo_field} must be a string, got {type(repo).__name__}") 

256 logger.error("Example: 'owner/repository'") 

257 return False 

258 elif "/" not in repo: 

259 logger.error(f"{repo_field} must be in format 'owner/repo', got: {repo}") 

260 logger.error("Example: 'jebel-quant/rhiza'") 

261 return False 

262 else: 

263 logger.success(f"{repo_field} format is valid: {repo}") 

264 return True 

265 

266 

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

268 """Validate include paths. 

269 

270 Args: 

271 config: Configuration dictionary. 

272 

273 Returns: 

274 True if valid, False otherwise. 

275 """ 

276 logger.debug("Validating include paths") 

277 if "include" not in config: 

278 return True 

279 

280 include = config["include"] 

281 if not isinstance(include, list): 

282 logger.error(f"include must be a list, got {type(include).__name__}") 

283 logger.error("Example: include: ['.github', '.gitignore']") 

284 return False 

285 elif len(include) == 0: 

286 logger.error("include list cannot be empty") 

287 logger.error("Add at least one path to materialize") 

288 return False 

289 else: 

290 logger.success(f"include list has {len(include)} path(s)") 

291 for path in include: 

292 if not isinstance(path, str): 

293 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}") 

294 else: 

295 logger.info(f" - {path}") 

296 return True 

297 

298 

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

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

301 

302 Args: 

303 config: Configuration dictionary. 

304 """ 

305 branch_field = None 

306 if "template-branch" in config: 

307 branch_field = "template-branch" 

308 elif "ref" in config: 

309 branch_field = "ref" 

310 

311 if branch_field: 

312 branch = config[branch_field] 

313 if not isinstance(branch, str): 

314 logger.warning(f"{branch_field} should be a string, got {type(branch).__name__}: {branch}") 

315 logger.warning("Example: 'main' or 'develop'") 

316 else: 

317 logger.success(f"{branch_field} is valid: {branch}") 

318 

319 

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

321 """Validate template-host field. 

322 

323 Args: 

324 config: Configuration dictionary. 

325 """ 

326 if "template-host" not in config: 

327 return 

328 

329 host = config["template-host"] 

330 if not isinstance(host, str): 

331 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}") 

332 logger.warning("Must be 'github' or 'gitlab'") 

333 elif host not in GitHost._value2member_map_: 

334 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}") 

335 logger.warning("Other hosts are not currently supported") 

336 else: 

337 logger.success(f"template-host is valid: {host}") 

338 

339 

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

341 """Validate language field. 

342 

343 Args: 

344 config: Configuration dictionary. 

345 """ 

346 if "language" not in config: 

347 return 

348 

349 language = config["language"] 

350 if not isinstance(language, str): 

351 logger.warning(f"language should be a string, got {type(language).__name__}: {language}") 

352 logger.warning("Example: 'python', 'go'") 

353 else: 

354 registry = get_validator_registry() 

355 supported_languages = registry.get_supported_languages() 

356 if language.lower() not in supported_languages: 

357 logger.warning(f"language '{language}' is not recognized") 

358 logger.warning(f"Supported languages: {', '.join(supported_languages)}") 

359 else: 

360 logger.success(f"language is valid: {language}") 

361 

362 

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

364 """Validate exclude field. 

365 

366 Args: 

367 config: Configuration dictionary. 

368 """ 

369 if "exclude" not in config: 

370 return 

371 

372 exclude = config["exclude"] 

373 if not isinstance(exclude, list): 

374 logger.warning(f"exclude should be a list, got {type(exclude).__name__}") 

375 logger.warning("Example: exclude: ['.github/workflows/ci.yml']") 

376 else: 

377 logger.success(f"exclude list has {len(exclude)} path(s)") 

378 for path in exclude: 

379 if not isinstance(path, str): 

380 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}") 

381 else: 

382 logger.info(f" - {path}") 

383 

384 

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

386 """Validate optional fields if present. 

387 

388 Args: 

389 config: Configuration dictionary. 

390 """ 

391 logger.debug("Validating optional fields") 

392 _validate_branch_field(config) 

393 _validate_host_field(config) 

394 _validate_language_field(config) 

395 _validate_exclude_field(config) 

396 

397 

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

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

400 

401 Performs authoritative validation of the template configuration: 

402 - Checks if target is a git repository 

403 - Checks for language-specific project structure 

404 - Checks if template.yml exists 

405 - Validates YAML syntax 

406 - Validates required fields 

407 - Validates field values are appropriate 

408 

409 Args: 

410 target: Path to the target Git repository directory. 

411 template_file: Optional explicit path to the template file. When 

412 ``None`` the default ``<target>/.rhiza/template.yml`` is used. 

413 

414 Returns: 

415 True if validation passes, False otherwise. 

416 """ 

417 target = target.resolve() 

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

419 

420 # Check if target is a git repository 

421 if not _check_git_repository(target): 

422 return False 

423 

424 # Check for template file first to get the language 

425 exists, template_file = _check_template_file_exists(target, template_file) 

426 if not exists: 

427 return False 

428 

429 # Parse YAML file 

430 success, config = _parse_yaml_file(template_file) 

431 if not success or config is None: 

432 return False 

433 

434 # Get the language from config (default to "python" for backward compatibility) 

435 language = config.get("language", "python") 

436 logger.info(f"Project language: {language}") 

437 

438 # Check for language-specific project structure 

439 if not _check_project_structure(target, language): 

440 return False 

441 

442 # Validate configuration mode (templates OR include) 

443 if not _validate_configuration_mode(config): 

444 return False 

445 

446 # Validate required fields 

447 validation_passed = _validate_required_fields(config) 

448 

449 # Validate specific field formats 

450 if not _validate_repository_format(config): 

451 validation_passed = False 

452 

453 # Validate templates if present 

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

455 validation_passed = False 

456 

457 # Validate include if present 

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

459 validation_passed = False 

460 

461 # Validate optional fields 

462 _validate_optional_fields(config) 

463 

464 # Final verdict 

465 logger.debug("Validation complete, determining final result") 

466 if validation_passed: 

467 logger.success("✓ Validation passed: template.yml is valid") 

468 return True 

469 else: 

470 logger.error("✗ Validation failed: template.yml has errors") 

471 logger.error("Fix the errors above and run 'rhiza validate' again") 

472 return False