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, TwinClass};
#[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 artifact_orphans: Vec<OrphanFile>,
pub entrypoint_orphans: 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" && !d.entrypoint)
.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 Export Candidates - High Confidence ({})\n",
high_confidence_dead.len()
)
.unwrap();
writeln!(
out,
"_Cross-checked candidates (literal layer, symbol graph, entry-point fence). The detector proposes; you decide._\n"
)
.unwrap();
for dead in high_confidence_dead.iter().take(display_limit) {
writeln!(
out,
"- [ ] Delete candidate `{}` in {}:{} — {}",
dead.symbol,
dead.file,
dead.line.unwrap_or(0),
dead.reason
)
.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" || d.entrypoint)
.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();
write_twins_by_class(&mut out, &findings.twins, limit, display_limit);
}
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()
|| !findings.artifact_orphans.is_empty()
|| !findings.entrypoint_orphans.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_\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.entrypoint_orphans.is_empty() {
writeln!(
out,
"### Runtime Entrypoints Without Importers ({} files — not orphans)\n",
findings.entrypoint_orphans.len()
)
.unwrap();
writeln!(
out,
"_Cargo [[bin]], package.json main/bin, shebang scripts and detected main markers. They have no importers by design — roots, not dead files._\n"
)
.unwrap();
for orphan in findings.entrypoint_orphans.iter().take(display_limit) {
writeln!(out, "- `{}` ({} LOC)", orphan.path, orphan.loc).unwrap();
}
write_limit_notice(
&mut out,
findings.entrypoint_orphans.len(),
limit,
"--limit",
);
writeln!(out).unwrap();
}
if !findings.artifact_orphans.is_empty() {
writeln!(
out,
"### Artifact / Doc Orphans ({} files — excluded from review)\n",
findings.artifact_orphans.len()
)
.unwrap();
writeln!(
out,
"_Generated files, lockfiles, vendored code, fixtures and docs with no importers. Not actionable orphans._\n"
)
.unwrap();
for orphan in findings.artifact_orphans.iter().take(display_limit) {
writeln!(out, "- `{}` ({} LOC)", orphan.path, orphan.loc).unwrap();
}
write_limit_notice(&mut out, findings.artifact_orphans.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" && !d.entrypoint)
.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,
"- [ ] Review delete candidate `{}` 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");
let exact_twins: Vec<_> = findings
.twins
.iter()
.filter(|t| t.class == TwinClass::Exact)
.collect();
for twin in exact_twins.iter().take(display_limit) {
writeln!(
out,
"- [ ] Consolidate `{}` ({} locations)",
twin.name,
twin.locations.len()
)
.unwrap();
}
write_limit_notice(&mut out, exact_twins.len(), limit, "--limit");
let shape_twins: Vec<_> = findings
.twins
.iter()
.filter(|t| t.class == TwinClass::ShapeSimilar)
.collect();
for twin in shape_twins.iter().take(display_limit) {
writeln!(
out,
"- [ ] Review similar shapes of `{}` ({} locations)",
twin.name,
twin.locations.len()
)
.unwrap();
}
write_limit_notice(&mut out, shape_twins.len(), limit, "--limit");
let collision_twins: Vec<&ExactTwin> = findings
.twins
.iter()
.filter(|t| t.class == TwinClass::NameCollision)
.collect();
let collision_groups = collision_file_groups(&collision_twins);
for (files, symbols) in collision_groups.iter().take(display_limit) {
if symbols.len() > 1 {
writeln!(
out,
"- [ ] Review systemic twin-pair {} ({} shared symbol names)",
files
.iter()
.map(|f| format!("`{}`", f))
.collect::<Vec<_>>()
.join(" ↔ "),
symbols.len()
)
.unwrap();
} else {
writeln!(
out,
"- [ ] Review name collision `{}` ({} locations)",
symbols[0],
files.len()
)
.unwrap();
}
}
write_limit_notice(&mut out, collision_groups.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 collision_file_groups<'a>(twins: &[&'a ExactTwin]) -> Vec<(Vec<String>, Vec<&'a str>)> {
let mut groups: std::collections::BTreeMap<Vec<String>, Vec<&'a str>> =
std::collections::BTreeMap::new();
for twin in twins {
let mut files: Vec<String> = twin.locations.iter().map(|l| l.file_path.clone()).collect();
files.sort();
files.dedup();
groups.entry(files).or_default().push(twin.name.as_str());
}
let mut out: Vec<(Vec<String>, Vec<&'a str>)> = groups.into_iter().collect();
out.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
out
}
fn write_twins_by_class(
out: &mut String,
twins: &[ExactTwin],
limit: Option<usize>,
display_limit: usize,
) {
let exact: Vec<&ExactTwin> = twins
.iter()
.filter(|t| t.class == TwinClass::Exact)
.collect();
let shape: Vec<&ExactTwin> = twins
.iter()
.filter(|t| t.class == TwinClass::ShapeSimilar)
.collect();
let collisions: Vec<&ExactTwin> = twins
.iter()
.filter(|t| t.class == TwinClass::NameCollision)
.collect();
let idioms: Vec<&ExactTwin> = twins
.iter()
.filter(|t| t.class == TwinClass::Idiom)
.collect();
let twin_line = |twin: &ExactTwin| {
let locations: Vec<_> = twin
.locations
.iter()
.map(|l| l.file_path.as_str())
.collect();
format!(
"`{}`: {} locations ({})",
twin.name,
twin.locations.len(),
locations.join(", ")
)
};
if !exact.is_empty() {
writeln!(
out,
"#### EXACT — identical shape, consolidate ({})\n",
exact.len()
)
.unwrap();
for twin in exact.iter().take(display_limit) {
writeln!(out, "- [ ] Consolidate {}", twin_line(twin)).unwrap();
}
write_limit_notice(out, exact.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !shape.is_empty() {
writeln!(
out,
"#### SHAPE_SIMILAR — overlapping shape, review ({})\n",
shape.len()
)
.unwrap();
for twin in shape.iter().take(display_limit) {
writeln!(out, "- [ ] Review {}", twin_line(twin)).unwrap();
}
write_limit_notice(out, shape.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !collisions.is_empty() {
let groups = collision_file_groups(&collisions);
writeln!(
out,
"#### NAME_COLLISION — informational ({} symbols across {} file groups)\n",
collisions.len(),
groups.len()
)
.unwrap();
writeln!(
out,
"_Same name, no shape evidence of duplication. Do not consolidate blindly._\n"
)
.unwrap();
for (files, symbols) in groups.iter().take(display_limit) {
if symbols.len() > 1 {
let mut examples: Vec<&str> = symbols.iter().take(3).copied().collect();
examples.sort_unstable();
writeln!(
out,
"- [ ] Systemic twin-pair {}: {} shared symbol names (e.g. {})",
files
.iter()
.map(|f| format!("`{}`", f))
.collect::<Vec<_>>()
.join(" ↔ "),
symbols.len(),
examples.join(", ")
)
.unwrap();
} else {
writeln!(
out,
"- [ ] `{}`: name collision across {}",
symbols[0],
files
.iter()
.map(|f| format!("`{}`", f))
.collect::<Vec<_>>()
.join(", ")
)
.unwrap();
}
}
write_limit_notice(out, groups.len(), limit, "--limit");
writeln!(out).unwrap();
}
if !idioms.is_empty() {
let names: Vec<&str> = idioms.iter().take(5).map(|t| t.name.as_str()).collect();
writeln!(
out,
"#### IDIOM — language convention, no action ({}): {}\n",
idioms.len(),
names.join(", ")
)
.unwrap();
}
}
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" && !dead.entrypoint {
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_key(|b| std::cmp::Reverse(b.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,
action: "delete_candidate".to_string(),
entrypoint: 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"));
}
fn twin(name: &str, class: TwinClass, files: &[&str]) -> ExactTwin {
use super::super::twins::{TwinClassification, TwinLocation};
ExactTwin {
name: name.to_string(),
locations: files
.iter()
.map(|f| TwinLocation {
file_path: f.to_string(),
line: 1,
kind: "function".to_string(),
import_count: 0,
is_canonical: false,
signature_fingerprint: None,
})
.collect(),
signature_similarity: None,
classification: match class {
TwinClass::Exact | TwinClass::ShapeSimilar => TwinClassification::Duplicate,
_ => TwinClassification::Namesake,
},
class,
}
}
#[test]
fn test_name_collision_pairs_aggregate_per_file_pair() {
let findings = AuditFindings {
twins: (0..30)
.map(|idx| {
twin(
&format!("shared_symbol_{idx}"),
TwinClass::NameCollision,
&["src/pack.rs", "src/context/mod.rs"],
)
})
.collect(),
..AuditFindings::default()
};
let report = generate_markdown_report(&findings, None);
let pair_rows = report
.lines()
.filter(|l| l.contains("Systemic twin-pair"))
.count();
assert_eq!(
pair_rows, 1,
"expected ONE aggregated twin-pair row:\n{report}"
);
assert!(report.contains("30 shared symbol names"), "{report}");
assert!(
report.contains("`src/context/mod.rs` ↔ `src/pack.rs`"),
"{report}"
);
assert!(!report.contains("Consolidate"), "{report}");
let todos = generate_todos(&findings, None);
let todo_rows = todos
.lines()
.filter(|l| l.contains("systemic twin-pair"))
.count();
assert_eq!(todo_rows, 1, "expected ONE aggregated todo row:\n{todos}");
assert!(!todos.contains("Consolidate"), "{todos}");
}
#[test]
fn test_consolidate_only_for_exact_class() {
let findings = AuditFindings {
twins: vec![
twin("render_widget", TwinClass::Exact, &["src/a.rs", "src/b.rs"]),
twin(
"register",
TwinClass::NameCollision,
&["src/c.rs", "src/d.rs"],
),
twin("from_env", TwinClass::Idiom, &["src/e.rs", "src/f.rs"]),
],
..AuditFindings::default()
};
let report = generate_markdown_report(&findings, None);
let consolidate_lines: Vec<_> = report
.lines()
.filter(|l| l.contains("Consolidate"))
.collect();
assert!(
consolidate_lines
.iter()
.all(|l| l.contains("render_widget")),
"Consolidate must only mention the EXACT twin:\n{report}"
);
assert!(report.contains("EXACT"), "{report}");
assert!(report.contains("NAME_COLLISION"), "{report}");
assert!(report.contains("IDIOM"), "{report}");
let todos = generate_todos(&findings, None);
let todo_consolidate: Vec<_> = todos
.lines()
.filter(|l| l.contains("Consolidate"))
.collect();
assert_eq!(todo_consolidate.len(), 1, "{todos}");
assert!(todo_consolidate[0].contains("render_widget"), "{todos}");
}
}