crates/nornir-docgen/src/test_inventory.rs
//! Test inventory — enumerate a repo's tests grouped by the **Rust-standard**
//! categories so the `tests` doc section can list them without hand-authoring:
//!
//! - **unit** — `#[test]` / `#[tokio::test]` (any `…::test`) fns under `src/`;
//! - **integration** — the same, under `tests/`;
//! - **doc** — runnable ```` ``` ```` code fences in `///` / `//!` doc comments.
//!
//! Source of truth is the crate sources parsed with `syn` (already a dep), so
//! the inventory stays correct as tests come and go.
use std::path::Path;
use walkdir::WalkDir;
/// Tests found in one `.rs` file (unit or integration).
#[derive(Debug, Clone)]
pub struct FileTests {
/// Repo-root-relative path.
pub file: String,
/// `#[test]`/`#[tokio::test]` fn names, in source order.
pub tests: Vec<String>,
}
/// Doc-tests found in one `.rs` file.
#[derive(Debug, Clone)]
pub struct FileDocTests {
pub file: String,
/// Count of runnable doc-test code fences.
pub count: usize,
}
/// The whole inventory for a repo.
#[derive(Debug, Default, Clone)]
pub struct TestInventory {
pub unit: Vec<FileTests>,
pub integration: Vec<FileTests>,
pub doc: Vec<FileDocTests>,
}
impl TestInventory {
pub fn unit_total(&self) -> usize {
self.unit.iter().map(|f| f.tests.len()).sum()
}
pub fn integration_total(&self) -> usize {
self.integration.iter().map(|f| f.tests.len()).sum()
}
pub fn doc_total(&self) -> usize {
self.doc.iter().map(|f| f.count).sum()
}
}
/// True if a `.rs` file (by stem) is really a **performance benchmark** rather
/// than a correctness test — these often live as `#[test]` fns (so `cargo test`
/// runs them) but measure throughput and print MB/s. Counting them as tests
/// inflates the test inventory and double-represents work that belongs in a
/// `benches` section. Skipped by default; `tests include_benches=true` keeps them.
pub fn is_benchmark_file(stem: &str) -> bool {
let s = stem.to_ascii_lowercase();
s.ends_with("_bench")
|| s.ends_with("_benches")
|| s.starts_with("perf_")
|| s.starts_with("bench_")
|| s.contains("throughput")
|| s.contains("benchmark")
}
/// Walk every crate under `repo_root` (optionally filtered to crate names
/// containing `crate_filter`) and collect its test inventory. When
/// `include_benches` is false (the default), files whose name marks them as
/// performance benchmarks (see [`is_benchmark_file`]) are skipped.
pub fn scan(repo_root: &Path, crate_filter: Option<&str>) -> TestInventory {
scan_opts(repo_root, crate_filter, false)
}
pub fn scan_opts(
repo_root: &Path,
crate_filter: Option<&str>,
include_benches: bool,
) -> TestInventory {
let mut inv = TestInventory::default();
for entry in WalkDir::new(repo_root)
.into_iter()
// Never filter the root itself (it may legitimately be a hidden dir, e.g.
// a `.tmp…` scan target); only prune skipped dirs encountered below it.
.filter_entry(|e| e.depth() == 0 || !is_skipped_dir(&e.file_name().to_string_lossy()))
.flatten()
{
if entry.file_name() != "Cargo.toml" {
continue;
}
let Ok(text) = std::fs::read_to_string(entry.path()) else {
continue;
};
let Ok(doc) = text.parse::<toml::Table>().map(toml::Value::Table) else {
continue;
};
let Some(crate_name) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
else {
continue;
};
if let Some(pat) = crate_filter {
if !crate_name.contains(pat) {
continue;
}
}
let Some(crate_dir) = entry.path().parent() else {
continue;
};
scan_crate(repo_root, crate_dir, &mut inv, include_benches);
}
inv.unit.sort_by(|a, b| a.file.cmp(&b.file));
inv.integration.sort_by(|a, b| a.file.cmp(&b.file));
inv.doc.sort_by(|a, b| a.file.cmp(&b.file));
inv
}
fn scan_crate(repo_root: &Path, crate_dir: &Path, inv: &mut TestInventory, include_benches: bool) {
// unit (src/) + integration (tests/); doc-tests come from src/.
for (sub, integration) in [("src", false), ("tests", true)] {
let dir = crate_dir.join(sub);
if !dir.is_dir() {
continue;
}
for entry in WalkDir::new(&dir)
.into_iter()
.filter_entry(|e| e.depth() == 0 || !is_skipped_dir(&e.file_name().to_string_lossy()))
.flatten()
{
let path = entry.path();
if !entry.file_type().is_file()
|| path.extension().and_then(|e| e.to_str()) != Some("rs")
{
continue;
}
// Skip performance-benchmark files (they belong in a benches section,
// not the test count) unless explicitly included.
if !include_benches {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if is_benchmark_file(stem) {
continue;
}
}
}
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
let rel = rel_path(repo_root, path);
let names = test_fn_names(&src);
if !names.is_empty() {
let ft = FileTests {
file: rel.clone(),
tests: names,
};
if integration {
inv.integration.push(ft);
} else {
inv.unit.push(ft);
}
}
// Doc-tests only counted from library/source files.
if !integration {
let count = count_doc_tests(&src);
if count > 0 {
inv.doc.push(FileDocTests { file: rel, count });
}
}
}
}
}
fn rel_path(repo_root: &Path, path: &Path) -> String {
path.strip_prefix(repo_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
/// Names of `#[test]`/`#[tokio::test]`-attributed fns (any attribute whose last
/// path segment is `test`), recursing into nested modules. `#[bench]` is *not*
/// a test and is excluded.
fn test_fn_names(src: &str) -> Vec<String> {
use syn::visit::{self, Visit};
struct Collector {
names: Vec<String>,
}
impl<'ast> Visit<'ast> for Collector {
fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
if has_test_attr(&f.attrs) {
self.names.push(f.sig.ident.to_string());
}
visit::visit_item_fn(self, f);
}
}
fn has_test_attr(attrs: &[syn::Attribute]) -> bool {
attrs
.iter()
.any(|a| a.path().segments.last().is_some_and(|s| s.ident == "test"))
}
let Ok(file) = syn::parse_file(src) else {
return Vec::new();
};
let mut c = Collector { names: Vec::new() };
c.visit_file(&file);
c.names
}
/// Count runnable doc-test code fences in `///` / `//!` comments. A fence whose
/// info string marks it non-runnable (`ignore`, `text`) is skipped; everything
/// else (empty, `rust`, `no_run`, `should_panic`, `compile_fail`, …) counts.
fn count_doc_tests(src: &str) -> usize {
let mut count = 0;
let mut in_fence = false;
for raw in src.lines() {
let line = raw.trim_start();
let Some(doc) = line
.strip_prefix("///")
.or_else(|| line.strip_prefix("//!"))
else {
continue;
};
let doc = doc.trim_start();
let Some(info) = doc.strip_prefix("```").or_else(|| doc.strip_prefix("~~~")) else {
continue;
};
if in_fence {
in_fence = false; // closing fence
continue;
}
in_fence = true;
let info = info.trim().to_ascii_lowercase();
let non_runnable = info.split([',', ' ']).any(|t| t == "ignore" || t == "text");
if !non_runnable {
count += 1;
}
}
count
}
fn is_skipped_dir(name: &str) -> bool {
// Build outputs (`target`, `target-flight`/`target-*`), node_modules, and every
// hidden dir (`.git`, `.nornir`, and `.claude` agent worktree/symlink farms —
// whose mirrored sibling checkouts would otherwise be counted as this repo's
// tests and needlessly walked).
name == "target"
|| name.starts_with("target-")
|| name == "node_modules"
|| name.starts_with('.')
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, s: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, s).unwrap();
}
#[test]
fn scans_unit_integration_and_doc() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(
&root.join("Cargo.toml"),
"[package]\nname='demo'\nversion='0.1.0'\n",
);
write(
&root.join("src/lib.rs"),
r#"
/// Adds.
/// ```
/// assert_eq!(demo::add(1,2), 3);
/// ```
/// ```ignore
/// not_run();
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
#[test]
fn unit_a() {}
#[tokio::test]
async fn unit_b() {}
#[bench]
fn not_a_test() {}
}
"#,
);
write(
&root.join("tests/it.rs"),
"#[test]\nfn integration_one() {}\n",
);
let inv = scan(root, None);
assert_eq!(inv.unit_total(), 2, "unit: {:?}", inv.unit);
assert_eq!(inv.integration_total(), 1);
assert_eq!(inv.doc_total(), 1, "only the runnable fence counts");
assert!(inv.unit[0].tests.contains(&"unit_a".to_string()));
assert!(!inv.unit[0].tests.contains(&"not_a_test".to_string()));
}
#[test]
fn skips_benchmark_files_by_default() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(
&root.join("Cargo.toml"),
"[package]\nname='demo'\nversion='0.1.0'\n",
);
write(
&root.join("tests/integration_test.rs"),
"#[test]\nfn real_one() {}\n",
);
write(
&root.join("tests/compress_dir_bench.rs"),
"#[test]\nfn compress_dir_real_jars() {}\n",
);
write(
&root.join("tests/perf_bench.rs"),
"#[test]\nfn perf_suite() {}\n",
);
// default: bench files excluded
let inv = scan(root, None);
assert_eq!(
inv.integration_total(),
1,
"only the real test counts: {:?}",
inv.integration
);
assert_eq!(inv.integration[0].file, "tests/integration_test.rs");
// include_benches=true brings them back
let inv2 = scan_opts(root, None, true);
assert_eq!(inv2.integration_total(), 3);
}
#[test]
fn benchmark_file_classifier() {
assert!(is_benchmark_file("compress_dir_bench"));
assert!(is_benchmark_file("perf_bench"));
assert!(is_benchmark_file("maven_bench"));
assert!(is_benchmark_file("jar_throughput"));
assert!(is_benchmark_file("decompress_benchmark"));
assert!(!is_benchmark_file("integration_test"));
assert!(!is_benchmark_file("maven_artifacts"));
assert!(!is_benchmark_file("codec"));
}
#[test]
fn crate_filter_limits_scope() {
let t = tempfile::tempdir().unwrap();
let root = t.path();
write(
&root.join("a/Cargo.toml"),
"[package]\nname='alpha'\nversion='0.1.0'\n",
);
write(&root.join("a/src/lib.rs"), "#[test]\nfn ta() {}\n");
write(
&root.join("b/Cargo.toml"),
"[package]\nname='beta'\nversion='0.1.0'\n",
);
write(&root.join("b/src/lib.rs"), "#[test]\nfn tb() {}\n");
let inv = scan(root, Some("alph"));
assert_eq!(inv.unit_total(), 1);
assert_eq!(inv.unit[0].file, "a/src/lib.rs");
}
}