Skip to main content

ferrous_forge/templates/
validation.rs

1//! Template validation for repository templates
2//!
3//! @task T021
4//! @epic T014
5
6use crate::error::{Error, Result};
7use crate::templates::manifest::TemplateManifest;
8use std::path::Path;
9
10/// Validation result with detailed errors
11#[derive(Debug, Clone)]
12pub struct ValidationResult {
13    /// Whether validation passed
14    pub valid: bool,
15    /// List of validation errors
16    pub errors: Vec<String>,
17    /// List of validation warnings
18    pub warnings: Vec<String>,
19}
20
21impl ValidationResult {
22    /// Create a new empty validation result
23    pub fn new() -> Self {
24        Self {
25            valid: true,
26            errors: Vec::new(),
27            warnings: Vec::new(),
28        }
29    }
30
31    /// Add an error
32    pub fn add_error(&mut self, message: impl Into<String>) {
33        self.errors.push(message.into());
34        self.valid = false;
35    }
36
37    /// Add a warning
38    pub fn add_warning(&mut self, message: impl Into<String>) {
39        self.warnings.push(message.into());
40    }
41}
42
43impl Default for ValidationResult {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Validate a template directory and its manifest
50///
51/// # Errors
52///
53/// Returns an error if the template fails validation checks.
54///
55/// @task T021
56/// @epic T014
57pub async fn validate_template(template_dir: &Path, manifest: &TemplateManifest) -> Result<()> {
58    let result = validate_template_detailed(template_dir, manifest).await;
59
60    if !result.valid {
61        let error_msg = format!("Template validation failed:\n{}", result.errors.join("\n"));
62        return Err(Error::template(error_msg));
63    }
64
65    // Log warnings
66    for warning in &result.warnings {
67        tracing::warn!("Template warning: {}", warning);
68    }
69
70    Ok(())
71}
72
73/// Validate template with detailed results
74///
75/// @task T021
76/// @epic T014
77pub async fn validate_template_detailed(
78    template_dir: &Path,
79    manifest: &TemplateManifest,
80) -> ValidationResult {
81    let mut result = ValidationResult::new();
82
83    // Validate manifest fields
84    validate_manifest_fields(manifest, &mut result);
85
86    // Validate template files exist
87    validate_template_files(template_dir, manifest, &mut result).await;
88
89    // Validate template structure
90    validate_template_structure(template_dir, &mut result).await;
91
92    // Validate ferrous-forge compliance
93    validate_ferrous_forge_compliance(template_dir, manifest, &mut result).await;
94
95    result
96}
97
98/// Validate manifest fields
99fn validate_manifest_fields(manifest: &TemplateManifest, result: &mut ValidationResult) {
100    // Check name
101    if manifest.name.is_empty() {
102        result.add_error("Template name cannot be empty");
103    }
104
105    // Check version format
106    if manifest.version.is_empty() {
107        result.add_error("Template version cannot be empty");
108    } else if !is_valid_version(&manifest.version) {
109        result.add_warning(format!(
110            "Version '{}' does not follow semantic versioning",
111            manifest.version
112        ));
113    }
114
115    // Check description
116    if manifest.description.is_empty() {
117        result.add_warning("Template description is empty");
118    }
119
120    // Check author
121    if manifest.author.is_empty() {
122        result.add_warning("Template author is empty");
123    }
124
125    // Check edition
126    let valid_editions = ["2015", "2018", "2021", "2024"];
127    if !valid_editions.contains(&manifest.edition.as_str()) {
128        result.add_warning(format!("Edition '{}' may not be valid", manifest.edition));
129    }
130
131    // Validate variable names
132    let mut seen_names = std::collections::HashSet::new();
133    for var in &manifest.variables {
134        if !seen_names.insert(&var.name) {
135            result.add_error(format!("Duplicate variable name: {}", var.name));
136        }
137
138        // Check for reserved variable names
139        if var.name == "project_name" || var.name == "author" {
140            result.add_warning(format!(
141                "Variable '{}' may conflict with default variables",
142                var.name
143            ));
144        }
145    }
146
147    // Validate files
148    if manifest.files.is_empty() {
149        result.add_error("Template must have at least one file");
150    }
151}
152
153/// Validate that template files exist
154async fn validate_template_files(
155    template_dir: &Path,
156    manifest: &TemplateManifest,
157    result: &mut ValidationResult,
158) {
159    for file in &manifest.files {
160        let source_path = template_dir.join(&file.source);
161        if !source_path.exists() {
162            result.add_error(format!(
163                "Template file not found: {}",
164                file.source.display()
165            ));
166        }
167    }
168
169    // Check for required Ferrous Forge files
170    let required_files = ["template.toml"];
171    for required in &required_files {
172        let path = template_dir.join(required);
173        if !path.exists() {
174            result.add_error(format!("Required file missing: {}", required));
175        }
176    }
177}
178
179/// Validate template structure
180async fn validate_template_structure(template_dir: &Path, result: &mut ValidationResult) {
181    // Check for Cargo.toml in template (for Rust projects)
182    let cargo_toml = template_dir.join("Cargo.toml");
183    if !cargo_toml.exists() {
184        result.add_warning("No Cargo.toml found - template may not be a Rust project");
185    } else {
186        // Try to parse Cargo.toml
187        match tokio::fs::read_to_string(&cargo_toml).await {
188            Ok(content) => {
189                if let Err(e) = toml::from_str::<toml::Value>(&content) {
190                    result.add_error(format!("Invalid Cargo.toml: {}", e));
191                }
192            }
193            Err(e) => {
194                result.add_error(format!("Failed to read Cargo.toml: {}", e));
195            }
196        }
197    }
198
199    // Check for src directory
200    let src_dir = template_dir.join("src");
201    if !src_dir.exists() {
202        result.add_warning("No src/ directory found");
203    }
204}
205
206/// Validate Ferrous Forge compliance
207async fn validate_ferrous_forge_compliance(
208    template_dir: &Path,
209    manifest: &TemplateManifest,
210    result: &mut ValidationResult,
211) {
212    // Check if template includes ferrous-forge configuration
213    let forge_config = template_dir.join(".ferrous-forge").join("config.toml");
214    if !forge_config.exists() {
215        result.add_warning("Template does not include Ferrous Forge configuration");
216    }
217
218    // Check for CI configuration
219    let ci_dirs = [".github/workflows", ".ci"];
220    let has_ci = ci_dirs.iter().any(|dir| template_dir.join(dir).exists());
221    if !has_ci {
222        result.add_warning("Template does not include CI configuration");
223    }
224
225    // Check edition compliance
226    if manifest.edition != "2024" {
227        result.add_warning(format!(
228            "Template uses edition {} instead of recommended 2024",
229            manifest.edition
230        ));
231    }
232}
233
234/// Check if version follows semantic versioning format
235fn is_valid_version(version: &str) -> bool {
236    // Basic semver check: major.minor.patch
237    let parts: Vec<&str> = version.split('.').collect();
238    if parts.len() < 2 || parts.len() > 3 {
239        return false;
240    }
241
242    parts.iter().all(|p| p.parse::<u64>().is_ok())
243}
244
245/// Validate template before installation
246///
247/// This is the main entry point for template validation.
248///
249/// # Errors
250///
251/// Returns an error if the manifest cannot be read or parsed.
252///
253/// @task T021
254/// @epic T014
255pub async fn validate_before_install(template_dir: &Path) -> Result<ValidationResult> {
256    // Load manifest
257    let manifest_path = template_dir.join("template.toml");
258    if !manifest_path.exists() {
259        return Err(Error::template(
260            "Template manifest not found: template.toml",
261        ));
262    }
263
264    let manifest_content = tokio::fs::read_to_string(&manifest_path)
265        .await
266        .map_err(|e| Error::template(format!("Failed to read manifest: {e}")))?;
267
268    let manifest: TemplateManifest = toml::from_str(&manifest_content)
269        .map_err(|e| Error::template(format!("Failed to parse manifest: {e}")))?;
270
271    // Run validation
272    let result = validate_template_detailed(template_dir, &manifest).await;
273
274    Ok(result)
275}