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<()> {
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 for warning in &result.warnings {
63 tracing::warn!("Template warning: {}", warning);
64 }
65
66 Ok(())
67}
68
69pub 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(manifest, &mut result);
81
82 validate_template_files(template_dir, manifest, &mut result).await;
84
85 validate_template_structure(template_dir, &mut result).await;
87
88 validate_ferrous_forge_compliance(template_dir, manifest, &mut result).await;
90
91 result
92}
93
94fn validate_manifest_fields(manifest: &TemplateManifest, result: &mut ValidationResult) {
96 if manifest.name.is_empty() {
98 result.add_error("Template name cannot be empty");
99 }
100
101 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 if manifest.description.is_empty() {
113 result.add_warning("Template description is empty");
114 }
115
116 if manifest.author.is_empty() {
118 result.add_warning("Template author is empty");
119 }
120
121 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 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 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 if manifest.files.is_empty() {
145 result.add_error("Template must have at least one file");
146 }
147}
148
149async 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 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
175async fn validate_template_structure(template_dir: &Path, result: &mut ValidationResult) {
177 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 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 let src_dir = template_dir.join("src");
197 if !src_dir.exists() {
198 result.add_warning("No src/ directory found");
199 }
200}
201
202async fn validate_ferrous_forge_compliance(
204 template_dir: &Path,
205 manifest: &TemplateManifest,
206 result: &mut ValidationResult,
207) {
208 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 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 if manifest.edition != "2024" {
223 result.add_warning(format!(
224 "Template uses edition {} instead of recommended 2024",
225 manifest.edition
226 ));
227 }
228}
229
230fn is_valid_version(version: &str) -> bool {
232 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
241pub async fn validate_before_install(template_dir: &Path) -> Result<ValidationResult> {
247 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 let result = validate_template_detailed(template_dir, &manifest).await;
264
265 Ok(result)
266}