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