use std::fmt::Write as _;
use serde::Serialize;
use super::crowd::types::Crowd;
use super::cycles::ClassifiedCycle;
use super::dead_parrots::DeadExport;
use super::twins::ExactTwin;
#[derive(Debug, Default)]
pub struct AuditFindings {
pub cycles: Vec<ClassifiedCycle>,
pub dead_exports: Vec<DeadExport>,
pub twins: Vec<ExactTwin>,
pub orphan_files: Vec<OrphanFile>,
pub shadow_exports: Vec<ShadowExport>,
pub crowds: Vec<Crowd>,
pub total_files: usize,
pub total_loc: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct OrphanFile {
pub path: String,
pub loc: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShadowExport {
pub name: String,
pub total_locations: usize,
pub dead_locations: usize,
}
fn write_limit_notice(out: &mut String, total_items: usize, limit: Option<usize>, reason: &str) {
if let Some(limit) = limit {
let omitted = total_items.saturating_sub(limit);
if omitted > 0 {
writeln!(
out,
"- _{} additional items omitted by {}_",
omitted, reason
)
.unwrap();
}
}
}
pub fn generate_markdown_report(findings: &AuditFindings, limit: Option<usize>) -> String {
let mut out = String::with_capacity(8192);
let display_limit = limit.unwrap_or(usize::MAX);
writeln!(out, "# Codebase Audit Report\n").unwrap();
writeln!(out, "_Generated by loctree_\n").unwrap();
let total_issues = findings.cycles.len()
+ findings.dead_exports.len()
+ findings.twins.len()
+ findings.orphan_files.len()
+ findings.shadow_exports.len();
let health_score = calculate_health_score(findings);
writeln!(out, "## Summary\n").unwrap();
writeln!(out, "| Metric | Value |\n|--------|-------|").unwrap();
writeln!(out, "| Files | {} |", findings.total_files).unwrap();
writeln!(out, "| Lines of Code | {} |", findings.total_loc).unwrap();
writeln!(out, "| Total Issues | {} |", total_issues).unwrap();
writeln!(out, "| Health Score | {}/100 |", health_score).unwrap();
writeln!(out).unwrap();
let breaking_cycles: Vec<_> = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
.collect();
let high_confidence_dead: Vec<_> = findings
.dead_exports
.iter()
.filter(|d| d.confidence == "high")
.collect();
if !breaking_cycles.is_empty() || !high_confidence_dead.is_empty() {
writeln!(out, "## [CRITICAL] Action Required\n").unwrap();
if !breaking_cycles.is_empty() {
writeln!(out, "### Breaking Cycles ({})\n", breaking_cycles.len()).unwrap();
for cycle in breaking_cycles.iter().take(display_limit) {
writeln!(out, "- [ ] {}", format_cycle(cycle)).unwrap();
}
write_limit_notice(&mut out, breaking_cycles.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !high_confidence_dead.is_empty() {
writeln!(
out,
"### Dead Exports - High Confidence ({})\n",
high_confidence_dead.len()
)
.unwrap();
for dead in high_confidence_dead.iter().take(display_limit) {
writeln!(
out,
"- [ ] Remove `{}` in {}:{}",
dead.symbol,
dead.file,
dead.line.unwrap_or(0)
)
.unwrap();
}
write_limit_notice(&mut out, high_confidence_dead.len(), limit, "--limit");
writeln!(out).unwrap();
}
}
let structural_cycles: Vec<_> = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
.collect();
let low_confidence_dead: Vec<_> = findings
.dead_exports
.iter()
.filter(|d| d.confidence != "high")
.collect();
if !structural_cycles.is_empty()
|| !low_confidence_dead.is_empty()
|| !findings.twins.is_empty()
|| !findings.shadow_exports.is_empty()
{
writeln!(out, "## [WARNING] Review Recommended\n").unwrap();
if !structural_cycles.is_empty() {
writeln!(out, "### Structural Cycles ({})\n", structural_cycles.len()).unwrap();
for cycle in structural_cycles.iter().take(display_limit) {
writeln!(out, "- [ ] {}", format_cycle(cycle)).unwrap();
}
write_limit_notice(&mut out, structural_cycles.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !findings.twins.is_empty() {
writeln!(out, "### Duplicate Symbols ({})\n", findings.twins.len()).unwrap();
for twin in findings.twins.iter().take(display_limit) {
let locations: Vec<_> = twin
.locations
.iter()
.map(|l| l.file_path.as_str())
.collect();
writeln!(
out,
"- [ ] `{}`: {} locations ({})",
twin.name,
twin.locations.len(),
locations.join(", ")
)
.unwrap();
}
write_limit_notice(&mut out, findings.twins.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !findings.shadow_exports.is_empty() {
writeln!(
out,
"### Shadow Exports ({})\n",
findings.shadow_exports.len()
)
.unwrap();
writeln!(out, "_Same symbol with some dead instances_\n").unwrap();
for shadow in findings.shadow_exports.iter().take(display_limit) {
writeln!(
out,
"- [ ] `{}`: {}/{} locations dead",
shadow.name, shadow.dead_locations, shadow.total_locations
)
.unwrap();
}
write_limit_notice(&mut out, findings.shadow_exports.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !low_confidence_dead.is_empty() {
writeln!(
out,
"### Dead Exports - Review Needed ({})\n",
low_confidence_dead.len()
)
.unwrap();
for dead in low_confidence_dead.iter().take(display_limit) {
writeln!(
out,
"- [ ] Review `{}` in {}:{}",
dead.symbol,
dead.file,
dead.line.unwrap_or(0)
)
.unwrap();
}
write_limit_notice(&mut out, low_confidence_dead.len(), limit, "--limit");
writeln!(out).unwrap();
}
}
if !findings.orphan_files.is_empty() || !findings.crowds.is_empty() {
writeln!(out, "## [INFO] For Reference\n").unwrap();
if !findings.orphan_files.is_empty() {
let total_orphan_loc: usize = findings.orphan_files.iter().map(|f| f.loc).sum();
writeln!(
out,
"### Orphan Files ({} files, {} LOC)\n",
findings.orphan_files.len(),
total_orphan_loc
)
.unwrap();
writeln!(out, "_Files with no importers (may be entry points)_\n").unwrap();
for orphan in findings.orphan_files.iter().take(display_limit) {
writeln!(out, "- `{}` ({} LOC)", orphan.path, orphan.loc).unwrap();
}
write_limit_notice(&mut out, findings.orphan_files.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !findings.crowds.is_empty() {
writeln!(out, "### Crowds ({})\n", findings.crowds.len()).unwrap();
writeln!(out, "_Files with high coupling_\n").unwrap();
for crowd in findings.crowds.iter().take(display_limit) {
writeln!(out, "- `{}`: {} files", crowd.pattern, crowd.members.len()).unwrap();
}
write_limit_notice(&mut out, findings.crowds.len(), limit, "--limit");
writeln!(out).unwrap();
}
}
let quick_wins = calculate_quick_wins(findings);
if !quick_wins.is_empty() {
writeln!(out, "## [QUICK WIN] Easy Improvements\n").unwrap();
for (i, win) in quick_wins.iter().enumerate().take(5) {
writeln!(out, "{}. {}", i + 1, win).unwrap();
}
write_limit_notice(&mut out, quick_wins.len(), Some(5), "quick-win summary cap");
writeln!(out).unwrap();
}
out
}
pub fn generate_todos(findings: &AuditFindings, limit: Option<usize>) -> String {
let mut out = String::with_capacity(4096);
let display_limit = limit.unwrap_or(usize::MAX);
let today = chrono::Local::now().format("%Y-%m-%d");
writeln!(out, "# Audit Todos ({})\n", today).unwrap();
let breaking_cycles: Vec<_> = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
.collect();
let high_confidence_dead: Vec<_> = findings
.dead_exports
.iter()
.filter(|d| d.confidence == "high")
.collect();
if !breaking_cycles.is_empty() || !high_confidence_dead.is_empty() {
writeln!(out, "## Critical\n").unwrap();
for cycle in breaking_cycles.iter().take(display_limit) {
writeln!(out, "- [ ] Break cycle: {}", format_cycle(cycle)).unwrap();
}
write_limit_notice(&mut out, breaking_cycles.len(), limit, "--limit");
for dead in high_confidence_dead.iter().take(display_limit) {
writeln!(
out,
"- [ ] Remove `{}` in {}:{}",
dead.symbol,
dead.file,
dead.line.unwrap_or(0)
)
.unwrap();
}
write_limit_notice(&mut out, high_confidence_dead.len(), limit, "--limit");
writeln!(out).unwrap();
}
let structural_cycles: Vec<_> = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
.collect();
if !structural_cycles.is_empty() || !findings.twins.is_empty() {
writeln!(out, "## Warnings\n").unwrap();
for cycle in structural_cycles.iter().take(display_limit) {
writeln!(out, "- [ ] Review cycle: {}", format_cycle(cycle)).unwrap();
}
write_limit_notice(&mut out, structural_cycles.len(), limit, "--limit");
for twin in findings.twins.iter().take(display_limit) {
writeln!(
out,
"- [ ] Consolidate `{}` ({} locations)",
twin.name,
twin.locations.len()
)
.unwrap();
}
write_limit_notice(&mut out, findings.twins.len(), limit, "--limit");
writeln!(out).unwrap();
}
let quick_wins = calculate_quick_wins(findings);
if !quick_wins.is_empty() {
writeln!(out, "## Quick Wins\n").unwrap();
for win in quick_wins.iter().take(5) {
writeln!(out, "- [ ] {}", win).unwrap();
}
write_limit_notice(&mut out, quick_wins.len(), Some(5), "quick-win summary cap");
}
out
}
fn format_cycle(cycle: &ClassifiedCycle) -> String {
cycle.nodes.join(" → ")
}
fn calculate_health_score(findings: &AuditFindings) -> u8 {
let mut score: i32 = 100;
let breaking = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
.count();
score -= (breaking * 10) as i32;
let structural = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
.count();
score -= (structural * 3) as i32;
let dead_high = findings
.dead_exports
.iter()
.filter(|d| d.confidence == "high")
.count();
score -= (dead_high.min(20) * 2) as i32;
score -= (findings.twins.len().min(10) * 2) as i32;
score.clamp(0, 100) as u8
}
fn calculate_quick_wins(findings: &AuditFindings) -> Vec<String> {
let mut wins = Vec::new();
let mut by_dir: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for dead in &findings.dead_exports {
if dead.confidence == "high" {
let dir = std::path::Path::new(&dead.file)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
let entry = by_dir.entry(dir).or_insert((0, 0));
entry.0 += 1;
entry.1 += 10;
}
}
let mut dirs: Vec<_> = by_dir.into_iter().collect();
dirs.sort_by(|a, b| b.1.0.cmp(&a.1.0));
for (dir, (count, loc_estimate)) in dirs.into_iter().take(3) {
if count >= 3 {
wins.push(format!(
"Clean `{}`: remove {} dead exports (~{} LOC)",
dir, count, loc_estimate
));
}
}
let breaking: Vec<_> = findings
.cycles
.iter()
.filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
.take(2)
.collect();
for cycle in breaking {
wins.push(format!(
"Break cycle in `{}`",
cycle.nodes.first().unwrap_or(&"unknown".to_string())
));
}
wins
}
#[cfg(test)]
mod tests {
use super::*;
fn dead_export(symbol: &str, line: usize) -> DeadExport {
DeadExport {
file: "src/lib.rs".into(),
symbol: symbol.into(),
line: Some(line),
confidence: "high".into(),
reason: "unused export".into(),
open_url: None,
is_test: false,
}
}
#[test]
fn test_empty_findings_generates_report() {
let findings = AuditFindings::default();
let report = generate_markdown_report(&findings, None);
assert!(report.contains("# Codebase Audit Report"));
assert!(report.contains("Health Score | 100/100"));
}
#[test]
fn test_todos_output() {
let findings = AuditFindings::default();
let todos = generate_todos(&findings, None);
assert!(todos.contains("# Audit Todos"));
}
#[test]
fn test_markdown_report_is_full_by_default() {
let findings = AuditFindings {
dead_exports: (0..3)
.map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
.collect(),
..AuditFindings::default()
};
let report = generate_markdown_report(&findings, None);
assert!(report.contains("dead_0"));
assert!(report.contains("dead_1"));
assert!(report.contains("dead_2"));
assert!(!report.contains("omitted by --limit"));
}
#[test]
fn test_markdown_report_calls_out_explicit_limit() {
let findings = AuditFindings {
dead_exports: (0..3)
.map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
.collect(),
..AuditFindings::default()
};
let report = generate_markdown_report(&findings, Some(2));
assert!(report.contains("dead_0"));
assert!(report.contains("dead_1"));
assert!(!report.contains("dead_2"));
assert!(report.contains("1 additional items omitted by --limit"));
}
#[test]
fn test_todos_call_out_explicit_limit() {
let findings = AuditFindings {
dead_exports: (0..3)
.map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
.collect(),
..AuditFindings::default()
};
let todos = generate_todos(&findings, Some(2));
assert!(todos.contains("dead_0"));
assert!(todos.contains("dead_1"));
assert!(!todos.contains("dead_2"));
assert!(todos.contains("1 additional items omitted by --limit"));
}
}