cargo-coupling 0.4.0

A coupling analysis tool for Rust projects - measuring the 'right distance' in your code
Documentation
//! Regression coverage for explicitly scoped workspace analysis (Issue #86).

use std::fs;
use std::path::Path;

use cargo_coupling::{
    CompiledConfig, ProjectMetrics, analyze_workspace_with_config, load_compiled_config,
};

fn fixture() -> tempfile::TempDir {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    fs::create_dir_all(root.join("alpha/src/feature")).unwrap();
    fs::create_dir_all(root.join("beta/src")).unwrap();
    for (path, content) in [
        (
            "Cargo.toml",
            "[workspace]\nmembers = [\"alpha\", \"beta\"]\nresolver = \"3\"\n",
        ),
        (
            "alpha/Cargo.toml",
            "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n[dependencies]\nbeta = { path = \"../beta\" }\n",
        ),
        (
            "beta/Cargo.toml",
            "[package]\nname = \"beta\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
        ),
        (
            "alpha/src/lib.rs",
            "use beta::shared;\npub mod feature;\npub mod other;\npub fn run() { shared::call(); feature::run(); }\n",
        ),
        (
            "alpha/src/feature/mod.rs",
            "pub mod helper;\npub fn run() { helper::call(); }\n",
        ),
        ("alpha/src/feature/helper.rs", "pub fn call() {}\n"),
        ("alpha/src/other.rs", "pub fn other() {}\n"),
        ("beta/src/lib.rs", "pub mod shared;\n"),
        ("beta/src/shared.rs", "pub fn call() {}\n"),
    ] {
        fs::write(root.join(path), content).unwrap();
    }
    tmp
}

fn analyze(path: &Path) -> ProjectMetrics {
    analyze_workspace_with_config(path, &CompiledConfig::empty()).unwrap()
}

fn assert_scope(metrics: &ProjectMetrics, scope: &Path, files: usize) {
    assert_eq!(metrics.total_files, files);
    let scope = fs::canonicalize(scope).unwrap();
    for module in metrics.modules.values() {
        assert!(
            fs::canonicalize(&module.path).unwrap().starts_with(&scope),
            "{} is outside {}",
            module.path.display(),
            scope.display(),
        );
    }
    assert!(metrics.skipped_crates.is_empty());
}

#[test]
fn workspace_root_keeps_all_members() {
    let tmp = fixture();
    let metrics = analyze(tmp.path());
    assert_scope(&metrics, tmp.path(), 6);
    assert!(metrics.modules.contains_key("shared"));
    assert!(metrics.modules.contains_key("other"));
    assert_eq!(metrics.workspace_members.len(), 2);
}

#[test]
fn member_scope_excludes_siblings_but_preserves_dependency_resolution() {
    let tmp = fixture();
    let alpha = tmp.path().join("alpha");
    let metrics = analyze(&alpha);
    assert_scope(&metrics, &alpha, 4);
    assert!(
        metrics
            .couplings
            .iter()
            .all(|c| c.source.starts_with("alpha::"))
    );
    assert!(
        metrics
            .couplings
            .iter()
            .any(|c| c.target_crate.as_deref() == Some("beta"))
    );
    assert_eq!(metrics.crate_dependencies.len(), 1);
    assert!(metrics.crate_dependencies["alpha"].contains(&"beta".to_string()));
}

#[test]
fn source_directory_scope_preserves_qualified_module_names() {
    let tmp = fixture();
    let feature = tmp.path().join("alpha/src/feature");
    let metrics = analyze(&feature);
    assert_scope(&metrics, &feature, 2);
    assert!(metrics.modules.contains_key("feature"));
    assert!(metrics.modules.contains_key("feature::helper"));
}

#[test]
fn source_file_scope_analyzes_only_that_file() {
    let tmp = fixture();
    let helper = tmp.path().join("alpha/src/feature/helper.rs");
    assert_scope(&analyze(&helper), &helper, 1);
}

#[test]
fn member_manifest_selects_the_member() {
    let tmp = fixture();
    let alpha = tmp.path().join("alpha");
    assert_scope(&analyze(&alpha.join("Cargo.toml")), &alpha, 4);
}

#[test]
fn partial_scope_does_not_report_sibling_config_as_dead() {
    let tmp = fixture();
    fs::write(
        tmp.path().join(".coupling.toml"),
        "[subdomains]\ncore = [\"beta/src/retired.rs\"]\n",
    )
    .unwrap();
    let config = load_compiled_config(tmp.path()).unwrap();
    let full = analyze_workspace_with_config(tmp.path(), &config).unwrap();
    assert!(!full.dead_config_patterns.is_empty());
    let partial = analyze_workspace_with_config(&tmp.path().join("alpha"), &config).unwrap();
    assert!(partial.dead_config_patterns.is_empty());
}

#[test]
fn member_scope_keeps_its_path_modules_outside_the_member_directory() {
    let tmp = fixture();
    fs::write(tmp.path().join("shared.rs"), "pub fn call() {}\n").unwrap();
    fs::write(
        tmp.path().join("alpha/src/lib.rs"),
        "#[path = \"../../shared.rs\"]\npub mod shared;\npub fn run() { shared::call(); }\n",
    )
    .unwrap();
    let metrics = analyze(&tmp.path().join("alpha"));
    assert_eq!(metrics.total_files, 5);
    assert!(metrics.modules.values().any(|module| {
        fs::canonicalize(&module.path).unwrap()
            == fs::canonicalize(tmp.path().join("shared.rs")).unwrap()
    }));
    assert!(
        metrics
            .couplings
            .iter()
            .all(|c| c.source.starts_with("alpha::"))
    );
}

#[cfg(unix)]
#[test]
fn symlink_and_parent_components_preserve_the_requested_scope() {
    let tmp = fixture();
    let links = tempfile::tempdir().unwrap();
    let alias = links.path().join("workspace");
    std::os::unix::fs::symlink(tmp.path(), &alias).unwrap();
    let path = alias.join("alpha/src/feature/../other.rs");
    assert_scope(&analyze(&path), &path, 1);
}