use anyhow::Result;
use arborist::AnalysisConfig;
use serde::Serialize;
use std::path::{Path, PathBuf};
const EXCLUDED_DIRS: &[&str] = &[
".straymark",
".git",
"node_modules",
"target",
"vendor",
"dist",
"build",
".venv",
"__pycache__",
];
const SOURCE_EXTENSIONS: &[&str] = &[
"rs", "py", "js", "ts", "jsx", "tsx", "java", "go",
"cs", "cpp", "cc", "cxx", "c", "h", "php", "kt", "swift",
];
#[derive(Debug, Serialize)]
pub struct AnalysisReport {
pub path: String,
pub threshold: u32,
pub functions: Vec<FunctionEntry>,
pub summary: AnalysisSummary,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct FunctionEntry {
pub file: String,
pub name: String,
pub line: u32,
pub cognitive: u32,
pub cyclomatic: u32,
pub sloc: u32,
}
#[derive(Debug, Serialize)]
pub struct AnalysisSummary {
pub files_analyzed: usize,
pub total_functions: usize,
pub above_threshold: usize,
pub above_threshold_pct: f64,
pub max_cognitive: u32,
pub max_cognitive_location: String,
pub avg_cognitive: f64,
}
fn walk_source_files(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
walk_recursive(root, &mut files);
files.sort();
files
}
fn walk_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !EXCLUDED_DIRS.contains(&name) {
walk_recursive(&path, files);
}
}
} else if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if SOURCE_EXTENSIONS.contains(&ext) {
files.push(path);
}
}
}
}
}
pub fn analyze_path(root: &Path, threshold: u32) -> Result<AnalysisReport> {
let files = walk_source_files(root);
let config = AnalysisConfig {
cognitive_threshold: Some(threshold as u64),
include_methods: true,
};
let mut all_functions: Vec<FunctionEntry> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut files_analyzed: usize = 0;
for file_path in &files {
match arborist::analyze_file_with_config(file_path, &config) {
Ok(report) => {
files_analyzed += 1;
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
for func in report.functions {
all_functions.push(FunctionEntry {
file: relative.clone(),
name: func.name,
line: func.start_line as u32,
cognitive: func.cognitive as u32,
cyclomatic: func.cyclomatic as u32,
sloc: func.sloc as u32,
});
}
}
Err(e) => {
let relative = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy();
warnings.push(format!("{}: {}", relative, e));
}
}
}
let total_functions = all_functions.len();
let above_threshold: Vec<&FunctionEntry> = all_functions
.iter()
.filter(|f| f.cognitive > threshold)
.collect();
let above_count = above_threshold.len();
let above_pct = if total_functions > 0 {
(above_count as f64 / total_functions as f64) * 100.0
} else {
0.0
};
let (max_cog, max_loc) = all_functions
.iter()
.max_by_key(|f| f.cognitive)
.map(|f| (f.cognitive, format!("{}:{}", f.file, f.name)))
.unwrap_or((0, String::new()));
let avg_cog = if total_functions > 0 {
all_functions.iter().map(|f| f.cognitive as f64).sum::<f64>() / total_functions as f64
} else {
0.0
};
Ok(AnalysisReport {
path: root.to_string_lossy().to_string(),
threshold,
functions: all_functions,
summary: AnalysisSummary {
files_analyzed,
total_functions,
above_threshold: above_count,
above_threshold_pct: above_pct,
max_cognitive: max_cog,
max_cognitive_location: max_loc,
avg_cognitive: avg_cog,
},
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_empty_directory() {
let dir = TempDir::new().unwrap();
let report = analyze_path(dir.path(), 8).unwrap();
assert_eq!(report.summary.files_analyzed, 0);
assert_eq!(report.summary.total_functions, 0);
assert!(report.functions.is_empty());
assert!(report.warnings.is_empty());
}
#[test]
fn test_walk_excludes_ignored_dirs() {
let dir = TempDir::new().unwrap();
for excluded in &["node_modules", "target", ".git"] {
let sub = dir.path().join(excluded);
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("lib.rs"), "fn excluded() {}").unwrap();
}
fs::write(dir.path().join("main.rs"), "fn included() {}").unwrap();
let files = walk_source_files(dir.path());
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("main.rs"));
}
#[test]
fn test_single_rust_file() {
let dir = TempDir::new().unwrap();
let code = r#"
fn simple_add(a: i32, b: i32) -> i32 {
a + b
}
"#;
fs::write(dir.path().join("lib.rs"), code).unwrap();
let report = analyze_path(dir.path(), 8).unwrap();
assert_eq!(report.summary.files_analyzed, 1);
assert_eq!(report.summary.total_functions, 1);
assert_eq!(report.functions[0].name, "simple_add");
assert_eq!(report.functions[0].cognitive, 0);
}
#[test]
fn test_threshold_filtering() {
let dir = TempDir::new().unwrap();
let code = r#"
fn complex(x: i32) -> i32 {
if x > 0 {
if x > 10 {
if x > 100 {
return x * 2;
}
return x + 1;
}
return x;
}
0
}
fn simple() -> i32 {
42
}
"#;
fs::write(dir.path().join("test.rs"), code).unwrap();
let report = analyze_path(dir.path(), 2).unwrap();
assert_eq!(report.summary.total_functions, 2);
assert!(report.summary.above_threshold >= 1);
}
#[test]
fn test_summary_statistics() {
let dir = TempDir::new().unwrap();
let code = r#"
fn a() -> i32 { 1 }
fn b() -> i32 { 2 }
fn c() -> i32 { 3 }
"#;
fs::write(dir.path().join("funcs.rs"), code).unwrap();
let report = analyze_path(dir.path(), 8).unwrap();
assert_eq!(report.summary.total_functions, 3);
assert_eq!(report.summary.above_threshold, 0);
assert_eq!(report.summary.above_threshold_pct, 0.0);
assert_eq!(report.summary.avg_cognitive, 0.0);
}
#[test]
fn test_unsupported_extension_skipped() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("notes.txt"), "not code").unwrap();
fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
let files = walk_source_files(dir.path());
assert!(files.is_empty());
}
}