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        // Check version consistency (SSoT)
103        if self
104            .config
105            .validation
106            .check_version_consistency
107            .unwrap_or(true)
108        {
109            let version_validator = crate::validation::VersionConsistencyValidator::new(
110                self.project_root.clone(),
111                self.config.clone(),
112            )?;
113            let version_result = version_validator.validate().await?;
114            violations.extend(version_result.violations);
115        }
116
117        Ok(violations)
118    }
119
120    /// Generate a human-readable report from violations
121    pub fn generate_report(&self, violations: &[Violation]) -> String {
122        if violations.is_empty() {
123            return "✅ All Rust validation checks passed! Code meets Ferrous Forge standards."
124                .to_string();
125        }
126
127        let mut report = format!(
128            "❌ Found {} violations of Ferrous Forge standards:\n\n",
129            violations.len()
130        );
131
132        let grouped_violations = self.group_violations_by_type(violations);
133        self.add_violation_sections(&mut report, grouped_violations);
134
135        report
136    }
137
138    /// Group violations by their type
139    fn group_violations_by_type<'a>(
140        &self,
141        violations: &'a [Violation],
142    ) -> std::collections::HashMap<&'a ViolationType, Vec<&'a Violation>> {
143        let mut by_type = std::collections::HashMap::new();
144        for violation in violations {
145            by_type
146                .entry(&violation.violation_type)
147                .or_insert_with(Vec::new)
148                .push(violation);
149        }
150        by_type
151    }
152
153    /// Add violation sections to the report
154    fn add_violation_sections(
155        &self,
156        report: &mut String,
157        grouped_violations: std::collections::HashMap<&ViolationType, Vec<&Violation>>,
158    ) {
159        for (violation_type, violations) in grouped_violations {
160            let type_name = format!("{:?}", violation_type)
161                .to_uppercase()
162                .replace('_', " ");
163
164            report.push_str(&format!(
165                "🚨 {} ({} violations):\n",
166                type_name,
167                violations.len()
168            ));
169
170            self.add_violation_details(report, &violations);
171            report.push('\n');
172        }
173    }
174
175    /// Add individual violation details to the report
176    fn add_violation_details(&self, report: &mut String, violations: &[&Violation]) {
177        for violation in violations.iter().take(10) {
178            report.push_str(&format!(
179                "  {}:{} - {}\n",
180                violation.file.display(),
181                violation.line + 1,
182                violation.message
183            ));
184        }
185
186        if violations.len() > 10 {
187            report.push_str(&format!("  ... and {} more\n", violations.len() - 10));
188        }
189    }
190
191    /// Run clippy with strict configuration
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the clippy command fails to execute.
196    pub async fn run_clippy(&self) -> Result<ClippyResult> {
197        let output = Command::new("cargo")
198            .args(&[
199                "clippy",
200                "--all-features",
201                "--",
202                "-D",
203                "warnings",
204                "-D",
205                "clippy::unwrap_used",
206                "-D",
207                "clippy::expect_used",
208                "-D",
209                "clippy::panic",
210                "-D",
211                "clippy::unimplemented",
212                "-D",
213                "clippy::todo",
214            ])
215            .current_dir(&self.project_root)
216            .output()
217            .map_err(|e| Error::process(format!("Failed to run clippy: {}", e)))?;
218
219        Ok(ClippyResult {
220            success: output.status.success(),
221            output: String::from_utf8_lossy(&output.stdout).to_string()
222                + &String::from_utf8_lossy(&output.stderr),
223        })
224    }
225
226    async fn check_rust_version(&self, violations: &mut Vec<Violation>) -> Result<()> {
227        let output = Command::new("rustc")
228            .arg("--version")
229            .output()
230            .map_err(|_| Error::validation("Rust compiler not found"))?;
231
232        let version_line = String::from_utf8_lossy(&output.stdout);
233
234        let version_regex = Regex::new(r"rustc (\d+)\.(\d+)\.(\d+)")
235            .map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
236
237        if let Some(captures) = version_regex.captures(&version_line) {
238            let major: u32 = captures[1].parse().unwrap_or(0);
239            let minor: u32 = captures[2].parse().unwrap_or(0);
240
241            // Check against the configured minimum (parse required_rust_version)
242            let min_minor = self.parse_required_minor();
243
244            if major < 1 || (major == 1 && minor < min_minor) {
245                violations.push(Violation {
246                    violation_type: ViolationType::OldRustVersion,
247                    file: PathBuf::from("<system>"),
248                    line: 0,
249                    message: format!(
250                        "Rust version {}.{} is too old. Minimum required: {}",
251                        major, minor, self.config.required_rust_version
252                    ),
253                    severity: Severity::Error,
254                });
255            }
256        } else {
257            violations.push(Violation {
258                violation_type: ViolationType::OldRustVersion,
259                file: PathBuf::from("<system>"),
260                line: 0,
261                message: "Could not parse Rust version".to_string(),
262                severity: Severity::Error,
263            });
264        }
265
266        Ok(())
267    }
268
269    /// Parse the minor version number from the `required_rust_version` string
270    fn parse_required_minor(&self) -> u32 {
271        let parts: Vec<&str> = self.config.required_rust_version.split('.').collect();
272        if parts.len() >= 2 {
273            parts[1].parse().unwrap_or(82)
274        } else {
275            82 // fallback to 1.82
276        }
277    }
278
279    async fn find_rust_files(&self) -> Result<Vec<PathBuf>> {
280        let mut rust_files = Vec::new();
281        self.collect_rust_files_recursive(&self.project_root, &mut rust_files)?;
282        Ok(rust_files)
283    }
284
285    fn collect_rust_files_recursive(
286        &self,
287        path: &Path,
288        rust_files: &mut Vec<PathBuf>,
289    ) -> Result<()> {
290        if path.to_string_lossy().contains("target/") {
291            return Ok(());
292        }
293
294        if path.is_file() {
295            if let Some(ext) = path.extension()
296                && ext == "rs"
297            {
298                rust_files.push(path.to_path_buf());
299            }
300        } else if path.is_dir() {
301            let entries = std::fs::read_dir(path)?;
302            for entry in entries {
303                let entry = entry?;
304                let entry_path = entry.path();
305                if entry_path.file_name() == Some(std::ffi::OsStr::new("target")) {
306                    continue;
307                }
308                self.collect_rust_files_recursive(&entry_path, rust_files)?;
309            }
310        }
311
312        Ok(())
313    }
314
315    async fn find_cargo_files(&self) -> Result<Vec<PathBuf>> {
316        let mut cargo_files = Vec::new();
317        self.collect_cargo_files_recursive(&self.project_root, &mut cargo_files)?;
318        Ok(cargo_files)
319    }
320
321    fn collect_cargo_files_recursive(
322        &self,
323        path: &Path,
324        cargo_files: &mut Vec<PathBuf>,
325    ) -> Result<()> {
326        if path.to_string_lossy().contains("target/") {
327            return Ok(());
328        }
329
330        if path.is_file() {
331            if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
332                cargo_files.push(path.to_path_buf());
333            }
334        } else if path.is_dir() {
335            let entries = std::fs::read_dir(path)?;
336            for entry in entries {
337                let entry = entry?;
338                let entry_path = entry.path();
339                if entry_path.file_name() == Some(std::ffi::OsStr::new("target")) {
340                    continue;
341                }
342                self.collect_cargo_files_recursive(&entry_path, cargo_files)?;
343            }
344        }
345
346        Ok(())
347    }
348}
349
350// Re-export for backwards compatibility
351pub use patterns::is_in_string_literal;