Coverage for src / rhiza / commands / validate.py: 100%
129 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 01:59 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 01:59 +0000
1"""Command for validating Rhiza template configuration.
3This module provides functionality to validate template.yml files in the
4.rhiza/template.yml location (new standard location after migration).
5"""
7from pathlib import Path
9import yaml
10from loguru import logger
13def validate(target: Path) -> bool:
14 """Validate template.yml configuration in the target repository.
16 Performs authoritative validation of the template configuration:
17 - Checks if target is a git repository
18 - Checks for standard project structure (src and tests folders)
19 - Checks for pyproject.toml (required)
20 - Checks if template.yml exists
21 - Validates YAML syntax
22 - Validates required fields
23 - Validates field values are appropriate
25 Args:
26 target: Path to the target Git repository directory.
28 Returns:
29 True if validation passes, False otherwise.
30 """
31 # Convert to absolute path to avoid path resolution issues
32 target = target.resolve()
34 # Check if target is a git repository by looking for .git directory
35 # Rhiza only works with git repositories
36 if not (target / ".git").is_dir():
37 logger.error(f"Target directory is not a git repository: {target}")
38 logger.error("Initialize a git repository with 'git init' first")
39 return False
41 logger.info(f"Validating template configuration in: {target}")
43 # Check for standard project structure (src and tests folders)
44 logger.debug("Validating project structure")
45 src_dir = target / "src"
46 tests_dir = target / "tests"
48 if not src_dir.exists():
49 logger.warning(f"Standard 'src' folder not found: {src_dir}")
50 logger.warning("Consider creating a 'src' directory for source code")
51 else:
52 logger.success(f"'src' folder exists: {src_dir}")
54 if not tests_dir.exists():
55 logger.warning(f"Standard 'tests' folder not found: {tests_dir}")
56 logger.warning("Consider creating a 'tests' directory for test files")
57 else:
58 logger.success(f"'tests' folder exists: {tests_dir}")
60 # Check for pyproject.toml - this is always required
61 logger.debug("Validating pyproject.toml")
62 pyproject_file = target / "pyproject.toml"
64 if not pyproject_file.exists():
65 logger.error(f"pyproject.toml not found: {pyproject_file}")
66 logger.error("pyproject.toml is required for Python projects")
67 logger.info("Run 'rhiza init' to create a default pyproject.toml")
68 return False
69 else:
70 logger.success(f"pyproject.toml exists: {pyproject_file}")
72 # Check for template.yml in new location only
73 template_file = target / ".rhiza" / "template.yml"
75 if not template_file.exists():
76 logger.error(f"No template file found at: {template_file.relative_to(target)}")
77 logger.error("The template configuration must be in the .rhiza folder.")
78 logger.info("")
79 logger.info("To fix this:")
80 logger.info(" • If you're starting fresh, run: rhiza init")
81 logger.info(" • If you have an existing configuration, run: rhiza migrate")
82 logger.info("")
83 logger.info("The 'rhiza migrate' command will move your configuration from")
84 logger.info(" .github/rhiza/template.yml → .rhiza/template.yml")
85 return False
87 logger.success(f"Template file exists: {template_file.relative_to(target)}")
89 # Validate YAML syntax by attempting to parse the file
90 logger.debug(f"Parsing YAML file: {template_file}")
91 try:
92 with open(template_file) as f:
93 config = yaml.safe_load(f)
94 except yaml.YAMLError as e:
95 logger.error(f"Invalid YAML syntax in template.yml: {e}")
96 logger.error("Fix the YAML syntax errors and try again")
97 return False
99 # Check if the file is completely empty
100 if config is None:
101 logger.error("template.yml is empty")
102 logger.error("Add configuration to template.yml or run 'rhiza init' to generate defaults")
103 return False
105 logger.success("YAML syntax is valid")
107 # Validate required fields exist and have correct types
108 # template-repository: Must be a string in 'owner/repo' format
109 # include: Must be a non-empty list of paths
110 logger.debug("Validating required fields")
111 required_fields = {
112 "template-repository": str,
113 "include": list,
114 }
116 validation_passed = True
118 # Check each required field
119 for field, expected_type in required_fields.items():
120 if field not in config:
121 logger.error(f"Missing required field: {field}")
122 logger.error(f"Add '{field}' to your template.yml")
123 validation_passed = False
124 elif not isinstance(config[field], expected_type):
125 logger.error(
126 f"Field '{field}' must be of type {expected_type.__name__}, got {type(config[field]).__name__}"
127 )
128 logger.error(f"Fix the type of '{field}' in template.yml")
129 validation_passed = False
130 else:
131 logger.success(f"Field '{field}' is present and valid")
133 # Validate template-repository format
134 # Must be in 'owner/repo' format (e.g., 'jebel-quant/rhiza')
135 logger.debug("Validating template-repository format")
136 if "template-repository" in config:
137 repo = config["template-repository"]
138 if not isinstance(repo, str):
139 logger.error(f"template-repository must be a string, got {type(repo).__name__}")
140 logger.error("Example: 'owner/repository'")
141 validation_passed = False
142 elif "/" not in repo:
143 logger.error(f"template-repository must be in format 'owner/repo', got: {repo}")
144 logger.error("Example: 'jebel-quant/rhiza'")
145 validation_passed = False
146 else:
147 logger.success(f"template-repository format is valid: {repo}")
149 # Validate include paths
150 # Must be a non-empty list of strings
151 logger.debug("Validating include paths")
152 if "include" in config:
153 include = config["include"]
154 if not isinstance(include, list):
155 logger.error(f"include must be a list, got {type(include).__name__}")
156 logger.error("Example: include: ['.github', '.gitignore']")
157 validation_passed = False
158 elif len(include) == 0:
159 logger.error("include list cannot be empty")
160 logger.error("Add at least one path to materialize")
161 validation_passed = False
162 else:
163 logger.success(f"include list has {len(include)} path(s)")
164 # Log each included path for transparency
165 for path in include:
166 if not isinstance(path, str):
167 logger.warning(f"include path should be a string, got {type(path).__name__}: {path}")
168 else:
169 logger.info(f" - {path}")
171 # Validate optional fields if present
172 # template-branch: Branch name in the template repository
173 logger.debug("Validating optional fields")
174 if "template-branch" in config:
175 branch = config["template-branch"]
176 if not isinstance(branch, str):
177 logger.warning(f"template-branch should be a string, got {type(branch).__name__}: {branch}")
178 logger.warning("Example: 'main' or 'develop'")
179 else:
180 logger.success(f"template-branch is valid: {branch}")
182 # template-host: Git hosting platform (github or gitlab)
183 if "template-host" in config:
184 host = config["template-host"]
185 if not isinstance(host, str):
186 logger.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
187 logger.warning("Must be 'github' or 'gitlab'")
188 elif host not in ("github", "gitlab"):
189 logger.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
190 logger.warning("Other hosts are not currently supported")
191 else:
192 logger.success(f"template-host is valid: {host}")
194 # exclude: Optional list of paths to exclude from materialization
195 if "exclude" in config:
196 exclude = config["exclude"]
197 if not isinstance(exclude, list):
198 logger.warning(f"exclude should be a list, got {type(exclude).__name__}")
199 logger.warning("Example: exclude: ['.github/workflows/ci.yml']")
200 else:
201 logger.success(f"exclude list has {len(exclude)} path(s)")
202 # Log each excluded path for transparency
203 for path in exclude:
204 if not isinstance(path, str):
205 logger.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
206 else:
207 logger.info(f" - {path}")
209 # Final verdict on validation
210 logger.debug("Validation complete, determining final result")
211 if validation_passed:
212 logger.success("✓ Validation passed: template.yml is valid")
213 return True
214 else:
215 logger.error("✗ Validation failed: template.yml has errors")
216 logger.error("Fix the errors above and run 'rhiza validate' again")
217 return False