#![cfg(feature = "kuzu-backend")]
use std::path::{Path, PathBuf};
use gitcortex_core::{
graph::{Edge, GraphDiff, Node, NodeId, NodeMetadata, Span},
schema::{NodeKind, Visibility, SCHEMA_VERSION},
store::GraphStore,
};
use gitcortex_store::{branch, kuzu::KuzuGraphStore};
fn make_node(name: &str, kind: NodeKind, file: &str, line: u32) -> Node {
Node {
id: NodeId::new(),
kind,
name: name.to_owned(),
qualified_name: format!("crate::{name}"),
file: PathBuf::from(file),
span: Span {
start_line: line,
end_line: line + 5,
},
metadata: NodeMetadata {
loc: 6,
visibility: Visibility::Pub,
is_async: false,
is_unsafe: false,
..Default::default()
},
}
}
fn tmp_store() -> (KuzuGraphStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = KuzuGraphStore::open(dir.path()).expect("open store");
(store, dir)
}
#[test]
fn repository_lock_rejects_a_second_store_owner() {
let repo = tempfile::tempdir().expect("repo tempdir");
let first = KuzuGraphStore::open(repo.path()).expect("first store owner");
let error = KuzuGraphStore::open(repo.path())
.err()
.expect("second owner must be rejected");
assert!(error.to_string().contains("repository graph is active"));
drop(first);
let reopened = KuzuGraphStore::open(repo.path()).expect("lock released on drop");
drop(reopened);
}
#[test]
fn insert_and_lookup_node() {
let (mut store, _dir) = tmp_store();
let node = make_node("greet", NodeKind::Function, "src/lib.rs", 1);
let node_id = node.id.clone();
let diff = GraphDiff {
added_nodes: vec![node],
..Default::default()
};
store.apply_diff("main", &diff).expect("apply_diff");
let results = store
.lookup_symbol("main", "greet", false)
.expect("lookup_symbol");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, node_id);
assert_eq!(results[0].name, "greet");
assert_eq!(results[0].kind, NodeKind::Function);
}
#[test]
fn list_definitions_ordered_by_line() {
let (mut store, _dir) = tmp_store();
let nodes = vec![
make_node("baz", NodeKind::Function, "src/lib.rs", 20),
make_node("foo", NodeKind::Function, "src/lib.rs", 1),
make_node("bar", NodeKind::Struct, "src/lib.rs", 10),
];
let diff = GraphDiff {
added_nodes: nodes,
..Default::default()
};
store.apply_diff("main", &diff).expect("apply_diff");
let defs = store
.list_definitions("main", Path::new("src/lib.rs"))
.expect("list_definitions");
assert_eq!(defs.len(), 3);
assert_eq!(defs[0].name, "foo");
assert_eq!(defs[1].name, "bar");
assert_eq!(defs[2].name, "baz");
}
#[test]
fn find_callers_via_calls_edge() {
let (mut store, _dir) = tmp_store();
let caller = make_node("announce", NodeKind::Function, "src/lib.rs", 10);
let callee = make_node("greet", NodeKind::Method, "src/lib.rs", 1);
let edge = Edge::call(caller.id.clone(), callee.id.clone(), 12);
let diff = GraphDiff {
added_nodes: vec![caller.clone(), callee],
added_edges: vec![edge],
..Default::default()
};
store.apply_diff("main", &diff).expect("apply_diff");
let callers = store.find_callers("main", "greet").expect("find_callers");
assert_eq!(callers.len(), 1);
assert_eq!(callers[0].name, "announce");
}
#[test]
fn delete_file_removes_nodes() {
let (mut store, _dir) = tmp_store();
let node = make_node("old_fn", NodeKind::Function, "src/old.rs", 1);
let add_diff = GraphDiff {
added_nodes: vec![node],
..Default::default()
};
store.apply_diff("main", &add_diff).expect("apply_diff");
assert_eq!(
store.lookup_symbol("main", "old_fn", false).unwrap().len(),
1
);
let del_diff = GraphDiff {
removed_files: vec![PathBuf::from("src/old.rs")],
..Default::default()
};
store
.apply_diff("main", &del_diff)
.expect("apply_diff remove");
assert_eq!(
store.lookup_symbol("main", "old_fn", false).unwrap().len(),
0
);
}
#[test]
fn last_indexed_sha_round_trip() {
let (mut store, _dir) = tmp_store();
assert!(store.last_indexed_sha("main").unwrap().is_none());
store
.set_last_indexed_sha("main", "abc123")
.expect("set sha");
assert_eq!(
store.last_indexed_sha("main").unwrap().as_deref(),
Some("abc123")
);
}
#[test]
fn paged_graph_reads_are_stable_and_complete() {
let (mut store, _dir) = tmp_store();
let nodes = vec![
make_node("alpha", NodeKind::Function, "src/lib.rs", 1),
make_node("beta", NodeKind::Function, "src/lib.rs", 10),
make_node("gamma", NodeKind::Function, "src/lib.rs", 20),
];
let edges = vec![
Edge::call(nodes[0].id.clone(), nodes[1].id.clone(), 3),
Edge::call(nodes[1].id.clone(), nodes[2].id.clone(), 12),
];
store
.apply_diff(
"main",
&GraphDiff {
added_nodes: nodes,
added_edges: edges,
..Default::default()
},
)
.expect("apply graph");
let first_nodes = store.list_nodes_page("main", 0, 2).expect("first nodes");
let second_nodes = store.list_nodes_page("main", 2, 2).expect("second nodes");
assert_eq!(first_nodes.len(), 2);
assert_eq!(second_nodes.len(), 1);
assert!(first_nodes[0].id.as_str() < first_nodes[1].id.as_str());
let first_edge = store.list_edges_page("main", 0, 1).expect("first edge");
let second_edge = store.list_edges_page("main", 1, 1).expect("second edge");
assert_eq!(first_edge.len(), 1);
assert_eq!(second_edge.len(), 1);
assert_ne!(first_edge[0].src, second_edge[0].src);
}
#[test]
fn bounded_exact_neighborhood_respects_direction_and_limit() {
let (mut store, _dir) = tmp_store();
let seed = make_node("seed", NodeKind::Function, "src/lib.rs", 1);
let incoming = make_node("incoming", NodeKind::Function, "src/lib.rs", 10);
let outgoing = make_node("outgoing", NodeKind::Function, "src/lib.rs", 20);
store
.apply_diff(
"main",
&GraphDiff {
added_nodes: vec![seed.clone(), incoming.clone(), outgoing.clone()],
added_edges: vec![
Edge::call(incoming.id.clone(), seed.id.clone(), 12),
Edge::call(seed.id.clone(), outgoing.id.clone(), 3),
],
..Default::default()
},
)
.expect("apply graph");
let inbound = store
.get_neighborhood_by_id("main", &seed.id.as_str(), "in", 10)
.expect("inbound neighborhood");
assert_eq!(inbound.edges.len(), 1);
assert_eq!(inbound.edges[0].src, incoming.id);
let limited = store
.get_neighborhood_by_id("main", &seed.id.as_str(), "both", 1)
.expect("limited neighborhood");
assert_eq!(limited.edges.len(), 1);
assert_eq!(limited.nodes.len(), 2);
}
#[test]
fn branch_diff_detects_added_and_removed_nodes() {
let (mut store, _dir) = tmp_store();
let node_a = make_node("shared", NodeKind::Function, "src/lib.rs", 1);
let node_b = make_node("only_main", NodeKind::Function, "src/lib.rs", 10);
let node_c = make_node("only_feat", NodeKind::Function, "src/lib.rs", 20);
let main_diff = GraphDiff {
added_nodes: vec![node_a.clone(), node_b.clone()],
added_edges: vec![Edge::call(node_a.id.clone(), node_b.id.clone(), 3)],
..Default::default()
};
store.apply_diff("main", &main_diff).expect("apply main");
let feat_diff = GraphDiff {
added_nodes: vec![node_a.clone(), node_c.clone()],
added_edges: vec![Edge::call(node_a.id.clone(), node_c.id.clone(), 4)],
..Default::default()
};
store
.apply_diff("feat/new", &feat_diff)
.expect("apply feat");
let diff = store.branch_diff("main", "feat/new").expect("branch_diff");
assert!(diff.added_nodes.iter().any(|n| n.name == "only_feat"));
assert!(!diff.removed_node_ids.is_empty());
assert_eq!(diff.added_edges.len(), 1);
assert_eq!(diff.added_edges[0].dst, node_c.id);
assert_eq!(diff.removed_edges.len(), 1);
assert_eq!(diff.removed_edges[0].1, node_b.id);
}
#[test]
fn schema_mismatch_requires_explicit_initialization_before_rebuild() {
let repo = tempfile::tempdir().expect("repo tempdir");
let repo_id = branch::repo_id(repo.path());
let db_path = branch::db_path(&repo_id);
std::fs::create_dir_all(db_path.parent().expect("data parent")).expect("create data dir");
std::fs::write(&db_path, b"existing graph data").expect("fake existing graph");
branch::write_schema_version(&repo_id, SCHEMA_VERSION.saturating_sub(1))
.expect("old schema marker");
let error = KuzuGraphStore::open(repo.path())
.err()
.expect("ordinary open must reject incompatible schema");
assert!(error.to_string().contains("run `gcx init`"));
assert_eq!(
std::fs::read(&db_path).expect("preserved graph"),
b"existing graph data"
);
let store = KuzuGraphStore::open_for_init(repo.path()).expect("explicit rebuild");
drop(store);
assert_eq!(branch::read_schema_version(&repo_id), SCHEMA_VERSION);
branch::wipe_repo_data(&repo_id).expect("clean test data");
}