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