ferrous_forge/templates/
validation.rs1use crate::error::{Error, Result};
7use crate::templates::manifest::TemplateManifest;
8use std::path::Path;
9
10#[derive(Debug, Clone)]
12pub struct ValidationResult {
13 pub valid: bool,
15 pub errors: Vec<String>,
17 pub warnings: Vec<String>,
19}
20
21impl ValidationResult {
22 pub fn new() -> Self {
24 Self {
25 valid: true,
26 errors: Vec::new(),
27 warnings: Vec::new(),
28 }
29 }
30
31 pub fn add_error(&mut self, message: impl Into<String>) {
33 self.errors.push(message.into());
34 self.valid = false;
35 }
36
37 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
49pub 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 for warning in &result.warnings {
67 tracing::warn!("Template warning: {}", warning);
68 }
69
70 Ok(())
71}
72
73pub 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(manifest, &mut result);
85
86 validate_template_files(template_dir, manifest, &mut result).await;
88
89 validate_template_structure(template_dir, &mut result).await;
91
92 validate_ferrous_forge_compliance(template_dir, manifest, &mut result).await;
94
95 result
96}
97
98fn validate_manifest_fields(manifest: &TemplateManifest, result: &mut ValidationResult) {
100 if manifest.name.is_empty() {
102 result.add_error("Template name cannot be empty");
103 }
104
105 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 if manifest.description.is_empty() {
117 result.add_warning("Template description is empty");
118 }
119
120 if manifest.author.is_empty() {
122 result.add_warning("Template author is empty");
123 }
124
125 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 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 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 if manifest.files.is_empty() {
149 result.add_error("Template must have at least one file");
150 }
151}
152
153async 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 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
179async fn validate_template_structure(template_dir: &Path, result: &mut ValidationResult) {
181 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 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 let src_dir = template_dir.join("src");
201 if !src_dir.exists() {
202 result.add_warning("No src/ directory found");
203 }
204}
205
206async fn validate_ferrous_forge_compliance(
208 template_dir: &Path,
209 manifest: &TemplateManifest,
210 result: &mut ValidationResult,
211) {
212 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 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 if manifest.edition != "2024" {
227 result.add_warning(format!(
228 "Template uses edition {} instead of recommended 2024",
229 manifest.edition
230 ));
231 }
232}
233
234fn is_valid_version(version: &str) -> bool {
236 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
245pub async fn validate_before_install(template_dir: &Path) -> Result<ValidationResult> {
256 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 let result = validate_template_detailed(template_dir, &manifest).await;
273
274 Ok(result)
275}