crates/autonom/src/report.rs
//! **The "code that lacks tests" report** (feature `scan`) — the runnable output
//! the completeness gate produces for a real repo.
//!
//! [`feeder`](crate::feeder) turns source into facts; this module drives the pure
//! [`unreached_functions`] / [`compute_gap`] engine over them and shapes the
//! result into a human worklist: the untested functions, grouped by crate/module,
//! with a summary count and a deterministic Markdown rendering that the caller
//! writes to a durable artifact (feeding the constellation-wide test-writing
//! loop).
use std::collections::BTreeMap;
// Drive the pure engine through autonom's OWN façade (`crate::…`) — one import
// home, and the re-export is proven complete by this very use.
use crate::feeder::ScanResult;
use crate::{compute_gap, test_reachable, Allowlist, Mode, Surface, SurfaceKind, SurfaceNode};
/// One function with no test reaching it — a candidate that needs a test written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingFn {
/// The fully-qualified function id (`<crate>::<module>::<fn>`).
pub fqn: String,
/// The grouping bucket (the head segment — the crate/tenant).
pub group: String,
}
/// The full "needs tests" report for one scanned repo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeedsTestsReport {
/// The scanned root (for the report header).
pub root: String,
/// Total non-test functions in the surface.
pub total_functions: usize,
/// How many were reached by a test (covered).
pub covered: usize,
/// How many were excused by the allowlist.
pub allowlisted: usize,
/// The functions that need a test, sorted by fqn.
pub needs_tests: Vec<MissingFn>,
pub files_scanned: usize,
pub files_skipped: usize,
}
impl NeedsTestsReport {
/// Build the report from a scan + an optional allowlist (excused surfaces are
/// counted separately, not listed as needing tests).
pub fn from_scan(root: &str, scan: &ScanResult, allowlist: &Allowlist) -> Self {
// Surface = every non-test fn as a Function node.
let mut surface = Surface::new();
surface.extend(scan.symbols.iter().filter(|r| !r.is_test).map(|r| SurfaceNode {
kind: SurfaceKind::Function,
id: r.fqn.clone(),
mode: Mode::NA,
label: r.label.clone().unwrap_or_else(|| r.fqn.clone()),
caps: Default::default(),
}));
// Covered = the non-test fns a test can reach (their surface keys).
let reachable = test_reachable(&scan.symbols, &scan.edges);
let covered_keys = surface
.nodes
.iter()
.filter(|n| reachable.contains(n.id.as_str()))
.map(|n| n.key_str())
.collect();
let gap = compute_gap(&surface, &covered_keys, &allowlist.key_set());
let needs_tests = gap
.missing
.iter()
.map(|n| MissingFn { fqn: n.id.clone(), group: group_of(&n.id) })
.collect();
NeedsTestsReport {
root: root.to_string(),
total_functions: surface.len(),
covered: gap.covered,
allowlisted: gap.allowlisted.len(),
needs_tests,
files_scanned: scan.files_scanned,
files_skipped: scan.files_skipped,
}
}
/// The count of functions needing a test (the headline number).
pub fn gap_count(&self) -> usize {
self.needs_tests.len()
}
/// The needs-tests functions grouped by crate/tenant, each list sorted.
pub fn by_group(&self) -> BTreeMap<String, Vec<&MissingFn>> {
let mut map: BTreeMap<String, Vec<&MissingFn>> = BTreeMap::new();
for m in &self.needs_tests {
map.entry(m.group.clone()).or_default().push(m);
}
for v in map.values_mut() {
v.sort_by(|a, b| a.fqn.cmp(&b.fqn));
}
map
}
/// A one-line summary for the CLI.
pub fn summary(&self) -> String {
format!(
"{}/{} functions test-reached · {} allowlisted · {} NEED TESTS ({} files, {} unparseable)",
self.covered,
self.total_functions,
self.allowlisted,
self.gap_count(),
self.files_scanned,
self.files_skipped,
)
}
/// A deterministic Markdown rendering — the durable artifact that feeds the
/// test-writing loop. Grouped by crate, newest scan overwrites in place.
pub fn to_markdown(&self) -> String {
let mut s = String::new();
s.push_str("# autonom — code that lacks tests\n\n");
s.push_str(&format!("Scanned root: `{}`\n\n", self.root));
s.push_str(&format!(
"**{} functions need a test** — {}/{} test-reached, {} allowlisted \
({} files scanned, {} unparseable).\n\n",
self.gap_count(),
self.covered,
self.total_functions,
self.allowlisted,
self.files_scanned,
self.files_skipped,
));
s.push_str(
"> A function is listed when NO `#[test]` (or `#[cfg(test)]` code) reaches it \
through the call graph. Call resolution is by simple name, so the list \
under-reports on name collisions rather than crying wolf — triage it as a \
worklist, not a proof of deadness.\n\n",
);
let by_group = self.by_group();
if by_group.is_empty() {
s.push_str("_No untested functions found — every function is reached by a test._\n");
return s;
}
for (group, fns) in &by_group {
s.push_str(&format!("## {group} ({} need tests)\n\n", fns.len()));
for m in fns {
s.push_str(&format!("- `{}`\n", m.fqn));
}
s.push('\n');
}
s
}
}
/// The grouping head — the fqn's first `::` segment (the crate / tenant), or the
/// whole id if it has none.
fn group_of(fqn: &str) -> String {
fqn.split("::").next().unwrap_or(fqn).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feeder::Scanner;
use crate::AllowEntry;
fn scan(src: &str) -> ScanResult {
let mut s = Scanner::new();
s.add_source(src, "demo", false);
s.finish()
}
const SAMPLE: &str = r#"
pub fn covered() { helper(); }
fn helper() {}
pub fn orphan_a() {}
pub fn orphan_b() {}
#[cfg(test)]
mod tests {
#[test]
fn t() { crate::covered(); }
}
"#;
#[test]
fn report_lists_only_unreached_functions() {
let scan = scan(SAMPLE);
let r = NeedsTestsReport::from_scan("/repo", &scan, &Allowlist::new());
// Surface = covered, helper, orphan_a, orphan_b (4 non-test fns).
assert_eq!(r.total_functions, 4);
assert_eq!(r.covered, 2, "covered + helper are reached");
assert_eq!(r.gap_count(), 2, "the two orphans need tests");
let ids: Vec<_> = r.needs_tests.iter().map(|m| m.fqn.clone()).collect();
assert!(ids.iter().any(|i| i.ends_with("::orphan_a")));
assert!(ids.iter().any(|i| i.ends_with("::orphan_b")));
assert!(!ids.iter().any(|i| i.ends_with("::covered")));
}
#[test]
fn allowlist_excuses_a_function_from_the_list() {
let scan = scan(SAMPLE);
// Excuse orphan_a explicitly.
let allow = Allowlist {
entries: vec![AllowEntry {
key: "function:demo::orphan_a@na".into(),
reason: "tracked in issue #7".into(),
}],
};
let r = NeedsTestsReport::from_scan("/repo", &scan, &allow);
assert_eq!(r.allowlisted, 1);
assert_eq!(r.gap_count(), 1, "only orphan_b still needs a test");
assert!(r.needs_tests[0].fqn.ends_with("::orphan_b"));
}
#[test]
fn markdown_is_deterministic_and_grouped() {
let scan = scan(SAMPLE);
let r = NeedsTestsReport::from_scan("/repo", &scan, &Allowlist::new());
let md = r.to_markdown();
assert!(md.starts_with("# autonom — code that lacks tests"));
assert!(md.contains("2 functions need a test"));
assert!(md.contains("## demo (2 need tests)"));
assert!(md.contains("`demo::orphan_a`"));
// Deterministic: same scan → identical Markdown.
let r2 = NeedsTestsReport::from_scan("/repo", &scan, &Allowlist::new());
assert_eq!(md, r2.to_markdown());
}
#[test]
fn all_covered_reports_clean() {
let src = "pub fn a() {} #[cfg(test)] mod t { #[test] fn x() { crate::a(); } }";
let r = NeedsTestsReport::from_scan("/repo", &scan(src), &Allowlist::new());
assert_eq!(r.gap_count(), 0);
assert!(r.to_markdown().contains("No untested functions found"));
}
#[test]
fn groups_split_by_crate_head() {
let mut s = Scanner::new();
s.add_source("pub fn one() {}", "alpha::m", false);
s.add_source("pub fn two() {}", "beta::m", false);
let r = NeedsTestsReport::from_scan("/repo", &s.finish(), &Allowlist::new());
let groups = r.by_group();
assert_eq!(groups.len(), 2);
assert!(groups.contains_key("alpha") && groups.contains_key("beta"));
}
}