Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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"));
    }
}