Skip to main content

ferrous_forge/validation/
rust_validator.rs

1//! Core Rust validation - modularized structure
2
3/// Individual file validation checks for Rust source and Cargo.toml.
4pub mod file_checks;
5/// Regex-based validation patterns for code style enforcement.
6pub mod patterns;
7
8use crate::config::Config;
9use crate::validation::{Severity, Violation, ViolationType};
10use crate::{Error, Result};
11use file_checks::{validate_cargo_toml_full, validate_rust_file};
12use patterns::ValidationPatterns;
13use regex::Regex;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17/// Result from running clippy
18#[derive(Debug, Clone)]
19pub struct ClippyResult {
20    /// Whether clippy ran successfully
21    pub success: bool,
22    /// Output from clippy command
23    pub output: String,
24}
25
26/// Core Rust validator
27pub struct RustValidator {
28    /// Root directory of the project to validate
29    project_root: PathBuf,
30    /// Compiled regex patterns for validation
31    patterns: ValidationPatterns,
32    /// Active configuration (drives limits and locked settings)
33    config: Config,
34}
35
36impl RustValidator {
37    /// Create a new validator with default configuration
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if the validation regex patterns fail to compile.
42    pub fn new(project_root: PathBuf) -> Result<Self> {
43        Self::with_config(project_root, Config::default())
44    }
45
46    /// Create a new validator with explicit configuration
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the validation regex patterns fail to compile.
51    pub fn with_config(project_root: PathBuf, config: Config) -> Result<Self> {
52        let patterns = ValidationPatterns::new()?;
53        Ok(Self {
54            project_root,
55            patterns,
56            config,
57        })
58    }
59
60    /// Get reference to validation patterns
61    pub fn patterns(&self) -> &ValidationPatterns {
62        &self.patterns
63    }
64
65    /// Validate all Rust code in the project
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if files cannot be read, the Rust compiler version
70    /// cannot be determined, or `Cargo.toml` files cannot be parsed.
71    pub async fn validate_project(&self) -> Result<Vec<Violation>> {
72        let mut violations = Vec::new();
73
74        // Check installed Rust version against config minimum
75        self.check_rust_version(&mut violations).await?;
76
77        // Find and validate all Cargo.toml files
78        let cargo_files = self.find_cargo_files().await?;
79        for cargo_file in cargo_files {
80            validate_cargo_toml_full(
81                &cargo_file,
82                &mut violations,
83                &self.config.required_edition,
84                &self.config.required_rust_version,
85            )
86            .await?;
87        }
88
89        // Find and validate all Rust source files
90        let rust_files = self.find_rust_files().await?;
91        for rust_file in rust_files {
92            validate_rust_file(
93                &rust_file,
94                &mut violations,
95                &self.patterns,
96                self.config.max_file_lines,
97                self.config.max_function_lines,
98            )
99            .await?;
100        }
101
102        Ok(violations)
103    }
104
105    /// Generate a human-readable report from violations
106    pub fn generate_report(&self, violations: &[Violation]) -> String {
107        if violations.is_empty() {
108            return "✅ All Rust validation checks passed! Code meets Ferrous Forge standards."
109                .to_string();
110        }
111
112        let mut report = format!(
113            "❌ Found {} violations of Ferrous Forge standards:\n\n",
114            violations.len()
115        );
116
117        let grouped_violations = self.group_violations_by_type(violations);
118        self.add_violation_sections(&mut report, grouped_violations);
119
120        report
121    }
122
123    /// Group violations by their type
124    fn group_violations_by_type<'a>(
125        &self,
126        violations: &'a [Violation],
127    ) -> std::collections::HashMap<&'a ViolationType, Vec<&'a Violation>> {
128        let mut by_type = std::collections::HashMap::new();
129        for violation in violations {
130            by_type
131                .entry(&violation.violation_type)
132                .or_insert_with(Vec::new)
133                .push(violation);
134        }
135        by_type
136    }
137
138    /// Add violation sections to the report
139    fn add_violation_sections(
140        &self,
141        report: &mut String,
142        grouped_violations: std::collections::HashMap<&ViolationType, Vec<&Violation>>,
143    ) {
144        for (violation_type, violations) in grouped_violations {
145            let type_name = format!("{:?}", violation_type)
146                .to_uppercase()
147                .replace('_', " ");
148
149            report.push_str(&format!(
150                "🚨 {} ({} violations):\n",
151                type_name,
152                violations.len()
153            ));
154
155            self.add_violation_details(report, &violations);
156            report.push('\n');
157        }
158    }
159
160    /// Add individual violation details to the report
161    fn add_violation_details(&self, report: &mut String, violations: &[&Violation]) {
162        for violation in violations.iter().take(10) {
163            report.push_str(&format!(
164                "  {}:{} - {}\n",
165                violation.file.display(),
166                violation.line + 1,
167                violation.message
168            ));
169        }
170
171        if violations.len() > 10 {
172            report.push_str(&format!("  ... and {} more\n", violations.len() - 10));
173        }
174    }
175
176    /// Run clippy with strict configuration
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the clippy command fails to execute.
181    pub async fn run_clippy(&self) -> Result<ClippyResult> {
182        let output = Command::new("cargo")
183            .args(&[
184                "clippy",
185                "--all-features",
186                "--",
187                "-D",
188                "warnings",
189                "-D",
190                "clippy::unwrap_used",
191                "-D",
192                "clippy::expect_used",
193                "-D",
194                "clippy::panic",
195                "-D",
196                "clippy::unimplemented",
197                "-D",
198                "clippy::todo",
199            ])
200            .current_dir(&self.project_root)
201            .output()
202            .map_err(|e| Error::process(format!("Failed to run clippy: {}", e)))?;
203
204        Ok(ClippyResult {
205            success: output.status.success(),
206            output: String::from_utf8_lossy(&output.stdout).to_string()
207                + &String::from_utf8_lossy(&output.stderr),
208        })
209    }
210
211    async fn check_rust_version(&self, violations: &mut Vec<Violation>) -> Result<()> {
212        let output = Command::new("rustc")
213            .arg("--version")
214            .output()
215            .map_err(|_| Error::validation("Rust compiler not found"))?;
216
217        let version_line = String::from_utf8_lossy(&output.stdout);
218
219        let version_regex = Regex::new(r"rustc (\d+)\.(\d+)\.(\d+)")
220            .map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
221
222        if let Some(captures) = version_regex.captures(&version_line) {
223            let major: u32 = captures[1].parse().unwrap_or(0);
224            let minor: u32 = captures[2].parse().unwrap_or(0);
225
226            // Check against the configured minimum (parse required_rust_version)
227            let min_minor = self.parse_required_minor();
228
229            if major < 1 || (major == 1 && minor < min_minor) {
230                violations.push(Violation {
231                    violation_type: ViolationType::OldRustVersion,
232                    file: PathBuf::from("<system>"),
233                    line: 0,
234                    message: format!(
235                        "Rust version {}.{} is too old. Minimum required: {}",
236                        major, minor, self.config.required_rust_version
237                    ),
238                    severity: Severity::Error,
239                });
240            }
241        } else {
242            violations.push(Violation {
243                violation_type: ViolationType::OldRustVersion,
244                file: PathBuf::from("<system>"),
245                line: 0,
246                message: "Could not parse Rust version".to_string(),
247                severity: Severity::Error,
248            });
249        }
250
251        Ok(())
252    }
253
254    /// Parse the minor version number from the `required_rust_version` string
255    fn parse_required_minor(&self) -> u32 {
256        let parts: Vec<&str> = self.config.required_rust_version.split('.').collect();
257        if parts.len() >= 2 {
258            parts[1].parse().unwrap_or(82)
259        } else {
260            82 // fallback to 1.82
261        }
262    }
263
264    async fn find_rust_files(&self) -> Result<Vec<PathBuf>> {
265        let mut rust_files = Vec::new();
266        self.collect_rust_files_recursive(&self.project_root, &mut rust_files)?;
267        Ok(rust_files)
268    }
269
270    fn collect_rust_files_recursive(
271        &self,
272        path: &Path,
273        rust_files: &mut Vec<PathBuf>,
274    ) -> Result<()> {
275        if path.to_string_lossy().contains("target/") {
276            return Ok(());
277        }
278
279        if path.is_file() {
280            if let Some(ext) = path.extension()
281                && ext == "rs"
282            {
283                rust_files.push(path.to_path_buf());
284            }
285        } else if path.is_dir() {
286            let entries = std::fs::read_dir(path)?;
287            for entry in entries {
288                let entry = entry?;
289                let entry_path = entry.path();
290                if entry_path.file_name() == Some(std::ffi::OsStr::new("target")) {
291                    continue;
292                }
293                self.collect_rust_files_recursive(&entry_path, rust_files)?;
294            }
295        }
296
297        Ok(())
298    }
299
300    async fn find_cargo_files(&self) -> Result<Vec<PathBuf>> {
301        let mut cargo_files = Vec::new();
302        self.collect_cargo_files_recursive(&self.project_root, &mut cargo_files)?;
303        Ok(cargo_files)
304    }
305
306    fn collect_cargo_files_recursive(
307        &self,
308        path: &Path,
309        cargo_files: &mut Vec<PathBuf>,
310    ) -> Result<()> {
311        if path.to_string_lossy().contains("target/") {
312            return Ok(());
313        }
314
315        if path.is_file() {
316            if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
317                cargo_files.push(path.to_path_buf());
318            }
319        } else if path.is_dir() {
320            let entries = std::fs::read_dir(path)?;
321            for entry in entries {
322                let entry = entry?;
323                let entry_path = entry.path();
324                if entry_path.file_name() == Some(std::ffi::OsStr::new("target")) {
325                    continue;
326                }
327                self.collect_cargo_files_recursive(&entry_path, cargo_files)?;
328            }
329        }
330
331        Ok(())
332    }
333}
334
335// Re-export for backwards compatibility
336pub use patterns::is_in_string_literal;