use super::test_symbol;
use crate::*;
fn resolution_state_of(db: &Database, edge_id: i64) -> i64 {
db.conn
.query_row(
"SELECT resolution_state FROM edges WHERE id = ?1",
params![edge_id],
|row| row.get(0),
)
.unwrap()
}
fn resolution_source_of(db: &Database, edge_id: i64) -> Option<String> {
db.conn
.query_row(
"SELECT resolution_source FROM edges WHERE id = ?1",
params![edge_id],
|row| row.get(0),
)
.unwrap()
}
fn insert_test_edge(db: &Database, target_name: &str) -> i64 {
let sym = test_symbol("src", SymbolKind::Function, "a.py", 1);
db.insert_symbols(std::slice::from_ref(&sym)).unwrap();
let edge = Edge::new(&sym.id, target_name, EdgeKind::Calls, "a.py", 1);
db.insert_edge(&edge).unwrap();
db.conn.last_insert_rowid()
}
#[test]
fn test_new_edge_has_default_state_zero() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "missing_target");
assert_eq!(resolution_state_of(&db, id), 0);
}
#[test]
fn test_update_edge_target_flips_state_to_one() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.update_edge_target(id, "some:symbol:id").unwrap();
assert_eq!(resolution_state_of(&db, id), 1);
}
#[test]
fn test_mark_edge_unresolvable_sets_state_to_two() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.mark_edge_unresolvable(id).unwrap();
assert_eq!(resolution_state_of(&db, id), 2);
}
#[test]
fn test_unresolved_edges_excludes_state_two() {
let db = Database::open_memory().unwrap();
let _unresolved = insert_test_edge(&db, "still_unresolved");
let burned = insert_test_edge(&db, "burned");
db.mark_edge_unresolvable(burned).unwrap();
let edges = db.unresolved_edges().unwrap();
let names: Vec<&str> = edges.iter().map(|e| e.target_name.as_str()).collect();
assert!(names.contains(&"still_unresolved"));
assert!(!names.contains(&"burned"));
}
#[test]
fn test_reset_unresolvable_for_names_targets_only_matching() {
let db = Database::open_memory().unwrap();
let burned_foo = insert_test_edge(&db, "foo");
let burned_bar = insert_test_edge(&db, "bar");
db.mark_edge_unresolvable(burned_foo).unwrap();
db.mark_edge_unresolvable(burned_bar).unwrap();
let reopened = db
.reset_unresolvable_for_names(&["foo".to_string()])
.unwrap();
assert_eq!(reopened, 1);
assert_eq!(resolution_state_of(&db, burned_foo), 0);
assert_eq!(resolution_state_of(&db, burned_bar), 2);
}
#[test]
fn test_reset_unresolvable_for_names_empty_is_noop() {
let db = Database::open_memory().unwrap();
let n = db.reset_unresolvable_for_names(&[]).unwrap();
assert_eq!(n, 0);
}
#[test]
fn test_reset_unresolvable_for_names_does_not_touch_state_zero_or_one() {
let db = Database::open_memory().unwrap();
let still_open = insert_test_edge(&db, "foo"); let already_resolved = insert_test_edge(&db, "foo");
db.update_edge_target(already_resolved, "some:id").unwrap();
db.reset_unresolvable_for_names(&["foo".to_string()])
.unwrap();
assert_eq!(resolution_state_of(&db, still_open), 0);
assert_eq!(resolution_state_of(&db, already_resolved), 1);
}
#[test]
fn test_mark_edge_external_sets_state_to_three() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.mark_edge_external(id).unwrap();
assert_eq!(resolution_state_of(&db, id), 3);
assert_eq!(db.edge_resolution_state(id).unwrap(), 3);
}
#[test]
fn test_unresolved_edges_excludes_state_three() {
let db = Database::open_memory().unwrap();
let _open = insert_test_edge(&db, "still_open");
let ext = insert_test_edge(&db, "external_dep");
db.mark_edge_external(ext).unwrap();
let edges = db.unresolved_edges().unwrap();
let names: Vec<&str> = edges.iter().map(|e| e.target_name.as_str()).collect();
assert!(names.contains(&"still_open"));
assert!(!names.contains(&"external_dep"));
}
#[test]
fn test_reset_all_unresolvable_resets_state_two_and_three() {
let db = Database::open_memory().unwrap();
let burned = insert_test_edge(&db, "burned");
let external = insert_test_edge(&db, "external");
db.mark_edge_unresolvable(burned).unwrap();
db.mark_edge_external(external).unwrap();
let reset = db.reset_all_unresolvable().unwrap();
assert_eq!(reset, 2);
assert_eq!(resolution_state_of(&db, burned), 0);
assert_eq!(resolution_state_of(&db, external), 0);
}
#[test]
fn test_reset_unresolvable_for_names_reopens_state_three() {
let db = Database::open_memory().unwrap();
let ext_foo = insert_test_edge(&db, "foo");
let ext_bar = insert_test_edge(&db, "bar");
db.mark_edge_external(ext_foo).unwrap();
db.mark_edge_external(ext_bar).unwrap();
let reopened = db
.reset_unresolvable_for_names(&["foo".to_string()])
.unwrap();
assert_eq!(reopened, 1);
assert_eq!(resolution_state_of(&db, ext_foo), 0);
assert_eq!(resolution_state_of(&db, ext_bar), 3);
}
#[test]
fn test_mark_heuristic_exhausted_seals_unresolved_state_zero() {
let db = Database::open_memory().unwrap();
let unresolved = insert_test_edge(&db, "nowhere");
let resolved = insert_test_edge(&db, "somewhere");
db.update_edge_target(resolved, "some:id").unwrap();
let marked = db.mark_heuristic_exhausted_in_tx().unwrap();
assert_eq!(marked, 1);
assert_eq!(resolution_state_of(&db, unresolved), 4);
assert_eq!(resolution_state_of(&db, resolved), 1, "resolved untouched");
}
#[test]
fn test_count_edges_in_state_buckets_by_state() {
let db = Database::open_memory().unwrap();
let resolved = insert_test_edge(&db, "somewhere");
db.update_edge_target(resolved, "some:id").unwrap();
let burned = insert_test_edge(&db, "burned");
db.mark_edge_unresolvable(burned).unwrap();
assert_eq!(db.count_edges_in_state(0).unwrap(), 0);
assert_eq!(db.count_edges_in_state(1).unwrap(), 1);
assert_eq!(db.count_edges_in_state(2).unwrap(), 1);
}
#[test]
fn test_has_heuristic_exhausted_tracks_state_four() {
let db = Database::open_memory().unwrap();
let _edge = insert_test_edge(&db, "nowhere");
assert!(!db.has_heuristic_exhausted().unwrap(), "state 0 not sealed");
db.mark_heuristic_exhausted_in_tx().unwrap();
assert!(db.has_heuristic_exhausted().unwrap());
}
#[test]
fn test_resolve_edges_skips_heuristic_exhausted_state_four() {
let db = Database::open_memory().unwrap();
let eid = insert_test_edge(&db, "nowhere");
db.mark_heuristic_exhausted_in_tx().unwrap();
assert_eq!(resolution_state_of(&db, eid), 4);
let resolved = db.resolve_edges().unwrap();
assert_eq!(resolved, 0);
assert_eq!(resolution_state_of(&db, eid), 4);
}
#[test]
fn test_unresolved_edges_excludes_state_four() {
let db = Database::open_memory().unwrap();
let exhausted = insert_test_edge(&db, "exhausted");
db.mark_heuristic_exhausted_in_tx().unwrap();
let _open = insert_test_edge(&db, "still_open");
let edges = db.unresolved_edges().unwrap();
let names: Vec<&str> = edges.iter().map(|e| e.target_name.as_str()).collect();
assert!(names.contains(&"still_open"));
assert!(!names.contains(&"exhausted"));
let _ = exhausted;
}
#[test]
fn test_reopen_heuristic_exhausted_resets_only_state_four() {
let db = Database::open_memory().unwrap();
let exhausted = insert_test_edge(&db, "exhausted");
db.mark_heuristic_exhausted_in_tx().unwrap();
let burned = insert_test_edge(&db, "burned");
db.mark_edge_unresolvable(burned).unwrap();
let external = insert_test_edge(&db, "external");
db.mark_edge_external(external).unwrap();
let reopened = db.reopen_heuristic_exhausted().unwrap();
assert_eq!(reopened, 1);
assert_eq!(resolution_state_of(&db, exhausted), 0);
assert_eq!(resolution_state_of(&db, burned), 2, "LSP verdict sealed");
assert_eq!(resolution_state_of(&db, external), 3, "LSP verdict sealed");
}
#[test]
fn test_reset_all_unresolvable_also_resets_state_four() {
let db = Database::open_memory().unwrap();
let exhausted = insert_test_edge(&db, "exhausted");
db.mark_heuristic_exhausted_in_tx().unwrap();
let burned = insert_test_edge(&db, "burned");
db.mark_edge_unresolvable(burned).unwrap();
let reset = db.reset_all_unresolvable().unwrap();
assert_eq!(reset, 2);
assert_eq!(resolution_state_of(&db, exhausted), 0);
assert_eq!(resolution_state_of(&db, burned), 0);
}
#[test]
fn test_reset_unresolvable_for_names_reopens_state_four() {
let db = Database::open_memory().unwrap();
let foo = insert_test_edge(&db, "foo");
let bar = insert_test_edge(&db, "bar");
db.mark_heuristic_exhausted_in_tx().unwrap();
let reopened = db
.reset_unresolvable_for_names(&["foo".to_string()])
.unwrap();
assert_eq!(reopened, 1);
assert_eq!(resolution_state_of(&db, foo), 0);
assert_eq!(resolution_state_of(&db, bar), 4);
}
#[test]
fn test_stats_surfaces_external_and_unresolvable_counts() {
let db = Database::open_memory().unwrap();
let resolved = insert_test_edge(&db, "resolved_target");
db.update_edge_target(resolved, "some:id").unwrap();
let burned = insert_test_edge(&db, "burned");
db.mark_edge_unresolvable(burned).unwrap();
let external = insert_test_edge(&db, "external");
db.mark_edge_external(external).unwrap();
let _open = insert_test_edge(&db, "open");
let stats = db.stats().unwrap();
assert_eq!(stats.num_resolved, 1);
assert_eq!(stats.num_unresolvable, 1);
assert_eq!(stats.num_external, 1);
assert_eq!(stats.num_edges, 4);
}
#[test]
fn test_invalidate_edges_targeting_resets_state_when_target_disappears() {
let db = Database::open_memory().unwrap();
let src = test_symbol("src", SymbolKind::Function, "a.py", 1);
let target = test_symbol("ghost", SymbolKind::Function, "b.py", 1);
db.insert_symbols(&[src.clone(), target.clone()]).unwrap();
let edge = Edge::new(&src.id, "ghost", EdgeKind::Calls, "a.py", 1);
db.insert_edge(&edge).unwrap();
let eid = db.conn.last_insert_rowid();
db.update_edge_target(eid, &target.id).unwrap();
assert_eq!(resolution_state_of(&db, eid), 1);
db.conn
.execute("DELETE FROM symbols WHERE id = ?1", params![target.id])
.unwrap();
let mut dirty = std::collections::HashSet::new();
dirty.insert("b.py".to_string());
db.invalidate_edges_targeting(&dirty).unwrap();
assert_eq!(
resolution_state_of(&db, eid),
0,
"dangling edge must return to state=0 so unresolved_edges() can see it"
);
let row: Option<String> = db
.conn
.query_row(
"SELECT target_id FROM edges WHERE id = ?1",
params![eid],
|r| r.get(0),
)
.unwrap();
assert!(row.is_none(), "target_id must be NULL after invalidation");
}
#[test]
fn test_delete_symbol_resets_state_on_dangling_incoming_edges() {
let db = Database::open_memory().unwrap();
let src = test_symbol("caller", SymbolKind::Function, "a.py", 1);
let target = test_symbol("ghost", SymbolKind::Function, "b.py", 1);
db.insert_symbols(&[src.clone(), target.clone()]).unwrap();
let edge = Edge::new(&src.id, "ghost", EdgeKind::Calls, "a.py", 1);
db.insert_edge(&edge).unwrap();
let eid = db.conn.last_insert_rowid();
db.update_edge_target(eid, &target.id).unwrap();
db.delete_symbol(&target.id).unwrap();
assert_eq!(resolution_state_of(&db, eid), 0);
assert_eq!(resolution_source_of(&db, eid), None, "stale tag must clear");
let visible = db
.unresolved_edges()
.unwrap()
.iter()
.any(|e| e.edge_id == eid);
assert!(
visible,
"orphaned edge must resurface in unresolved_edges()"
);
}
#[test]
fn test_delete_symbols_in_tx_resets_state_on_dangling_incoming_edges() {
let db = Database::open_memory().unwrap();
let src = test_symbol("caller", SymbolKind::Function, "a.py", 1);
let t1 = test_symbol("ghost1", SymbolKind::Function, "b.py", 1);
let t2 = test_symbol("ghost2", SymbolKind::Function, "c.py", 1);
db.insert_symbols(&[src.clone(), t1.clone(), t2.clone()])
.unwrap();
let e1 = Edge::new(&src.id, "ghost1", EdgeKind::Calls, "a.py", 1);
db.insert_edge(&e1).unwrap();
let eid1 = db.conn.last_insert_rowid();
db.update_edge_target(eid1, &t1.id).unwrap();
let e2 = Edge::new(&src.id, "ghost2", EdgeKind::Calls, "a.py", 2);
db.insert_edge(&e2).unwrap();
let eid2 = db.conn.last_insert_rowid();
db.update_edge_target(eid2, &t2.id).unwrap();
assert_eq!(resolution_source_of(&db, eid1).as_deref(), Some("lsp"));
db.delete_symbols(&[t1.id.clone(), t2.id.clone()]).unwrap();
assert_eq!(resolution_state_of(&db, eid1), 0);
assert_eq!(resolution_state_of(&db, eid2), 0);
assert_eq!(resolution_source_of(&db, eid1), None);
assert_eq!(resolution_source_of(&db, eid2), None);
}
#[test]
fn test_heuristic_resolve_flips_state_to_one() {
let db = Database::open_memory().unwrap();
let src = test_symbol("caller", SymbolKind::Function, "a.py", 1);
let target = test_symbol("foo", SymbolKind::Function, "a.py", 10);
db.insert_symbols(&[src.clone(), target.clone()]).unwrap();
let edge = Edge::new(&src.id, "foo", EdgeKind::Calls, "a.py", 2);
db.insert_edge(&edge).unwrap();
let eid = db.conn.last_insert_rowid();
assert_eq!(resolution_state_of(&db, eid), 0);
db.resolve_edges().unwrap();
assert_eq!(
resolution_state_of(&db, eid),
1,
"heuristic resolve must set state=1 so LSP doesn't re-attack the edge"
);
assert!(
db.unresolved_edges()
.unwrap()
.iter()
.all(|e| e.edge_id != eid),
"resolved edge must drop out of unresolved_edges()"
);
}
#[test]
fn test_partial_unresolved_index_exists() {
let db = Database::open_memory().unwrap();
let n: i64 = db
.conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE type='index' AND name='idx_edges_unresolved'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn test_resolution_state_default_via_insert_edges_batch() {
let db = Database::open_memory().unwrap();
let src = test_symbol("src", SymbolKind::Function, "a.py", 1);
db.insert_symbols(std::slice::from_ref(&src)).unwrap();
let edges = vec![
Edge::new(&src.id, "x", EdgeKind::Calls, "a.py", 1),
Edge::new(&src.id, "y", EdgeKind::Calls, "a.py", 2),
];
db.insert_edges(&edges).unwrap();
let states: Vec<i64> = db
.conn
.prepare("SELECT resolution_state FROM edges ORDER BY id")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
assert_eq!(states, vec![0, 0]);
}
#[test]
fn test_migration_v3_to_v4_backfills_resolved_to_state_one() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v3.sqlite");
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE symbols (
id TEXT PRIMARY KEY, name TEXT, kind TEXT, file_path TEXT,
start_line INTEGER, end_line INTEGER, start_byte INTEGER, end_byte INTEGER,
parent_id TEXT, signature TEXT, visibility TEXT, is_async BOOLEAN,
docstring TEXT, in_degree INTEGER DEFAULT 0,
content_hash TEXT, subtree_hash TEXT);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL, target_name TEXT NOT NULL, target_id TEXT,
kind TEXT NOT NULL, file_path TEXT NOT NULL, line INTEGER);
CREATE TABLE files (path TEXT PRIMARY KEY);
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO metadata (key, value) VALUES ('schema_version', '3');
INSERT INTO symbols (id, name, kind, file_path) VALUES ('s:1', 'foo', 'function', 'a.py');
INSERT INTO edges (source_id, target_name, target_id, kind, file_path, line)
VALUES ('s:1', 'foo', 's:1', 'calls', 'a.py', 1);
INSERT INTO edges (source_id, target_name, target_id, kind, file_path, line)
VALUES ('s:1', 'missing', NULL, 'calls', 'a.py', 2);",
)
.unwrap();
}
let db = Database::open(&path, DEFAULT_EMBEDDING_DIM).unwrap();
let has_resolution_state = db
.conn
.prepare("SELECT resolution_state FROM edges LIMIT 0")
.is_ok();
assert!(has_resolution_state, "v3→4 added resolution_state column");
let edge_count: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
.unwrap();
assert_eq!(edge_count, 0, "v7 cleared the index for full rebuild");
let bumped: String = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(bumped, SCHEMA_VERSION.to_string());
}
fn resolve_one_and_get_provenance(db: &Database, name: &str) -> Option<EdgeProvenance> {
let resolved = db.resolve_edges().unwrap();
assert_eq!(resolved, 1, "expected exactly one edge to resolve");
let refs = db.refs(name, None).unwrap();
refs.into_iter()
.find(|(e, _)| e.target_id.is_some())
.and_then(|(e, _)| e.provenance)
}
#[test]
fn resolve_tags_provenance_same_file() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let same_file = test_symbol("helper", SymbolKind::Function, "a.py", 20);
let other_file = test_symbol("helper", SymbolKind::Function, "b.py", 1);
db.insert_symbols(&[caller.clone(), same_file, other_file])
.unwrap();
db.insert_edge(&Edge::new(&caller.id, "helper", EdgeKind::Calls, "a.py", 5))
.unwrap();
assert_eq!(
resolve_one_and_get_provenance(&db, "helper"),
Some(EdgeProvenance::SameFile)
);
}
#[test]
fn resolve_tags_provenance_same_dir() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "pkg/a.py", 1);
let same_dir = test_symbol("helper", SymbolKind::Function, "pkg/b.py", 1);
let far = test_symbol("helper", SymbolKind::Function, "other/c.py", 1);
db.insert_symbols(&[caller.clone(), same_dir, far]).unwrap();
db.insert_edge(&Edge::new(
&caller.id,
"helper",
EdgeKind::Calls,
"pkg/a.py",
5,
))
.unwrap();
assert_eq!(
resolve_one_and_get_provenance(&db, "helper"),
Some(EdgeProvenance::SameDir)
);
}
#[test]
fn resolve_tags_provenance_unique_global() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let target = test_symbol("only_one", SymbolKind::Function, "far/away.py", 1);
db.insert_symbols(&[caller.clone(), target]).unwrap();
db.insert_edge(&Edge::new(
&caller.id,
"only_one",
EdgeKind::Calls,
"a.py",
5,
))
.unwrap();
assert_eq!(
resolve_one_and_get_provenance(&db, "only_one"),
Some(EdgeProvenance::UniqueGlobal)
);
}
#[test]
fn resolve_tags_provenance_kind_disambig() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("handleLogin", SymbolKind::Method, "auth/Service.java", 10);
let logger_class = test_symbol("Logger", SymbolKind::Class, "util/Logger.java", 1);
let logger_ctor = test_symbol("Logger", SymbolKind::Method, "util/Logger.java", 5);
db.insert_symbols(&[caller.clone(), logger_class, logger_ctor])
.unwrap();
db.insert_edge(&Edge::new(
&caller.id,
"Logger",
EdgeKind::References,
"auth/Service.java",
12,
))
.unwrap();
db.resolve_edges().unwrap();
let refs = db.refs("Logger", None).unwrap();
let edge = refs
.iter()
.find(|(e, _)| e.kind == EdgeKind::References)
.unwrap();
assert_eq!(edge.0.provenance, Some(EdgeProvenance::KindDisambig));
}
#[test]
fn resolve_tags_provenance_parent_scope() {
let db = Database::open_memory().unwrap();
let mut caller = test_symbol("run", SymbolKind::Method, "app/svc.py", 10);
caller.parent_id = Some("app/svc.py:class:Svc".to_string());
let mut same_scope = test_symbol("helper", SymbolKind::Method, "lib/a.py", 1);
same_scope.parent_id = Some("app/svc.py:class:Svc".to_string());
let mut other_scope = test_symbol("helper", SymbolKind::Method, "lib/b.py", 1);
other_scope.parent_id = Some("other/x.py:class:Other".to_string());
db.insert_symbols(&[caller.clone(), same_scope.clone(), other_scope])
.unwrap();
db.insert_edge(&Edge::new(
&caller.id,
"helper",
EdgeKind::Calls,
"app/svc.py",
12,
))
.unwrap();
assert_eq!(
resolve_one_and_get_provenance(&db, "helper"),
Some(EdgeProvenance::ParentScope)
);
}
#[test]
fn callees_surfaces_provenance() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let same_file = test_symbol("helper", SymbolKind::Function, "a.py", 20);
db.insert_symbols(&[caller.clone(), same_file]).unwrap();
db.insert_edge(&Edge::new(&caller.id, "helper", EdgeKind::Calls, "a.py", 5))
.unwrap();
db.resolve_edges().unwrap();
let callees = db.callees("process").unwrap();
assert_eq!(callees.len(), 1);
assert_eq!(callees[0].provenance, Some(EdgeProvenance::SameFile));
}
#[test]
fn impact_surfaces_provenance() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let target = test_symbol("helper", SymbolKind::Function, "a.py", 20);
db.insert_symbols(&[caller.clone(), target]).unwrap();
db.insert_edge(&Edge::new(&caller.id, "helper", EdgeKind::Calls, "a.py", 5))
.unwrap();
db.resolve_edges().unwrap();
let impact = db.impact("helper", 3).unwrap();
let call = impact
.iter()
.find(|(e, _)| e.kind == EdgeKind::Calls)
.unwrap();
assert_eq!(call.0.provenance, Some(EdgeProvenance::SameFile));
}
#[test]
fn reset_unresolvable_for_names_clears_provenance() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "foo");
db.mark_edge_unresolvable(id).unwrap();
assert_eq!(
resolution_source_of(&db, id).as_deref(),
Some("lsp_unresolvable")
);
let reopened = db
.reset_unresolvable_for_names(&["foo".to_string()])
.unwrap();
assert_eq!(reopened, 1);
assert_eq!(resolution_source_of(&db, id), None, "stale tag cleared");
}
#[test]
fn insert_edge_round_trips_provenance() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let target = test_symbol("helper", SymbolKind::Function, "a.py", 20);
db.insert_symbols(&[caller.clone(), target.clone()])
.unwrap();
let mut edge = Edge::new(&caller.id, "helper", EdgeKind::Calls, "a.py", 5);
edge.target_id = Some(target.id.clone());
edge.provenance = Some(EdgeProvenance::Lsp);
db.insert_edge(&edge).unwrap();
let eid = db.conn.last_insert_rowid();
let callees = db.callees("process").unwrap();
assert_eq!(callees[0].provenance, Some(EdgeProvenance::Lsp));
assert_eq!(resolution_state_of(&db, eid), 1);
assert!(
!db.unresolved_edges()
.unwrap()
.iter()
.any(|e| e.edge_id == eid),
"a resolved insert must not resurface as unresolved"
);
}
#[test]
fn insert_edge_without_target_is_unresolved() {
let db = Database::open_memory().unwrap();
let src = test_symbol("src", SymbolKind::Function, "a.py", 1);
db.insert_symbols(std::slice::from_ref(&src)).unwrap();
db.insert_edge(&Edge::new(&src.id, "missing", EdgeKind::Calls, "a.py", 1))
.unwrap();
let eid = db.conn.last_insert_rowid();
assert_eq!(resolution_state_of(&db, eid), 0);
}
#[test]
fn resolve_tags_provenance_import_path() {
let db = Database::open_memory().unwrap();
let import_sym = test_symbol("util.Logger", SymbolKind::Import, "auth/service.java", 1);
let caller = test_symbol("authenticate", SymbolKind::Method, "auth/service.java", 10);
let logger_class = test_symbol("Logger", SymbolKind::Class, "util/Logger.java", 1);
let logger_ctor = test_symbol("Logger", SymbolKind::Method, "util/Logger.java", 5);
db.insert_symbols(&[
import_sym.clone(),
caller.clone(),
logger_class,
logger_ctor,
])
.unwrap();
db.insert_edge(&Edge::new(
&import_sym.id,
"Logger",
EdgeKind::Imports,
"auth/service.java",
1,
))
.unwrap();
db.insert_edge(&Edge::new(
&caller.id,
"Logger",
EdgeKind::References,
"auth/service.java",
15,
))
.unwrap();
assert_eq!(db.resolve_edges().unwrap(), 2);
let refs = db.refs("Logger", None).unwrap();
let reference = refs
.iter()
.find(|(e, _)| e.kind == EdgeKind::References)
.unwrap();
assert_eq!(reference.0.provenance, Some(EdgeProvenance::ImportPath));
}
#[test]
fn lsp_resolve_tags_provenance_lsp() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.update_edge_target(id, "some:symbol:id").unwrap();
assert_eq!(resolution_source_of(&db, id).as_deref(), Some("lsp"));
}
#[test]
fn lsp_overwrite_retags_heuristic_as_lsp() {
let db = Database::open_memory().unwrap();
let caller = test_symbol("process", SymbolKind::Function, "a.py", 1);
let same_file = test_symbol("helper", SymbolKind::Function, "a.py", 20);
db.insert_symbols(&[caller.clone(), same_file.clone()])
.unwrap();
db.insert_edge(&Edge::new(&caller.id, "helper", EdgeKind::Calls, "a.py", 5))
.unwrap();
db.resolve_edges().unwrap();
let edge_id: i64 = db
.conn
.query_row("SELECT id FROM edges LIMIT 1", [], |r| r.get(0))
.unwrap();
assert_eq!(
resolution_source_of(&db, edge_id).as_deref(),
Some("same_file")
);
db.update_edge_target(edge_id, &same_file.id).unwrap();
assert_eq!(resolution_source_of(&db, edge_id).as_deref(), Some("lsp"));
}
#[test]
fn mark_external_tags_lsp_external() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.mark_edge_external(id).unwrap();
assert_eq!(
resolution_source_of(&db, id).as_deref(),
Some("lsp_external")
);
}
#[test]
fn mark_unresolvable_tags_lsp_unresolvable() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "anything");
db.mark_edge_unresolvable(id).unwrap();
assert_eq!(
resolution_source_of(&db, id).as_deref(),
Some("lsp_unresolvable")
);
}
#[test]
fn reset_unresolvable_clears_provenance() {
let db = Database::open_memory().unwrap();
let id = insert_test_edge(&db, "foo");
db.mark_edge_external(id).unwrap();
assert_eq!(
resolution_source_of(&db, id).as_deref(),
Some("lsp_external")
);
db.reset_all_unresolvable().unwrap();
assert_eq!(resolution_source_of(&db, id), None, "stale tag cleared");
}
fn bootstrap_pre_v6_db(path: &std::path::Path, schema_version: u32, seed_edges: bool) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE symbols (
id TEXT PRIMARY KEY, name TEXT, kind TEXT, file_path TEXT,
start_line INTEGER, end_line INTEGER, start_byte INTEGER, end_byte INTEGER,
parent_id TEXT, signature TEXT, visibility TEXT, is_async BOOLEAN,
docstring TEXT, in_degree INTEGER DEFAULT 0,
content_hash TEXT, subtree_hash TEXT);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL, target_name TEXT NOT NULL, target_id TEXT,
kind TEXT NOT NULL, file_path TEXT NOT NULL, line INTEGER,
resolution_state INTEGER NOT NULL DEFAULT 0);
CREATE TABLE files (path TEXT PRIMARY KEY);
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE query_log (id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL, source TEXT NOT NULL, ts INTEGER NOT NULL);",
)
.unwrap();
conn.execute(
"INSERT INTO metadata (key, value) VALUES ('schema_version', ?1)",
params![schema_version.to_string()],
)
.unwrap();
if seed_edges {
conn.execute_batch(
"INSERT INTO symbols (id, name, kind, file_path) VALUES ('s:1', 'foo', 'function', 'a.py');
INSERT INTO edges (source_id, target_name, target_id, kind, file_path, line, resolution_state)
VALUES ('s:1', 'foo', 's:1', 'calls', 'a.py', 1, 1);
INSERT INTO edges (source_id, target_name, target_id, kind, file_path, line, resolution_state)
VALUES ('s:1', 'missing', NULL, 'calls', 'a.py', 2, 0);",
)
.unwrap();
}
}
#[test]
fn migration_v5_to_v6_adds_resolution_source_column() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v5.sqlite");
bootstrap_pre_v6_db(&path, 5, true);
let db = Database::open(&path, DEFAULT_EMBEDDING_DIM).unwrap();
let has_resolution_source = db
.conn
.prepare("SELECT resolution_source FROM edges LIMIT 0")
.is_ok();
assert!(has_resolution_source, "v5→6 added resolution_source column");
let edge_count: i64 = db
.conn
.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
.unwrap();
assert_eq!(edge_count, 0, "v7 cleared the index for full rebuild");
let bumped: String = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(bumped, SCHEMA_VERSION.to_string());
}
#[test]
fn migration_v6_self_heals_missing_column() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("partial.sqlite");
bootstrap_pre_v6_db(&path, 6, false);
let db = Database::open(&path, DEFAULT_EMBEDDING_DIM).unwrap();
let has_col = db
.conn
.prepare("SELECT resolution_source FROM edges LIMIT 0")
.is_ok();
assert!(has_col, "missing resolution_source column was re-added");
}
fn bootstrap_v6_db(path: &std::path::Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE symbols (
id TEXT PRIMARY KEY, name TEXT, kind TEXT, file_path TEXT,
start_line INTEGER, end_line INTEGER, start_byte INTEGER, end_byte INTEGER,
parent_id TEXT, signature TEXT, visibility TEXT, is_async BOOLEAN,
docstring TEXT, in_degree INTEGER DEFAULT 0,
content_hash TEXT, subtree_hash TEXT);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL, target_name TEXT NOT NULL, target_id TEXT,
kind TEXT NOT NULL, file_path TEXT NOT NULL, line INTEGER,
resolution_state INTEGER NOT NULL DEFAULT 0, resolution_source TEXT);
CREATE TABLE files (path TEXT PRIMARY KEY);
CREATE TABLE symbol_content (
symbol_id TEXT PRIMARY KEY, content TEXT NOT NULL, header TEXT NOT NULL,
normalized_name TEXT NOT NULL DEFAULT '');
CREATE VIRTUAL TABLE symbol_fts USING fts5(
symbol_name, normalized_name, content,
content=symbol_content, content_rowid=rowid);
CREATE TRIGGER symbol_content_ai AFTER INSERT ON symbol_content BEGIN
INSERT INTO symbol_fts(rowid, symbol_name, normalized_name, content)
VALUES (new.rowid, (SELECT name FROM symbols WHERE id = new.symbol_id),
new.normalized_name, new.content);
END;
CREATE TRIGGER symbol_content_ad AFTER DELETE ON symbol_content BEGIN
INSERT INTO symbol_fts(symbol_fts, rowid, symbol_name, normalized_name, content)
VALUES ('delete', old.rowid, (SELECT name FROM symbols WHERE id = old.symbol_id),
old.normalized_name, old.content);
END;
CREATE TABLE symbol_embedding_map (symbol_id TEXT NOT NULL);
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE query_log (id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL, source TEXT NOT NULL, ts INTEGER NOT NULL);
INSERT INTO symbols (id, name, kind, file_path) VALUES ('a.py:import:os.path', 'os.path', 'import', 'a.py');
INSERT INTO files (path) VALUES ('a.py');
INSERT INTO edges (source_id, target_name, kind, file_path, line)
VALUES ('a.py:import:os.path', 'os', 'imports', 'a.py', 1);
INSERT INTO symbol_content (symbol_id, content, header)
VALUES ('a.py:import:os.path', 'body', 'sig');
INSERT INTO symbol_embedding_map (symbol_id) VALUES ('a.py:import:os.path');
INSERT INTO metadata (key, value) VALUES ('schema_version', '6');
INSERT INTO metadata (key, value) VALUES ('last_commit', 'deadbeef');",
)
.unwrap();
}
#[test]
fn migration_v6_to_v7_clears_index_for_full_rebuild() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v6.sqlite");
bootstrap_v6_db(&path);
let db = Database::open(&path, DEFAULT_EMBEDDING_DIM).unwrap();
let count = |table: &str| -> i64 {
db.conn
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
};
assert_eq!(count("symbols"), 0, "symbols cleared");
assert_eq!(count("edges"), 0, "edges cleared");
assert_eq!(count("files"), 0, "files cleared");
assert_eq!(count("symbol_content"), 0, "symbol_content cleared");
assert_eq!(
count("symbol_embedding_map"),
0,
"symbol_embedding_map cleared"
);
let last_commit: Option<String> = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'last_commit'",
[],
|r| r.get(0),
)
.optional()
.unwrap();
assert_eq!(
last_commit, None,
"last_commit cleared to force full reindex"
);
let bumped: String = db
.conn
.query_row(
"SELECT value FROM metadata WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(bumped, SCHEMA_VERSION.to_string());
let backups = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("v6.sqlite.pre-v")
})
.count();
assert_eq!(backups, 1, "v6→7 wipe must back up the index first");
}
#[test]
fn read_metadata_at_returns_value_when_present() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.set_metadata("last_commit", "abc1234").unwrap();
}
assert_eq!(
read_metadata_at(&db_path, "last_commit").unwrap(),
Some("abc1234".to_string())
);
}
#[test]
fn read_metadata_at_returns_none_when_row_absent() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let _db = Database::open(&db_path, 384).unwrap();
assert_eq!(read_metadata_at(&db_path, "last_commit").unwrap(), None);
}
#[test]
fn read_metadata_at_returns_none_for_non_cartog_sqlite() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("foreign.db");
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch("CREATE TABLE notes(content TEXT);")
.unwrap();
drop(conn);
assert_eq!(read_metadata_at(&db_path, "last_commit").unwrap(), None);
}
#[test]
fn read_metadata_at_returns_none_for_null_value() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
{
let db = Database::open(&db_path, 384).unwrap();
db.conn
.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('last_commit', NULL)",
[],
)
.unwrap();
}
assert_eq!(read_metadata_at(&db_path, "last_commit").unwrap(), None);
}