use anyhow::Result;
use std::cell::OnceCell;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use super::Node;
pub struct QualityAnalyzer {
warnings: OnceCell<Option<HashMap<String, usize>>>,
cargo_cmd: String,
collect_compiler_warnings: bool,
}
impl QualityAnalyzer {
pub fn new() -> Self {
Self::with_cargo_cmd("cargo")
}
pub fn with_cargo_cmd(cmd: &str) -> Self {
Self {
warnings: OnceCell::new(),
cargo_cmd: cmd.to_string(),
collect_compiler_warnings: true,
}
}
pub fn static_only() -> Self {
Self {
warnings: OnceCell::new(),
cargo_cmd: "cargo".to_string(),
collect_compiler_warnings: false,
}
}
pub fn analyze_node(&self, node: &mut Node) -> Result<()> {
let Some(ref path) = node.path.clone() else {
return Ok(());
};
node.warning_count = self
.collect_compiler_warnings
.then(|| self.warnings_for(path))
.flatten();
node.complexity = Some(cyclomatic_complexity(Path::new(path))?);
node.dead_code_ratio = Some(dead_code_ratio(Path::new(path))?);
node.coverage = None;
Ok(())
}
fn warnings_for(&self, path: &str) -> Option<usize> {
if self.warnings.get().is_none() {
let _ = self.warnings.set(collect_warnings_from(&self.cargo_cmd));
}
let map = self.warnings.get()?.as_ref()?;
let prefix = format!("{}/", path.trim_end_matches('/'));
Some(
map.iter()
.filter(|(file, _)| file.starts_with(&prefix) || file.as_str() == path)
.map(|(_, n)| n)
.sum(),
)
}
}
impl Default for QualityAnalyzer {
fn default() -> Self {
Self::new()
}
}
pub fn collect_warnings_from(cmd: &str) -> Option<HashMap<String, usize>> {
let output = match Command::new(cmd)
.args(["check", "--message-format=json"])
.output()
{
Ok(output) => output,
Err(e) => {
eprintln!("warning: cargo check unavailable, skipping warning metrics: {e}");
return None;
}
};
let mut counts = HashMap::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
continue;
}
let diag = &msg["message"];
if diag.get("level").and_then(|l| l.as_str()) != Some("warning") {
continue;
}
let Some(spans) = diag.get("spans").and_then(|s| s.as_array()) else {
continue;
};
for span in spans {
if span.get("is_primary").and_then(|p| p.as_bool()) == Some(true) {
if let Some(file) = span.get("file_name").and_then(|f| f.as_str()) {
*counts.entry(file.to_string()).or_insert(0) += 1;
}
}
}
}
Some(counts)
}
fn read_rs_files(path: &Path) -> Result<Vec<String>> {
let mut contents = Vec::new();
if path.is_file() {
if path.extension().map_or(false, |e| e == "rs") {
contents.push(std::fs::read_to_string(path)?);
}
} else if path.is_dir() {
for entry in walkdir::WalkDir::new(path) {
let entry = entry?;
if entry.path().extension().map_or(false, |e| e == "rs") {
contents.push(std::fs::read_to_string(entry.path())?);
}
}
}
Ok(contents)
}
pub fn cyclomatic_complexity(path: &Path) -> Result<f64> {
let mut total = 0.0;
for content in read_rs_files(path)? {
let mut score = 1.0;
for token in [
" if ",
" else if ",
" match ",
" for ",
" while ",
" loop ",
"&&",
"||",
] {
score += content.matches(token).count() as f64;
}
total += score;
}
Ok(total)
}
pub fn dead_code_ratio(path: &Path) -> Result<f64> {
let mut allows = 0usize;
let mut fns = 0usize;
for content in read_rs_files(path)? {
allows += content.matches("#[allow(dead_code)]").count();
fns += content.matches("fn ").count();
}
Ok(if fns == 0 {
0.0
} else {
(allows as f64 / fns as f64).min(1.0)
})
}