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/bin/autonom.rs

//! `autonom` — the runnable completeness feeder.
//!
//! Scans a repository's Rust source, computes which functions no test reaches,
//! and emits the "code that lacks tests" report. This is the seam that makes the
//! pure `autonom` gate *runnable* against a real tree without the warehouse /
//! facett feeders that live in nornir.
//!
//! ```text
//! autonom coverage [<repo-root>] [--out <file.md>] [--json]
//! ```
//!
//! - `<repo-root>` defaults to the current directory.
//! - `--out <file>` writes the Markdown report to a durable artifact (the
//!   test-writing worklist); the one-line summary always prints to stderr.
//! - `--json` prints the report as JSON to stdout instead of the summary.
//! - An `<repo-root>/.nornir/autonom-allow.toml` allowlist, if present, excuses
//!   its entries from the needs-tests list (the honest burn-down escape hatch).

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::{bail, Context, Result};
use autonom::feeder::scan_repo;
use autonom::report::NeedsTestsReport;
use autonom::{allowlist_path, load_allowlist};

fn main() -> ExitCode {
    match run() {
        // Exit 0 always: this is a REPORT, not a gate — a non-empty worklist is
        // the expected, useful output. The count already went to stderr.
        Ok(_) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("autonom: {e:#}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<usize> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
        print_usage();
        return Ok(0);
    }
    match args[0].as_str() {
        "coverage" => cmd_coverage(&args[1..]),
        other => {
            print_usage();
            bail!("unknown command `{other}` (expected `coverage`)");
        }
    }
}

fn print_usage() {
    eprintln!(
        "autonom — code-that-lacks-tests report\n\n\
         USAGE:\n  \
         autonom coverage [<repo-root>] [--out <file.md>] [--json]\n\n\
         Scans <repo-root> (default: .) for functions no test reaches and reports them,\n\
         grouped by crate. An optional <repo-root>/.nornir/autonom-allow.toml excuses\n\
         listed surfaces. --out writes a Markdown worklist; --json prints JSON."
    );
}

fn cmd_coverage(args: &[String]) -> Result<usize> {
    let mut root: Option<PathBuf> = None;
    let mut out: Option<PathBuf> = None;
    let mut json = false;
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--out" => {
                i += 1;
                let p = args.get(i).context("--out needs a file path")?;
                out = Some(PathBuf::from(p));
            }
            "--json" => json = true,
            other if other.starts_with('-') => bail!("unknown flag `{other}`"),
            other => root = Some(PathBuf::from(other)),
        }
        i += 1;
    }
    let root = root.unwrap_or_else(|| PathBuf::from("."));
    let root_display = root.display().to_string();

    let scan = scan_repo(&root).with_context(|| format!("scan {root_display}"))?;

    // Load a checked-in allowlist if the repo has one (excused surfaces).
    let al_path = allowlist_path(&root);
    let allowlist = load_allowlist(&al_path).unwrap_or_default();

    let report = NeedsTestsReport::from_scan(&root_display, &scan, &allowlist);

    // Summary always goes to stderr so `--json`/`--out` stdout stays clean.
    eprintln!("{}", report.summary());

    if json {
        println!("{}", serde_json::to_string_pretty(&report_to_json(&report))?);
    }
    if let Some(out) = out {
        write_markdown(&out, &report.to_markdown())?;
        eprintln!("autonom: wrote worklist -> {}", out.display());
    }
    Ok(report.gap_count())
}

fn write_markdown(path: &Path, md: &str) -> Result<()> {
    if let Some(dir) = path.parent() {
        if !dir.as_os_str().is_empty() {
            std::fs::create_dir_all(dir)
                .with_context(|| format!("create dir {}", dir.display()))?;
        }
    }
    std::fs::write(path, md).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

fn report_to_json(r: &NeedsTestsReport) -> serde_json::Value {
    serde_json::json!({
        "root": r.root,
        "total_functions": r.total_functions,
        "covered": r.covered,
        "allowlisted": r.allowlisted,
        "gap": r.gap_count(),
        "files_scanned": r.files_scanned,
        "files_skipped": r.files_skipped,
        "needs_tests": r.needs_tests.iter().map(|m| {
            serde_json::json!({ "fqn": m.fqn, "group": m.group })
        }).collect::<Vec<_>>(),
    })
}