sdivi-graph 0.2.23

Dependency graph construction for sdivi-rust
Documentation
//! Tests for DependencyGraph accessor methods, import resolution strategies,
//! and M25 specifier shape assertions.

use sdivi_graph::dependency_graph::build_dependency_graph;
use sdivi_parsing::feature_record::FeatureRecord;
use std::path::PathBuf;

fn make_record(path: &str, imports: &[&str]) -> FeatureRecord {
    FeatureRecord {
        path: PathBuf::from(path),
        language: "rust".to_string(),
        imports: imports.iter().map(|s| s.to_string()).collect(),
        exports: vec![],
        signatures: vec![],
        pattern_hints: vec![],
    }
}

// ── node_path / node_for_path ────────────────────────────────────────────────

#[test]
fn node_path_returns_path_for_valid_index() {
    let records = vec![
        make_record("src/lib.rs", &[]),
        make_record("src/models.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);

    let idx0 = dg
        .node_for_path(&PathBuf::from("src/lib.rs"))
        .expect("lib.rs must be in graph");
    let idx1 = dg
        .node_for_path(&PathBuf::from("src/models.rs"))
        .expect("models.rs must be in graph");

    assert_eq!(
        dg.node_path(idx0),
        Some(PathBuf::from("src/lib.rs").as_path())
    );
    assert_eq!(
        dg.node_path(idx1),
        Some(PathBuf::from("src/models.rs").as_path())
    );
}

#[test]
fn node_path_returns_none_for_out_of_range_index() {
    let records = vec![make_record("src/lib.rs", &[])];
    let dg = build_dependency_graph(&records);
    assert!(
        dg.node_path(999).is_none(),
        "out-of-range index must return None"
    );
}

#[test]
fn node_for_path_returns_none_for_unknown_path() {
    let records = vec![make_record("src/lib.rs", &[])];
    let dg = build_dependency_graph(&records);
    let result = dg.node_for_path(&PathBuf::from("src/unknown.rs"));
    assert!(result.is_none(), "unknown path must return None");
}

#[test]
fn node_for_path_empty_graph_returns_none() {
    let dg = build_dependency_graph(&[]);
    assert!(dg.node_for_path(&PathBuf::from("src/lib.rs")).is_none());
}

// ── edges_as_pairs ───────────────────────────────────────────────────────────

#[test]
fn edges_as_pairs_empty_graph_returns_empty_vec() {
    let dg = build_dependency_graph(&[]);
    assert!(dg.edges_as_pairs().is_empty());
}

#[test]
fn edges_as_pairs_single_edge_correct_direction() {
    // lib.rs imports models.rs via `crate::models`
    let records = vec![
        make_record("src/lib.rs", &["crate::models"]),
        make_record("src/models.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);

    let pairs = dg.edges_as_pairs();
    assert_eq!(pairs.len(), 1, "exactly one edge");

    let lib_idx = dg.node_for_path(&PathBuf::from("src/lib.rs")).unwrap();
    let models_idx = dg.node_for_path(&PathBuf::from("src/models.rs")).unwrap();

    assert!(
        pairs.contains(&(lib_idx, models_idx)),
        "edge must go from lib.rs ({lib_idx}) to models.rs ({models_idx}), got {pairs:?}"
    );
}

// ── neighbors ────────────────────────────────────────────────────────────────

#[test]
fn neighbors_returns_correct_targets() {
    // a.rs → b.rs and a.rs → c.rs
    let records = vec![
        make_record("src/a.rs", &["crate::b", "crate::c"]),
        make_record("src/b.rs", &[]),
        make_record("src/c.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);

    let a_idx = dg.node_for_path(&PathBuf::from("src/a.rs")).unwrap();
    let b_idx = dg.node_for_path(&PathBuf::from("src/b.rs")).unwrap();
    let c_idx = dg.node_for_path(&PathBuf::from("src/c.rs")).unwrap();

    let mut nbrs = dg.neighbors(a_idx);
    nbrs.sort_unstable();

    assert!(nbrs.contains(&b_idx), "a.rs must list b.rs as neighbour");
    assert!(nbrs.contains(&c_idx), "a.rs must list c.rs as neighbour");
}

#[test]
fn neighbors_leaf_node_returns_empty() {
    let records = vec![
        make_record("src/a.rs", &["crate::b"]),
        make_record("src/b.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    let b_idx = dg.node_for_path(&PathBuf::from("src/b.rs")).unwrap();
    assert!(dg.neighbors(b_idx).is_empty(), "b.rs has no outgoing edges");
}

// ── relative path import resolution ─────────────────────────────────────────

#[test]
fn relative_import_dot_slash_resolves_to_sibling() {
    // Python-style: `./utils` from `src/main.py` → `src/utils.py`
    let records = vec![
        make_record("src/main.py", &["./utils"]),
        make_record("src/utils.py", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        1,
        "relative ./utils must resolve to src/utils.py"
    );
}

#[test]
fn relative_import_parent_slash_resolves_to_parent_dir() {
    // M26 fix: `../shared` from `src/sub/module.py` walks up one directory
    // and resolves to `src/shared.py`, not the same-dir file.
    let records = vec![
        make_record("src/sub/module.py", &["../shared"]),
        make_record("src/shared.py", &[]), // parent dir — correct target
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        1,
        "../shared must resolve to src/shared.py via parent navigation"
    );
}

#[test]
fn relative_import_unresolvable_drops_silently() {
    // `./nonexistent` has no matching file — must produce 0 edges, not a panic.
    let records = vec![make_record("src/main.py", &["./nonexistent"])];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        0,
        "unresolvable relative import must be dropped"
    );
}

// ── directory module resolution ───────────────────────────────────────────────

#[test]
fn relative_import_resolves_to_directory_index_ts() {
    // TypeScript: `./components` → `./components/index.ts`
    let records = vec![
        make_record("src/app.ts", &["./components"]),
        make_record("src/components/index.ts", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(dg.edge_count(), 1, "./components must resolve via index.ts");
}

#[test]
fn relative_import_resolves_to_mod_rs() {
    // Rust: `./parser` → `./parser/mod.rs`
    let records = vec![
        make_record("src/lib.rs", &["./parser"]),
        make_record("src/parser/mod.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(dg.edge_count(), 1, "./parser must resolve via mod.rs");
}

// ── Rust crate:: prefix resolution ──────────────────────────────────────────

#[test]
fn crate_prefix_resolves_first_path_segment_only() {
    // `crate::models::User` should resolve to models.rs, ignoring `::User`
    let records = vec![
        make_record("src/lib.rs", &["crate::models::User"]),
        make_record("src/models.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        1,
        "crate::models::User must resolve to models.rs"
    );
}

#[test]
fn self_prefix_resolves_to_sibling_stem() {
    let records = vec![
        make_record("src/lib.rs", &["self::utils"]),
        make_record("src/utils.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(dg.edge_count(), 1, "self::utils must resolve to utils.rs");
}

#[test]
fn super_prefix_resolves_to_stem() {
    let records = vec![
        make_record("src/sub/mod.rs", &["super::config"]),
        make_record("src/config.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        1,
        "super::config must resolve to config.rs"
    );
}

// ── ambiguous stem (two files share the same stem) ──────────────────────────

#[test]
fn ambiguous_stem_does_not_add_edge() {
    // Two files share stem "models" — resolution is ambiguous; no edge added.
    let records = vec![
        make_record("src/main.rs", &["crate::models"]),
        make_record("src/models.rs", &[]),
        make_record("lib/models.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        0,
        "ambiguous stem must not produce an edge"
    );
}

#[test]
fn external_crate_import_produces_no_edge() {
    let records = vec![
        make_record("src/lib.rs", &["serde::Serialize", "tokio::runtime"]),
        make_record("src/models.rs", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(dg.edge_count(), 0, "external imports must produce no edges");
}

#[test]
fn python_from_import_dotted_specifier_requires_python_language() {
    // With language:"rust" (make_record default), "foo.bar" dispatches to the Rust
    // resolver (no crate::/self::/super:: prefix); Python dotted requires language:"python".
    let records = vec![
        make_record("src/main.py", &["foo.bar"]),
        make_record("src/foo/bar.py", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        0,
        "language:\"rust\" record dispatches to Rust resolver; dotted specifier dropped"
    );
}
#[test]
fn typescript_import_string_fragment_resolves() {
    // TypeScriptAdapter produces "./utils" from `import { x } from "./utils"`.
    let records = vec![
        make_record("src/app.ts", &["./utils"]),
        make_record("src/utils.ts", &[]),
    ];
    let dg = build_dependency_graph(&records);
    assert_eq!(
        dg.edge_count(),
        1,
        "./utils specifier from TS adapter must resolve"
    );
}