call-graph-cli 0.1.0

Interactive call and type hierarchy TUI
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

const TESTING_DOCUMENTATION: &str = include_str!("../src/testing/README.md");

#[test]
fn documented_test_inventory_matches_source_annotations() {
    let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let (expected, documented_total) = documented_inventory(TESTING_DOCUMENTATION);
    let actual = source_inventory(&workspace);
    let actual_total = actual.values().sum::<usize>();

    assert_eq!(
        actual, expected,
        "update src/testing/README.md when tests change"
    );
    assert_eq!(
        actual_total, documented_total,
        "documented test total must equal the per-file inventory"
    );
}

fn documented_inventory(documentation: &str) -> (BTreeMap<String, usize>, usize) {
    let block = documentation
        .split_once("<!-- test-inventory\n")
        .and_then(|(_, remainder)| remainder.split_once("\n-->"))
        .map(|(block, _)| block)
        .expect("src/testing/README.md must contain a test-inventory block");
    let mut inventory = BTreeMap::new();
    let mut total = None;
    for line in block.lines() {
        let (path, count) = line
            .split_once(':')
            .expect("test-inventory lines must use path: count");
        let count = count
            .trim()
            .parse::<usize>()
            .expect("test-inventory counts must be integers");
        if path == "total" {
            total = Some(count);
        } else {
            inventory.insert(path.to_owned(), count);
        }
    }
    let total = total.expect("test-inventory must contain total");
    assert_eq!(
        inventory.values().sum::<usize>(),
        total,
        "documented per-file test counts must add up to total"
    );
    (inventory, total)
}

fn source_inventory(workspace: &Path) -> BTreeMap<String, usize> {
    let mut inventory = BTreeMap::new();
    for directory in ["src", "tests", "examples"] {
        collect_rust_tests(workspace, &workspace.join(directory), &mut inventory);
    }
    inventory
}

fn collect_rust_tests(workspace: &Path, directory: &Path, inventory: &mut BTreeMap<String, usize>) {
    let entries = fs::read_dir(directory)
        .unwrap_or_else(|error| panic!("failed to read {}: {error}", directory.display()));
    for entry in entries {
        let path = entry.expect("failed to read directory entry").path();
        if path.is_dir() {
            collect_rust_tests(workspace, &path, inventory);
            continue;
        }
        if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
            continue;
        }
        let source = fs::read_to_string(&path)
            .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
        let count = source
            .lines()
            .filter(|line| {
                let line = line.trim();
                line == "#[test]" || line.starts_with("#[tokio::test")
            })
            .count();
        if count == 0 {
            continue;
        }
        let relative = path
            .strip_prefix(workspace)
            .expect("test source must be inside the workspace")
            .to_string_lossy()
            .replace('\\', "/");
        inventory.insert(relative, count);
    }
}