pub mod file_checks;
pub mod patterns;
use crate::config::Config;
use crate::validation::{Severity, Violation, ViolationType};
use crate::{Error, Result};
use file_checks::{validate_cargo_toml_full, validate_rust_file};
use patterns::ValidationPatterns;
use regex::Regex;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ClippyResult {
pub success: bool,
pub output: String,
}
pub struct RustValidator {
project_root: PathBuf,
patterns: ValidationPatterns,
config: Config,
}
impl RustValidator {
pub fn new(project_root: PathBuf) -> Result<Self> {
Self::with_config(project_root, Config::default())
}
pub fn with_config(project_root: PathBuf, config: Config) -> Result<Self> {
let patterns = ValidationPatterns::new()?;
Ok(Self {
project_root,
patterns,
config,
})
}
pub fn patterns(&self) -> &ValidationPatterns {
&self.patterns
}
pub async fn validate_project(&self) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
self.check_rust_version(&mut violations).await?;
let cargo_files = self.find_cargo_files().await?;
for cargo_file in cargo_files {
validate_cargo_toml_full(
&cargo_file,
&mut violations,
&self.config.required_edition,
&self.config.required_rust_version,
)
.await?;
}
let rust_files = self.find_rust_files().await?;
for rust_file in rust_files {
validate_rust_file(
&rust_file,
&mut violations,
&self.patterns,
self.config.max_file_lines,
self.config.max_function_lines,
)
.await?;
}
if self
.config
.validation
.check_version_consistency
.unwrap_or(true)
{
let version_validator = crate::validation::VersionConsistencyValidator::new(
self.project_root.clone(),
self.config.clone(),
)?;
let version_result = version_validator.validate().await?;
violations.extend(version_result.violations);
}
Ok(violations)
}
pub fn generate_report(&self, violations: &[Violation]) -> String {
if violations.is_empty() {
return "✅ All Rust validation checks passed! Code meets Ferrous Forge standards."
.to_string();
}
let mut report = format!(
"❌ Found {} violations of Ferrous Forge standards:\n\n",
violations.len()
);
let grouped_violations = self.group_violations_by_type(violations);
self.add_violation_sections(&mut report, grouped_violations);
report
}
fn group_violations_by_type<'a>(
&self,
violations: &'a [Violation],
) -> std::collections::HashMap<&'a ViolationType, Vec<&'a Violation>> {
let mut by_type = std::collections::HashMap::new();
for violation in violations {
by_type
.entry(&violation.violation_type)
.or_insert_with(Vec::new)
.push(violation);
}
by_type
}
fn add_violation_sections(
&self,
report: &mut String,
grouped_violations: std::collections::HashMap<&ViolationType, Vec<&Violation>>,
) {
for (violation_type, violations) in grouped_violations {
let type_name = format!("{:?}", violation_type)
.to_uppercase()
.replace('_', " ");
report.push_str(&format!(
"🚨 {} ({} violations):\n",
type_name,
violations.len()
));
self.add_violation_details(report, &violations);
report.push('\n');
}
}
fn add_violation_details(&self, report: &mut String, violations: &[&Violation]) {
for violation in violations.iter().take(10) {
report.push_str(&format!(
" {}:{} - {}\n",
violation.file.display(),
violation.line + 1,
violation.message
));
}
if violations.len() > 10 {
report.push_str(&format!(" ... and {} more\n", violations.len() - 10));
}
}
pub async fn run_clippy(&self) -> Result<ClippyResult> {
let output = tokio::process::Command::new("cargo")
.args(&[
"clippy",
"--all-features",
"--",
"-D",
"warnings",
"-D",
"clippy::unwrap_used",
"-D",
"clippy::expect_used",
"-D",
"clippy::panic",
"-D",
"clippy::unimplemented",
"-D",
"clippy::todo",
])
.current_dir(&self.project_root)
.output()
.await
.map_err(|e| Error::process(format!("Failed to run clippy: {}", e)))?;
Ok(ClippyResult {
success: output.status.success(),
output: String::from_utf8_lossy(&output.stdout).to_string()
+ &String::from_utf8_lossy(&output.stderr),
})
}
async fn check_rust_version(&self, violations: &mut Vec<Violation>) -> Result<()> {
let output = tokio::process::Command::new("rustc")
.arg("--version")
.output()
.await
.map_err(|_| Error::validation("Rust compiler not found"))?;
let version_line = String::from_utf8_lossy(&output.stdout);
let version_regex = Regex::new(r"rustc (\d+)\.(\d+)\.(\d+)")
.map_err(|e| Error::validation(format!("Invalid regex: {}", e)))?;
if let Some(captures) = version_regex.captures(&version_line) {
let major: u32 = captures[1].parse().unwrap_or(0);
let minor: u32 = captures[2].parse().unwrap_or(0);
let min_minor = self.parse_required_minor();
if major < 1 || (major == 1 && minor < min_minor) {
violations.push(Violation {
violation_type: ViolationType::OldRustVersion,
file: PathBuf::from("<system>"),
line: 0,
message: format!(
"Rust version {}.{} is too old. Minimum required: {}",
major, minor, self.config.required_rust_version
),
severity: Severity::Error,
});
}
} else {
violations.push(Violation {
violation_type: ViolationType::OldRustVersion,
file: PathBuf::from("<system>"),
line: 0,
message: "Could not parse Rust version".to_string(),
severity: Severity::Error,
});
}
Ok(())
}
fn parse_required_minor(&self) -> u32 {
let parts: Vec<&str> = self.config.required_rust_version.split('.').collect();
if parts.len() >= 2 {
parts[1].parse().unwrap_or(82)
} else {
82 }
}
async fn find_rust_files(&self) -> Result<Vec<PathBuf>> {
let root = self.project_root.clone();
tokio::task::spawn_blocking(move || {
let mut rust_files = Vec::new();
collect_rust_files_recursive(&root, &mut rust_files)?;
Ok(rust_files)
})
.await
.map_err(|e| Error::process(format!("Task join error: {}", e)))?
}
async fn find_cargo_files(&self) -> Result<Vec<PathBuf>> {
let root = self.project_root.clone();
tokio::task::spawn_blocking(move || {
let mut cargo_files = Vec::new();
collect_cargo_files_recursive(&root, &mut cargo_files)?;
Ok(cargo_files)
})
.await
.map_err(|e| Error::process(format!("Task join error: {}", e)))?
}
}
const SKIP_DIRS: &[&str] = &[
"target",
"node_modules",
".git",
".claude",
".next",
"dist",
"build",
".turbo",
".pnpm",
".yarn",
"__pycache__",
".venv",
"vendor",
];
fn should_skip_dir(entry_path: &Path) -> bool {
entry_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| SKIP_DIRS.contains(&name))
}
fn collect_rust_files_recursive(path: &Path, rust_files: &mut Vec<PathBuf>) -> Result<()> {
if should_skip_dir(path) {
return Ok(());
}
if path.is_file() {
if let Some(ext) = path.extension()
&& ext == "rs"
{
rust_files.push(path.to_path_buf());
}
} else if path.is_dir() {
let entries = std::fs::read_dir(path)?;
for entry in entries {
let entry = entry?;
let entry_path = entry.path();
if should_skip_dir(&entry_path) {
continue;
}
collect_rust_files_recursive(&entry_path, rust_files)?;
}
}
Ok(())
}
fn collect_cargo_files_recursive(path: &Path, cargo_files: &mut Vec<PathBuf>) -> Result<()> {
if should_skip_dir(path) {
return Ok(());
}
if path.is_file() {
if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
cargo_files.push(path.to_path_buf());
}
} else if path.is_dir() {
let entries = std::fs::read_dir(path)?;
for entry in entries {
let entry = entry?;
let entry_path = entry.path();
if should_skip_dir(&entry_path) {
continue;
}
collect_cargo_files_recursive(&entry_path, cargo_files)?;
}
}
Ok(())
}
pub use patterns::is_in_string_literal;