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<_>>(),
})
}