Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! This SQLite-backed substrate stores raw symbols, references, and resolved
4//! edges, and backs the live call-graph commands (callers, call-tree, impact,
5//! trace) as well as dead-code reachability. It is self-contained: it can be
6//! built and queried directly without going through the in-memory call graph.
7
8use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
9use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
10use crate::context::SubcLifecycleAdmission;
11use crate::error::AftError;
12use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
13use crate::parser::{grammar_for, LangId};
14use crate::symbols::{Range, SymbolKind};
15use rayon::prelude::*;
16use rusqlite::{
17    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
18};
19use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20use std::fmt;
21use std::io::Read;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
24use std::sync::{Arc, Condvar, Mutex, OnceLock};
25use std::thread::JoinHandle;
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27use tree_sitter::{Node, Parser};
28
29const SCHEMA_VERSION: i64 = 1;
30const BACKEND_TREESITTER: &str = "treesitter";
31const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
32const PROVENANCE_NAME_MATCH: &str = "name_match";
33const PROVENANCE_TYPE_MATCH: &str = "type_match";
34const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
35const TOP_LEVEL_SYMBOL: &str = "<top-level>";
36const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
37const MIGRATION_MANIFEST_VERSION: u32 = 1;
38const MIGRATION_GENERATION_TAG: &str = ".migrated.";
39const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
40const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
41const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
42const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
43/// Marker-protected generations older than this absolute age are reclaimed even
44/// if a stale reader marker remains. Current and newest-previous generations are
45/// always retained, bounding the root-keyed callgraph store to roughly two or
46/// three large generations without adding user-visible configuration.
47const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
48const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
49const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
50pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
51const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
52const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
53const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
54const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
55const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
56
57fn write_amplification_baseline_enabled() -> bool {
58    std::env::var_os("AFT_CALLGRAPH_WRITE_AMP_BASELINE").is_some()
59}
60
61type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
62
63#[derive(Clone, Debug, Eq, Hash, PartialEq)]
64struct RebuildCooldownKey {
65    callgraph_dir: PathBuf,
66    project_key: String,
67}
68
69#[derive(Clone, Debug)]
70struct RebuildCooldownRecord {
71    project_root: PathBuf,
72    published_at: Instant,
73    cross_root_cooldown_armed: bool,
74}
75
76// Prevent repeated rebuilds when requests rapidly switch between project
77// roots. Allow the first successful rebuild for a different root; after that
78// transition, report the artifact as unavailable instead of publishing another
79// complete generation. Record only successful publications in this map.
80static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
81    OnceLock::new();
82
83#[derive(Clone, Debug, Eq, Hash, PartialEq)]
84struct RootRepairWarningKey {
85    project_key: String,
86}
87
88#[derive(Clone, Debug)]
89struct RootRepairWarningRecord {
90    window_start: Instant,
91    last_emitted: Instant,
92    entry_count: u64,
93    suppressed: u64,
94}
95
96static ROOT_REPAIR_WARNINGS: OnceLock<
97    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
98> = OnceLock::new();
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct CallgraphWriteMetricsSnapshot {
102    pub commits_60s: u64,
103    pub pages_or_bytes_written_60s: u64,
104}
105
106#[derive(Debug, Default)]
107struct CallgraphWriteMetrics {
108    window_start_ms: AtomicU64,
109    commits_60s: AtomicU64,
110    pages_or_bytes_written_60s: AtomicU64,
111}
112
113static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
114    OnceLock::new();
115
116fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
117    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
118    let mut metrics = metrics
119        .lock()
120        .expect("callgraph write metrics mutex poisoned");
121    Arc::clone(
122        metrics
123            .entry(project_key.to_string())
124            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
125    )
126}
127
128fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
129    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
130    if current_start == 0 {
131        let _ = metrics.window_start_ms.compare_exchange(
132            0,
133            now_ms,
134            AtomicOrdering::AcqRel,
135            AtomicOrdering::Acquire,
136        );
137        return;
138    }
139    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
140        return;
141    }
142    if metrics
143        .window_start_ms
144        .compare_exchange(
145            current_start,
146            now_ms,
147            AtomicOrdering::AcqRel,
148            AtomicOrdering::Acquire,
149        )
150        .is_ok()
151    {
152        metrics.commits_60s.store(0, AtomicOrdering::Release);
153        metrics
154            .pages_or_bytes_written_60s
155            .store(0, AtomicOrdering::Release);
156    }
157}
158
159impl CallgraphWriteMetrics {
160    fn record_commit(&self, pages_or_bytes_written: u64) {
161        let now_ms = unix_millis_now();
162        roll_callgraph_write_metric_window(self, now_ms);
163        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
164        self.pages_or_bytes_written_60s
165            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
166    }
167
168    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
169        roll_callgraph_write_metric_window(self, unix_millis_now());
170        CallgraphWriteMetricsSnapshot {
171            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
172            pages_or_bytes_written_60s: self
173                .pages_or_bytes_written_60s
174                .load(AtomicOrdering::Acquire),
175        }
176    }
177}
178
179pub(crate) fn callgraph_write_metrics_for_project(
180    project_key: &str,
181) -> CallgraphWriteMetricsSnapshot {
182    callgraph_write_metrics_for_key(project_key).snapshot()
183}
184
185pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
186    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
187        return CallgraphWriteMetricsSnapshot::default();
188    };
189    let metrics = metrics
190        .lock()
191        .expect("callgraph write metrics mutex poisoned");
192    metrics.values().map(|metrics| metrics.snapshot()).fold(
193        CallgraphWriteMetricsSnapshot::default(),
194        |total, current| CallgraphWriteMetricsSnapshot {
195            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
196            pages_or_bytes_written_60s: total
197                .pages_or_bytes_written_60s
198                .saturating_add(current.pages_or_bytes_written_60s),
199        },
200    )
201}
202
203const ROOT_REPAIR_WARNING_TEXT: &str =
204    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
205
206fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
207    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
208    let mut warnings = warnings.lock().ok()?;
209    let entry = warnings.entry(key);
210    let record = match entry {
211        Entry::Vacant(entry) => {
212            entry.insert(RootRepairWarningRecord {
213                window_start: now,
214                last_emitted: now,
215                entry_count: 1,
216                suppressed: 0,
217            });
218            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
219        }
220        Entry::Occupied(entry) => entry.into_mut(),
221    };
222
223    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
224        let suppressed = record.suppressed;
225        record.window_start = now;
226        record.last_emitted = now;
227        record.entry_count = 1;
228        record.suppressed = 0;
229        return Some(if suppressed == 0 {
230            ROOT_REPAIR_WARNING_TEXT.to_string()
231        } else {
232            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
233        });
234    }
235
236    record.entry_count = record.entry_count.saturating_add(1);
237    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
238        record.suppressed = record.suppressed.saturating_add(1);
239        None
240    } else {
241        record.last_emitted = now;
242        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
243    }
244}
245
246pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
247    next_root_repair_warning(
248        RootRepairWarningKey {
249            project_key: project_key.to_string(),
250        },
251        Instant::now(),
252    )
253}
254
255/// Return the number of repair entries in the active 60-second window.
256///
257/// The window start is returned for callers that need to show freshness without
258/// adding another status verdict or turning this into a user-facing setting.
259pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
260    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
261    let warnings = warnings.lock().ok()?;
262    let record = warnings.get(&RootRepairWarningKey {
263        project_key: project_key.to_string(),
264    })?;
265    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
266        .then_some((record.entry_count, record.window_start))
267}
268
269pub(crate) fn repair_entry_rate_total() -> u64 {
270    let Ok(warnings) = ROOT_REPAIR_WARNINGS
271        .get_or_init(|| Mutex::new(HashMap::new()))
272        .lock()
273    else {
274        return 0;
275    };
276    let now = Instant::now();
277    warnings
278        .values()
279        .filter(|record| {
280            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
281        })
282        .map(|record| record.entry_count)
283        .sum()
284}
285
286#[cfg(test)]
287pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
288    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
289    let mut warnings = warnings.lock().unwrap();
290    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
291        project_key: project_key.to_string(),
292    }) {
293        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
294    }
295}
296
297#[cfg(test)]
298mod root_repair_warning_tests {
299    use super::*;
300
301    #[test]
302    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
303        let key = RootRepairWarningKey {
304            project_key: "test-project".to_string(),
305        };
306        let first_at = Instant::now();
307        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
308        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
309        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
310        assert_eq!(
311            repair_entry_rate("test-project").map(|rate| rate.0),
312            Some(2)
313        );
314
315        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
316        assert!(repeated.ends_with("(repeated 1x in 60s)"));
317        expire_repair_entry_window_for_test("test-project");
318        assert!(repair_entry_rate("test-project").is_none());
319    }
320}
321
322#[cfg(test)]
323mod write_amplification_tests {
324    use super::*;
325    use std::fs;
326    use tempfile::tempdir;
327
328    #[test]
329    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
330        let temp = tempdir().unwrap();
331        let root = temp.path().join("root");
332        fs::create_dir_all(&root).unwrap();
333        let source = root.join("main.ts");
334        fs::write(&source, "export function main() {}\n").unwrap();
335        let store_dir = temp.path().join("store");
336        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
337
338        let conn = store.conn.lock().unwrap();
339        let synchronous: i64 = conn
340            .pragma_query_value(None, "synchronous", |row| row.get(0))
341            .unwrap();
342        let autocheckpoint: i64 = conn
343            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
344            .unwrap();
345        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
346        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
347        drop(conn);
348        store.cold_build(std::slice::from_ref(&source)).unwrap();
349        drop(store);
350
351        let readonly = CallGraphStore::open_readonly(store_dir, root)
352            .unwrap()
353            .expect("writer-created empty schema should be readable");
354        let conn = readonly.inner.conn.lock().unwrap();
355        let synchronous: i64 = conn
356            .pragma_query_value(None, "synchronous", |row| row.get(0))
357            .unwrap();
358        assert_eq!(synchronous, 1);
359    }
360
361    #[test]
362    fn own_refresh_skips_identical_extract_but_not_position_shift() {
363        let temp = tempdir().unwrap();
364        let root = temp.path().join("root");
365        fs::create_dir_all(&root).unwrap();
366        let source = root.join("main.ts");
367        fs::write(&source, "export function main() { return 1; }\n").unwrap();
368        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
369        store.cold_build(std::slice::from_ref(&source)).unwrap();
370        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
371        assert!(write_metrics.commits_60s > 0);
372        assert!(write_metrics.pages_or_bytes_written_60s > 0);
373
374        let before = store.conn.lock().unwrap().total_changes();
375        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
376        let (stats, _) = store
377            .refresh_files_profiled(std::slice::from_ref(&source))
378            .unwrap();
379        let after = store.conn.lock().unwrap().total_changes();
380        assert_eq!(stats.unchanged_extract_files, 1);
381        assert_eq!(stats.refreshed_own_files, 0);
382        assert_eq!(
383            after - before,
384            2,
385            "only files and backend freshness rows update"
386        );
387
388        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
389        let (shifted_stats, _) = store
390            .refresh_files_profiled(std::slice::from_ref(&source))
391            .unwrap();
392        assert_eq!(shifted_stats.unchanged_extract_files, 0);
393        assert_eq!(shifted_stats.refreshed_own_files, 1);
394    }
395
396    #[test]
397    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
398        let now = Instant::now();
399        assert!(idle_checkpoint_due(None, now));
400        assert!(!idle_checkpoint_due(
401            Some(now),
402            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
403        ));
404        assert!(idle_checkpoint_due(
405            Some(now),
406            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
407        ));
408    }
409
410    #[test]
411    fn write_metrics_decay_after_the_sixty_second_window() {
412        let key = format!("metrics-test-{}", now_nanos());
413        let metrics = callgraph_write_metrics_for_key(&key);
414        metrics.record_commit(17);
415        assert_eq!(metrics.snapshot().commits_60s, 1);
416        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
417        metrics.window_start_ms.store(
418            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
419            AtomicOrdering::Release,
420        );
421        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
422    }
423}
424
425#[cfg(test)]
426type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
427// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
428// thread running the cold build, and the only caller (a test) installs and
429// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
430// across parallel tests — one test's installed observer fired during ANOTHER
431// test's `cold_build_with_lease`, asserting against the wrong build's edges
432// (flaked on Windows CI under parallel scheduling). Production never sets it.
433thread_local! {
434    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
435        const { std::cell::RefCell::new(None) };
436    #[cfg(test)]
437    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
438        const { std::cell::RefCell::new(None) };
439    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
440        const { std::cell::RefCell::new(None) };
441    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
442    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
443        const { std::cell::Cell::new(false) };
444    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
445        const { std::cell::RefCell::new(None) };
446    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
447        const { std::cell::RefCell::new(None) };
448}
449
450mod dead_code_projection;
451pub use dead_code_projection::project_dead_code_snapshot;
452#[cfg(test)]
453pub(crate) use dead_code_projection::set_projection_before_open_observer;
454
455#[doc(hidden)]
456pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
457    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
458}
459
460#[cfg(test)]
461fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
462    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
463}
464
465#[cfg(test)]
466fn notify_cold_build_before_publish_observer() {
467    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
468    if let Some(observer) = observer {
469        observer();
470    }
471}
472
473#[cfg(not(test))]
474fn notify_cold_build_before_publish_observer() {}
475
476#[doc(hidden)]
477pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
478    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
479}
480
481#[doc(hidden)]
482pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
483    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
484}
485
486#[doc(hidden)]
487pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
488    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
489}
490
491struct PublishAdmissionGuard {
492    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
493}
494
495impl Drop for PublishAdmissionGuard {
496    fn drop(&mut self) {
497        PUBLISH_ADMISSION.with(|slot| {
498            *slot.borrow_mut() = self.previous.take();
499        });
500    }
501}
502
503pub(crate) fn with_publish_epoch<R>(
504    epoch: crate::root_cache::ArtifactPublishEpoch,
505    expected: u64,
506    run: impl FnOnce() -> R,
507) -> R {
508    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
509    let _guard = PublishAdmissionGuard { previous };
510    run()
511}
512
513fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
514    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
515    match admission {
516        Some((epoch, expected)) => epoch
517            .run_if_current(expected, publish)
518            .unwrap_or(Err(CallGraphStoreError::Superseded)),
519        None => publish(),
520    }
521}
522
523struct RefreshCommitAdmissionGuard {
524    previous: Option<(
525        SubcLifecycleAdmission,
526        Arc<std::sync::atomic::AtomicU64>,
527        u64,
528    )>,
529}
530
531impl Drop for RefreshCommitAdmissionGuard {
532    fn drop(&mut self) {
533        REFRESH_COMMIT_ADMISSION.with(|slot| {
534            *slot.borrow_mut() = self.previous.take();
535        });
536    }
537}
538
539fn with_refresh_commit_admission<R>(
540    lifecycle: SubcLifecycleAdmission,
541    generation_flag: Arc<std::sync::atomic::AtomicU64>,
542    expected_generation: u64,
543    run: impl FnOnce() -> R,
544) -> R {
545    let previous = REFRESH_COMMIT_ADMISSION
546        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
547    let _guard = RefreshCommitAdmissionGuard { previous };
548    run()
549}
550
551fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
552    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
553    let commit = || {
554        publish_if_current(|| {
555            tx.commit()?;
556            Ok(())
557        })
558    };
559    match admission {
560        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
561            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
562            .unwrap_or(Err(CallGraphStoreError::Superseded)),
563        None => commit(),
564    }
565}
566
567fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
568    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
569    if let Some(observer) = observer {
570        observer(temp_path, target_path);
571    }
572}
573
574#[derive(Debug)]
575pub enum CallGraphStoreError {
576    Io(std::io::Error),
577    Sqlite(rusqlite::Error),
578    Json(serde_json::Error),
579    Aft(AftError),
580    Lock(crate::fs_lock::AcquireError),
581    MissingCallerData { file: String },
582    Unavailable(String),
583    Superseded,
584    StaleFiles(Vec<String>),
585}
586
587impl fmt::Display for CallGraphStoreError {
588    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
589        match self {
590            Self::Io(error) => write!(formatter, "I/O error: {error}"),
591            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
592            Self::Json(error) => write!(formatter, "json error: {error}"),
593            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
594            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
595            Self::MissingCallerData { file } => {
596                write!(formatter, "missing extracted caller data for {file}")
597            }
598            Self::Unavailable(message) => {
599                write!(formatter, "callgraph store unavailable: {message}")
600            }
601            Self::Superseded => {
602                write!(formatter, "callgraph store build superseded before publish")
603            }
604            Self::StaleFiles(files) => {
605                write!(
606                    formatter,
607                    "callgraph store has stale files: {}",
608                    files.join(", ")
609                )
610            }
611        }
612    }
613}
614
615impl std::error::Error for CallGraphStoreError {}
616
617impl From<std::io::Error> for CallGraphStoreError {
618    fn from(error: std::io::Error) -> Self {
619        Self::Io(error)
620    }
621}
622
623impl From<rusqlite::Error> for CallGraphStoreError {
624    fn from(error: rusqlite::Error) -> Self {
625        Self::Sqlite(error)
626    }
627}
628
629impl From<serde_json::Error> for CallGraphStoreError {
630    fn from(error: serde_json::Error) -> Self {
631        Self::Json(error)
632    }
633}
634
635impl From<AftError> for CallGraphStoreError {
636    fn from(error: AftError) -> Self {
637        Self::Aft(error)
638    }
639}
640
641impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
642    fn from(error: crate::fs_lock::AcquireError) -> Self {
643        Self::Lock(error)
644    }
645}
646
647pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
648
649/// Config flag name gating whether the store is opened (default on). Production
650/// commands open it through `open_if_enabled` so the substrate can be disabled
651/// without code changes.
652pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
653
654#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
655pub struct CallGraphStoreOptions {
656    pub enabled: bool,
657}
658
659pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
660
661/// Shared context state that lets the refresh worker observe a store installed
662/// after its batch was opened. The worker clones the installed store Arc before
663/// checking it, so no context lock guard crosses the check or enqueue call.
664#[derive(Clone)]
665pub(crate) struct CallgraphRefreshState {
666    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
667    heavy_root_work_allowed: Arc<AtomicBool>,
668}
669
670impl CallgraphRefreshState {
671    pub(crate) fn new(
672        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
673        heavy_root_work_allowed: Arc<AtomicBool>,
674    ) -> Self {
675        Self {
676            store,
677            heavy_root_work_allowed,
678        }
679    }
680
681    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
682        self.store
683            .read()
684            .unwrap_or_else(std::sync::PoisonError::into_inner)
685            .as_ref()
686            .map(Arc::clone)
687    }
688}
689
690type WorkspaceCratePrefixes = HashMap<String, String>;
691
692#[derive(Clone, Debug, Default)]
693struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
694
695const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
696
697pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
698    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
699}
700
701#[derive(Clone, Debug, Hash, PartialEq, Eq)]
702struct RefreshRoot {
703    callgraph_dir: PathBuf,
704    project_root: PathBuf,
705}
706
707#[derive(Clone)]
708pub(crate) struct CallgraphRefreshTicket {
709    lifecycle: SubcLifecycleAdmission,
710    generation_flag: Arc<std::sync::atomic::AtomicU64>,
711    expected_generation: u64,
712    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
713    expected_publish_epoch: u64,
714}
715
716impl CallgraphRefreshTicket {
717    pub(crate) fn new(
718        lifecycle: SubcLifecycleAdmission,
719        generation_flag: Arc<std::sync::atomic::AtomicU64>,
720        expected_generation: u64,
721        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
722        expected_publish_epoch: u64,
723    ) -> Self {
724        Self {
725            lifecycle,
726            generation_flag,
727            expected_generation,
728            publish_epoch,
729            expected_publish_epoch,
730        }
731    }
732
733    fn is_current(&self) -> bool {
734        self.lifecycle
735            .is_current(self.generation_flag.as_ref(), self.expected_generation)
736            && self.publish_epoch.current() == self.expected_publish_epoch
737    }
738}
739
740#[derive(Clone)]
741struct RefreshBatch {
742    root: RefreshRoot,
743    paths: BTreeSet<PathBuf>,
744    pending_sinks: Vec<PendingCallGraphStorePaths>,
745    refresh_states: Vec<CallgraphRefreshState>,
746    ticket: Option<CallgraphRefreshTicket>,
747}
748
749impl RefreshBatch {
750    fn defer(&self) {
751        for sink in &self.pending_sinks {
752            sink.lock().extend(self.paths.iter().cloned());
753        }
754    }
755
756    fn defer_after_open_failure(&self) {
757        self.defer();
758        if self
759            .ticket
760            .as_ref()
761            .is_some_and(|ticket| !ticket.is_current())
762            || !self
763                .refresh_states
764                .iter()
765                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
766        {
767            return;
768        }
769
770        let ready_store_installed = self.refresh_states.iter().any(|state| {
771            let store = state.installed_store_snapshot();
772            store.is_some_and(|store| {
773                store.project_root() == self.root.project_root
774                    && !store.is_legacy_fallback()
775                    && store.is_current()
776            })
777        });
778        if !ready_store_installed {
779            return;
780        }
781
782        // This re-check and the ready-store install's pending-sink take form a
783        // check-then-act handoff: after this defer, exactly one site observes
784        // the parked paths with a ready current store, so no polling is needed.
785        for sink in &self.pending_sinks {
786            let paths = {
787                let mut pending = sink.lock();
788                self.paths
789                    .iter()
790                    .filter(|path| pending.remove(*path))
791                    .cloned()
792                    .collect::<Vec<_>>()
793            };
794            if paths.is_empty() {
795                continue;
796            }
797            let _ = enqueue_callgraph_store_refresh_inner(
798                self.root.callgraph_dir.clone(),
799                self.root.project_root.clone(),
800                paths,
801                Arc::clone(sink),
802                self.refresh_states.clone(),
803                self.ticket.clone(),
804            );
805        }
806    }
807
808    fn merge(
809        &mut self,
810        paths: impl IntoIterator<Item = PathBuf>,
811        sink: PendingCallGraphStorePaths,
812        refresh_states: Vec<CallgraphRefreshState>,
813        ticket: Option<CallgraphRefreshTicket>,
814    ) {
815        self.paths.extend(paths);
816        if ticket.is_some() {
817            self.ticket = ticket;
818        }
819        if !self
820            .pending_sinks
821            .iter()
822            .any(|existing| Arc::ptr_eq(existing, &sink))
823        {
824            self.pending_sinks.push(sink);
825        }
826        for refresh_state in refresh_states {
827            if !self.refresh_states.iter().any(|existing| {
828                Arc::ptr_eq(&existing.store, &refresh_state.store)
829                    && Arc::ptr_eq(
830                        &existing.heavy_root_work_allowed,
831                        &refresh_state.heavy_root_work_allowed,
832                    )
833            }) {
834                self.refresh_states.push(refresh_state);
835            }
836        }
837    }
838}
839
840#[derive(Default)]
841struct RefreshQueue {
842    order: VecDeque<RefreshRoot>,
843    queued: HashMap<RefreshRoot, RefreshBatch>,
844    active: Option<RefreshBatch>,
845    shutdown_requested: bool,
846}
847
848struct RefreshWorkerShared {
849    queue: Mutex<RefreshQueue>,
850    wake: Condvar,
851}
852
853struct RefreshWorker {
854    shared: Arc<RefreshWorkerShared>,
855    thread: Mutex<Option<JoinHandle<()>>>,
856}
857
858struct RefreshWorkerWatchdog {
859    first_path: PathBuf,
860    batch_len: usize,
861    started: Instant,
862}
863
864impl RefreshWorkerWatchdog {
865    fn start(paths: &[PathBuf]) -> Self {
866        Self {
867            first_path: paths
868                .first()
869                .expect("non-empty callgraph refresh batch has a first path")
870                .clone(),
871            batch_len: paths.len(),
872            started: Instant::now(),
873        }
874    }
875}
876
877impl Drop for RefreshWorkerWatchdog {
878    fn drop(&mut self) {
879        let elapsed = self.started.elapsed();
880        if elapsed < REFRESH_WORKER_WARN_AFTER {
881            return;
882        }
883        let path = if self.batch_len == 1 {
884            self.first_path.display().to_string()
885        } else {
886            format!(
887                "{} (+{} paths)",
888                self.first_path.display(),
889                self.batch_len - 1
890            )
891        };
892        log::warn!(
893            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
894            path,
895            elapsed.as_millis()
896        );
897        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
898            log::warn!(
899                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
900                path,
901                elapsed.as_millis()
902            );
903        }
904    }
905}
906
907impl RefreshWorker {
908    fn spawn() -> Arc<Self> {
909        let shared = Arc::new(RefreshWorkerShared {
910            queue: Mutex::new(RefreshQueue::default()),
911            wake: Condvar::new(),
912        });
913        let thread_shared = Arc::clone(&shared);
914        let thread = std::thread::Builder::new()
915            .name("aft-callgraph-refresh".to_string())
916            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
917            .expect("failed to spawn callgraph refresh worker");
918        Arc::new(Self {
919            shared,
920            thread: Mutex::new(Some(thread)),
921        })
922    }
923
924    fn enqueue(
925        &self,
926        root: RefreshRoot,
927        paths: Vec<PathBuf>,
928        pending_sink: PendingCallGraphStorePaths,
929        refresh_states: Vec<CallgraphRefreshState>,
930        ticket: Option<CallgraphRefreshTicket>,
931    ) -> bool {
932        let mut queue = self
933            .shared
934            .queue
935            .lock()
936            .expect("callgraph refresh queue mutex poisoned");
937        if queue.shutdown_requested {
938            pending_sink.lock().extend(paths);
939            return false;
940        }
941        if let Some(batch) = queue.queued.get_mut(&root) {
942            batch.merge(paths, pending_sink, refresh_states, ticket);
943        } else {
944            queue.order.push_back(root.clone());
945            queue.queued.insert(
946                root.clone(),
947                RefreshBatch {
948                    root,
949                    paths: paths.into_iter().collect(),
950                    pending_sinks: vec![pending_sink],
951                    refresh_states,
952                    ticket,
953                },
954            );
955        }
956        self.shared.wake.notify_one();
957        true
958    }
959
960    fn shutdown_with_budget(&self, budget: Duration) -> bool {
961        let deadline = Instant::now() + budget;
962        let mut queue = self
963            .shared
964            .queue
965            .lock()
966            .expect("callgraph refresh queue mutex poisoned");
967        queue.shutdown_requested = true;
968        self.shared.wake.notify_one();
969        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
970            let remaining = deadline.saturating_duration_since(Instant::now());
971            let (next, _) = self
972                .shared
973                .wake
974                .wait_timeout(queue, remaining)
975                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
976            queue = next;
977        }
978        let drained = queue.active.is_none() && queue.order.is_empty();
979        if !drained {
980            if let Some(active) = queue.active.as_ref() {
981                active.defer();
982            }
983            for batch in queue.queued.values() {
984                batch.defer();
985            }
986            queue.order.clear();
987            queue.queued.clear();
988        }
989        drop(queue);
990
991        if drained {
992            if let Some(thread) = self
993                .thread
994                .lock()
995                .expect("callgraph refresh worker thread mutex poisoned")
996                .take()
997            {
998                let _ = thread.join();
999            }
1000        }
1001        drained
1002    }
1003}
1004
1005static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1006
1007pub fn enqueue_callgraph_store_refresh(
1008    callgraph_dir: PathBuf,
1009    project_root: PathBuf,
1010    paths: Vec<PathBuf>,
1011    pending_sink: PendingCallGraphStorePaths,
1012) -> bool {
1013    enqueue_callgraph_store_refresh_inner(
1014        callgraph_dir,
1015        project_root,
1016        paths,
1017        pending_sink,
1018        Vec::new(),
1019        None,
1020    )
1021}
1022
1023#[cfg(test)]
1024pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1025    callgraph_dir: PathBuf,
1026    project_root: PathBuf,
1027    paths: Vec<PathBuf>,
1028    pending_sink: PendingCallGraphStorePaths,
1029    ticket: CallgraphRefreshTicket,
1030) -> bool {
1031    enqueue_callgraph_store_refresh_inner(
1032        callgraph_dir,
1033        project_root,
1034        paths,
1035        pending_sink,
1036        Vec::new(),
1037        Some(ticket),
1038    )
1039}
1040
1041pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1042    callgraph_dir: PathBuf,
1043    project_root: PathBuf,
1044    paths: Vec<PathBuf>,
1045    pending_sink: PendingCallGraphStorePaths,
1046    refresh_state: CallgraphRefreshState,
1047    ticket: CallgraphRefreshTicket,
1048) -> bool {
1049    enqueue_callgraph_store_refresh_inner(
1050        callgraph_dir,
1051        project_root,
1052        paths,
1053        pending_sink,
1054        vec![refresh_state],
1055        Some(ticket),
1056    )
1057}
1058
1059fn enqueue_callgraph_store_refresh_inner(
1060    callgraph_dir: PathBuf,
1061    project_root: PathBuf,
1062    paths: Vec<PathBuf>,
1063    pending_sink: PendingCallGraphStorePaths,
1064    refresh_states: Vec<CallgraphRefreshState>,
1065    ticket: Option<CallgraphRefreshTicket>,
1066) -> bool {
1067    if paths.is_empty() {
1068        return true;
1069    }
1070    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1071    let worker = {
1072        let mut worker = slot
1073            .lock()
1074            .expect("callgraph refresh worker mutex poisoned");
1075        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1076    };
1077    worker.enqueue(
1078        RefreshRoot {
1079            callgraph_dir,
1080            project_root,
1081        },
1082        paths,
1083        pending_sink,
1084        refresh_states,
1085        ticket,
1086    )
1087}
1088
1089pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1090    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1091}
1092
1093#[doc(hidden)]
1094pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1095    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1096    let worker = slot
1097        .lock()
1098        .expect("callgraph refresh worker mutex poisoned")
1099        .clone();
1100    let Some(worker) = worker else {
1101        return true;
1102    };
1103    let drained = worker.shutdown_with_budget(budget);
1104    if drained {
1105        let mut current = slot
1106            .lock()
1107            .expect("callgraph refresh worker mutex poisoned");
1108        if current
1109            .as_ref()
1110            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1111        {
1112            *current = None;
1113        }
1114    }
1115    drained
1116}
1117
1118fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1119    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1120}
1121
1122fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1123    // The worker owns these caches so maps are shared only by refreshes for the
1124    // same canonical root and disappear when the worker shuts down.
1125    let mut workspace_crate_prefixes = HashMap::new();
1126    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1127    loop {
1128        let batch = {
1129            let mut queue = shared
1130                .queue
1131                .lock()
1132                .expect("callgraph refresh queue mutex poisoned");
1133            loop {
1134                if let Some(root) = queue.order.pop_front() {
1135                    let batch = queue
1136                        .queued
1137                        .remove(&root)
1138                        .expect("queued callgraph refresh root has a batch");
1139                    queue.active = Some(batch.clone());
1140                    break batch;
1141                }
1142                if queue.shutdown_requested {
1143                    return;
1144                }
1145                queue = shared
1146                    .wake
1147                    .wait(queue)
1148                    .expect("callgraph refresh queue mutex poisoned while waiting");
1149            }
1150        };
1151
1152        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1153
1154        let mut queue = shared
1155            .queue
1156            .lock()
1157            .expect("callgraph refresh queue mutex poisoned");
1158        queue.active = None;
1159        let became_idle = queue.order.is_empty();
1160        shared.wake.notify_all();
1161        drop(queue);
1162
1163        if became_idle {
1164            let checkpoint_due = idle_checkpoint_due(
1165                last_idle_checkpoints.get(&batch.root).copied(),
1166                Instant::now(),
1167            );
1168            if checkpoint_due {
1169                if let Some(store) = store {
1170                    if store.checkpoint_wal_truncate() {
1171                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1172                    }
1173                }
1174            }
1175        }
1176    }
1177}
1178
1179fn process_callgraph_refresh_batch(
1180    batch: &RefreshBatch,
1181    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1182) -> Option<CallGraphStore> {
1183    // A manifest event is an invalidation signal, not a source file to parse.
1184    // Drop the root's map even for a superseded batch: the filesystem changed,
1185    // and a later configure must never inherit crate membership from before it.
1186    if batch
1187        .paths
1188        .iter()
1189        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1190    {
1191        workspace_crate_prefixes.remove(&batch.root);
1192    }
1193
1194    let paths = batch
1195        .paths
1196        .iter()
1197        .filter(|path| crate::parser::detect_language(path).is_some())
1198        .cloned()
1199        .collect::<Vec<_>>();
1200    if paths.is_empty() {
1201        return None;
1202    }
1203    note_refresh_worker_batch_for_test(&batch.root.project_root);
1204    if batch
1205        .ticket
1206        .as_ref()
1207        .is_some_and(|ticket| !ticket.is_current())
1208    {
1209        // Superseded before starting: park the paths so the next configure's
1210        // pending replay (or unbind cleanup) decides their fate.
1211        batch.defer();
1212        return None;
1213    }
1214    let workspace_crate_prefix_cache =
1215        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1216    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1217    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1218    note_refresh_worker_call_for_test(&batch.root.project_root);
1219    let opened = if test_seam.fail_open {
1220        Ok(None)
1221    } else {
1222        CallGraphStore::open_ready(
1223            batch.root.callgraph_dir.clone(),
1224            batch.root.project_root.clone(),
1225        )
1226    };
1227    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1228        // The gate is deliberately after open_ready so tests can hold a failed
1229        // open between its result and the defer that parks the batch.
1230        let _ = gate.held_tx.send(());
1231        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1232    }
1233    let store = match opened {
1234        Ok(Some(store)) => store,
1235        Ok(None) => {
1236            batch.defer_after_open_failure();
1237            return None;
1238        }
1239        Err(error) => {
1240            batch.defer_after_open_failure();
1241            crate::slog_warn!(
1242                "callgraph store writer open failed during refresh; deferred paths: {}",
1243                error
1244            );
1245            return None;
1246        }
1247    };
1248    if !test_seam.delay.is_zero() {
1249        std::thread::sleep(test_seam.delay);
1250    }
1251    if batch
1252        .ticket
1253        .as_ref()
1254        .is_some_and(|ticket| !ticket.is_current())
1255    {
1256        // This is a superseded-ticket defer, not an open-failure defer: leave
1257        // the paths for the replacement configure instead of self-replaying.
1258        batch.defer();
1259        return Some(store);
1260    }
1261    let refresh_result = if test_seam.fail_refresh {
1262        Err(CallGraphStoreError::Unavailable(
1263            "injected refresh worker failure".to_string(),
1264        ))
1265    } else if let Some(ticket) = &batch.ticket {
1266        with_publish_epoch(
1267            ticket.publish_epoch.clone(),
1268            ticket.expected_publish_epoch,
1269            || {
1270                with_refresh_commit_admission(
1271                    ticket.lifecycle.clone(),
1272                    Arc::clone(&ticket.generation_flag),
1273                    ticket.expected_generation,
1274                    || {
1275                        store
1276                            .refresh_files_with_workspace_crate_prefix_cache(
1277                                &paths,
1278                                workspace_crate_prefix_cache.clone(),
1279                            )
1280                            .map(|_| ())
1281                    },
1282                )
1283            },
1284        )
1285    } else {
1286        store
1287            .refresh_files_with_workspace_crate_prefix_cache(
1288                &paths,
1289                workspace_crate_prefix_cache.clone(),
1290            )
1291            .map(|_| ())
1292    };
1293    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1294        // The commit lost the fence race: a newer configure or publication
1295        // owns the store now. Defer instead of stale-marking — the paths were
1296        // never committed, and the replacement generation re-indexes them.
1297        batch.defer();
1298        return Some(store);
1299    }
1300    if let Err(error) = refresh_result {
1301        crate::slog_warn!("callgraph store refresh failed: {}", error);
1302        match store.mark_files_stale(&paths) {
1303            Ok(marked) => {
1304                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1305                crate::slog_warn!(
1306                    "marked {} callgraph store file(s) stale after refresh failure",
1307                    marked.len()
1308                );
1309            }
1310            Err(mark_error) => crate::slog_warn!(
1311                "failed to mark callgraph store files stale after refresh failure: {}",
1312                mark_error
1313            ),
1314        }
1315    } else {
1316        crate::logging::note_callgraph_invalidations(paths.len());
1317    }
1318    Some(store)
1319}
1320
1321fn workspace_crate_prefix_cache_for_root(
1322    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1323    root: &RefreshRoot,
1324) -> WorkspaceCratePrefixCache {
1325    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1326        // Eviction only costs a future rebuild; it cannot make resolution stale.
1327        if let Some(evicted) = caches.keys().next().cloned() {
1328            caches.remove(&evicted);
1329        }
1330    }
1331    caches.entry(root.clone()).or_default().clone()
1332}
1333
1334#[derive(Clone, Copy, Default)]
1335struct RefreshWorkerTestSeam {
1336    delay: Duration,
1337    fail_refresh: bool,
1338    fail_open: bool,
1339    refresh_calls: usize,
1340    worker_calls: usize,
1341    stale_marks: usize,
1342}
1343
1344static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1345    OnceLock::new();
1346
1347struct RefreshWorkerTestGate {
1348    held_tx: crossbeam_channel::Sender<()>,
1349    release_rx: crossbeam_channel::Receiver<()>,
1350}
1351
1352static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1353    OnceLock::new();
1354
1355#[doc(hidden)]
1356pub fn install_callgraph_refresh_worker_test_gate(
1357    project_root: PathBuf,
1358) -> (
1359    crossbeam_channel::Receiver<()>,
1360    crossbeam_channel::Sender<()>,
1361) {
1362    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1363    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1364    REFRESH_WORKER_TEST_GATES
1365        .get_or_init(|| Mutex::new(HashMap::new()))
1366        .lock()
1367        .expect("callgraph refresh test gate mutex poisoned")
1368        .insert(
1369            project_root,
1370            RefreshWorkerTestGate {
1371                held_tx,
1372                release_rx,
1373            },
1374        );
1375    (held_rx, release_tx)
1376}
1377
1378fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1379    REFRESH_WORKER_TEST_GATES
1380        .get_or_init(|| Mutex::new(HashMap::new()))
1381        .lock()
1382        .expect("callgraph refresh test gate mutex poisoned")
1383        .remove(project_root)
1384}
1385
1386fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1387    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1388        return RefreshWorkerTestSeam::default();
1389    };
1390    seams
1391        .lock()
1392        .expect("callgraph refresh test seam mutex poisoned")
1393        .get(project_root)
1394        .copied()
1395        .unwrap_or_default()
1396}
1397
1398fn note_refresh_worker_batch_for_test(project_root: &Path) {
1399    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1400        if let Some(seam) = seams
1401            .lock()
1402            .expect("callgraph refresh test seam mutex poisoned")
1403            .get_mut(project_root)
1404        {
1405            seam.worker_calls += 1;
1406        }
1407    }
1408}
1409
1410fn note_refresh_worker_call_for_test(project_root: &Path) {
1411    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1412        if let Some(seam) = seams
1413            .lock()
1414            .expect("callgraph refresh test seam mutex poisoned")
1415            .get_mut(project_root)
1416        {
1417            seam.refresh_calls += 1;
1418        }
1419    }
1420}
1421
1422fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1423    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1424        if let Some(seam) = seams
1425            .lock()
1426            .expect("callgraph refresh test seam mutex poisoned")
1427            .get_mut(project_root)
1428        {
1429            seam.stale_marks += 1;
1430        }
1431    }
1432}
1433
1434#[doc(hidden)]
1435pub fn set_callgraph_refresh_worker_test_seam(
1436    project_root: PathBuf,
1437    delay: Duration,
1438    fail_refresh: bool,
1439) {
1440    REFRESH_WORKER_TEST_SEAMS
1441        .get_or_init(|| Mutex::new(HashMap::new()))
1442        .lock()
1443        .expect("callgraph refresh test seam mutex poisoned")
1444        .insert(
1445            project_root,
1446            RefreshWorkerTestSeam {
1447                delay,
1448                fail_refresh,
1449                ..RefreshWorkerTestSeam::default()
1450            },
1451        );
1452}
1453
1454#[doc(hidden)]
1455pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1456    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1457        if let Some(seam) = seams
1458            .lock()
1459            .expect("callgraph refresh test seam mutex poisoned")
1460            .get_mut(&project_root)
1461        {
1462            seam.fail_open = enabled;
1463        }
1464    }
1465}
1466
1467#[doc(hidden)]
1468pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1469    let seam = refresh_worker_test_seam(project_root);
1470    (seam.refresh_calls, seam.stale_marks)
1471}
1472
1473#[doc(hidden)]
1474pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1475    refresh_worker_test_seam(project_root).worker_calls
1476}
1477
1478#[doc(hidden)]
1479pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1480    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1481        seams
1482            .lock()
1483            .expect("callgraph refresh test seam mutex poisoned")
1484            .remove(project_root);
1485    }
1486}
1487
1488#[derive(Debug)]
1489pub struct CallGraphStore {
1490    project_root: PathBuf,
1491    project_key: String,
1492    /// The concrete on-disk DB file this store opened. With the generation
1493    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1494    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1495    sqlite_path: PathBuf,
1496    /// Root-keyed directory whose pointer controls this store. For a legacy
1497    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1498    /// newly published root-keyed generation invalidates the fallback reader.
1499    publication_dir: PathBuf,
1500    /// True only when the root-keyed read path opened data from a legacy
1501    /// harness partition. Writer-capable callers use this to schedule migration
1502    /// without making read-only/worktree callers acquire a writer lease.
1503    legacy_fallback: bool,
1504    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1505    /// or `None` when it opened the legacy single-file DB. Used to detect when
1506    /// another process has published a newer generation so this process can
1507    /// drop its connection and reopen (see `current_generation`).
1508    generation: Option<String>,
1509    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1510    read_marker: Option<crate::root_cache::ReadMarker>,
1511    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1512    // Failed validations are not cached, so a later successful build remains visible.
1513    database_ready: AtomicBool,
1514    write_metrics: Arc<CallgraphWriteMetrics>,
1515    conn: Mutex<Connection>,
1516}
1517
1518#[derive(Debug)]
1519pub struct ReadonlyCallGraphStore {
1520    inner: CallGraphStore,
1521}
1522
1523pub trait CallGraphRead {
1524    fn project_root(&self) -> &Path;
1525    fn project_key(&self) -> &str;
1526    fn sqlite_path(&self) -> &Path;
1527    fn is_current(&self) -> bool;
1528    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1529    fn indexed_file_count(&self) -> Result<usize>;
1530    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1531    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1532    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1533    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1534    fn direct_caller_counts_of(
1535        &self,
1536        targets: &[(String, String)],
1537    ) -> Result<HashMap<(String, String), usize>>;
1538    fn outgoing_calls_for_symbols(
1539        &self,
1540        sources: &[(String, String)],
1541    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1542    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1543        -> Result<StoreCallersResult>;
1544    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1545    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1546    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1547    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1548    fn call_tree(
1549        &self,
1550        file_rel: &Path,
1551        symbol: &str,
1552        depth: usize,
1553    ) -> Result<callgraph::CallTreeNode>;
1554    fn trace_to(
1555        &self,
1556        file_rel: &Path,
1557        symbol: &str,
1558        max_depth: usize,
1559    ) -> Result<callgraph::TraceToResult>;
1560    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1561    fn trace_to_symbol(
1562        &self,
1563        file_rel: &Path,
1564        symbol: &str,
1565        to_symbol: &str,
1566        to_file: Option<&Path>,
1567        max_depth: usize,
1568    ) -> Result<callgraph::TraceToSymbolResult>;
1569}
1570
1571#[derive(Debug, Clone, PartialEq, Eq)]
1572enum OpenRootRepair {
1573    None,
1574    ReRooted,
1575    NeedsRebuild {
1576        previous_roots: Vec<String>,
1577        current_root: String,
1578        reason: String,
1579    },
1580}
1581
1582struct OpenedStore {
1583    store: CallGraphStore,
1584    root_repair: OpenRootRepair,
1585}
1586
1587#[derive(Clone, Debug)]
1588struct LegacyCallgraphPartition {
1589    harness: String,
1590    dir: PathBuf,
1591    key: String,
1592    bytes: u64,
1593    freshness: Option<SystemTime>,
1594}
1595
1596#[derive(Clone, Debug)]
1597struct LegacyCallgraphTarget {
1598    partition: LegacyCallgraphPartition,
1599    sqlite_path: PathBuf,
1600    generation: Option<String>,
1601    source_bytes: u64,
1602    source_blake3: String,
1603}
1604
1605#[derive(Clone, Debug)]
1606struct SourceFingerprint {
1607    bytes: u64,
1608    blake3: String,
1609}
1610
1611#[derive(Clone, Debug)]
1612struct PublishedLegacyMigration {
1613    generation: String,
1614    migrated_bytes: u64,
1615}
1616
1617#[derive(Debug, Clone)]
1618pub struct ColdBuildStats {
1619    pub files: usize,
1620    pub nodes: usize,
1621    pub refs: usize,
1622    pub edges: usize,
1623    pub failed_files: Vec<String>,
1624    pub elapsed_ms: u128,
1625}
1626
1627#[derive(Debug, Clone)]
1628pub struct IncrementalStats {
1629    pub changed_files: Vec<String>,
1630    pub surface_changed: Vec<String>,
1631    pub deleted_files: Vec<String>,
1632    pub dependency_selected_refs: usize,
1633    pub refreshed_own_files: usize,
1634    pub unchanged_extract_files: usize,
1635}
1636
1637/// Phase timings for the copy-based incremental refresh benchmark.
1638#[doc(hidden)]
1639#[derive(Debug, Clone, Default, PartialEq, Eq)]
1640pub struct RefreshFilesProfile {
1641    pub parse: Duration,
1642    pub dependency_selection: Duration,
1643    pub row_deletes: Duration,
1644    pub row_inserts: Duration,
1645    pub dependent_parse: Duration,
1646    pub index_load: Duration,
1647    pub ref_resolution: Duration,
1648    pub method_dispatch: Duration,
1649    pub commit: Duration,
1650    pub total: Duration,
1651}
1652
1653impl RefreshFilesProfile {
1654    pub fn report(&self) -> String {
1655        format!(
1656            "parse={}ms dependency_selection={}ms row_deletes={}ms row_inserts={}ms dependent_parse={}ms index_load={}ms ref_resolution={}ms method_dispatch={}ms commit={}ms total={}ms",
1657            self.parse.as_millis(),
1658            self.dependency_selection.as_millis(),
1659            self.row_deletes.as_millis(),
1660            self.row_inserts.as_millis(),
1661            self.dependent_parse.as_millis(),
1662            self.index_load.as_millis(),
1663            self.ref_resolution.as_millis(),
1664            self.method_dispatch.as_millis(),
1665            self.commit.as_millis(),
1666            self.total.as_millis(),
1667        )
1668    }
1669}
1670
1671#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1672pub struct StoredEdge {
1673    pub source_file: String,
1674    pub source_symbol: String,
1675    pub target_file: String,
1676    pub target_symbol: String,
1677    pub kind: String,
1678    pub line: u32,
1679}
1680
1681#[derive(Debug, Clone, PartialEq, Eq)]
1682pub struct StoreNode {
1683    node_id: String,
1684    pub file: String,
1685    pub symbol: String,
1686    pub name: String,
1687    pub kind: String,
1688    pub line: u32,
1689    pub end_line: u32,
1690    pub signature: Option<String>,
1691    pub exported: bool,
1692    pub is_entry_point: bool,
1693    pub lang: LangId,
1694}
1695
1696#[cfg(test)]
1697impl StoreNode {
1698    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1699        Self {
1700            node_id: format!("{file}:{symbol}"),
1701            file: file.to_string(),
1702            symbol: symbol.to_string(),
1703            name: symbol.to_string(),
1704            kind: "function".to_string(),
1705            line: 1,
1706            end_line: 1,
1707            signature: None,
1708            exported: is_entry_point,
1709            is_entry_point,
1710            lang: LangId::TypeScript,
1711        }
1712    }
1713}
1714
1715#[derive(Debug, Clone, PartialEq, Eq)]
1716pub struct StoreCallSite {
1717    pub caller: StoreNode,
1718    pub target_file: String,
1719    pub target_symbol: String,
1720    pub target: Option<StoreNode>,
1721    pub line: u32,
1722    pub byte_start: usize,
1723    pub byte_end: usize,
1724    pub resolved: bool,
1725    pub provenance: String,
1726}
1727
1728impl StoreCallSite {
1729    pub fn approximate(&self) -> bool {
1730        self.provenance == PROVENANCE_NAME_MATCH
1731    }
1732
1733    pub fn resolved_by(&self) -> &str {
1734        &self.provenance
1735    }
1736
1737    pub fn supplemental_resolution(&self) -> Option<&str> {
1738        match self.provenance.as_str() {
1739            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1740            _ => None,
1741        }
1742    }
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct StoreUnresolvedCall {
1747    pub caller: StoreNode,
1748    pub symbol: String,
1749    pub full_ref: Option<String>,
1750    pub line: u32,
1751    pub byte_start: usize,
1752    pub byte_end: usize,
1753}
1754
1755#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct StoreCallersResult {
1757    pub target: StoreNode,
1758    pub callers: Vec<StoreCallSite>,
1759    pub scanned_files: usize,
1760    pub depth_limited: bool,
1761    pub truncated: usize,
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1765pub struct StoreImpactCaller {
1766    pub site: StoreCallSite,
1767    pub signature: Option<String>,
1768    pub is_entry_point: bool,
1769    pub call_expression: Option<String>,
1770    pub parameters: Vec<String>,
1771}
1772
1773#[derive(Debug, Clone, PartialEq, Eq)]
1774pub struct StoreImpactResult {
1775    pub target: StoreNode,
1776    pub parameters: Vec<String>,
1777    pub callers: Vec<StoreImpactCaller>,
1778    pub depth_limited: bool,
1779    pub truncated: usize,
1780}
1781
1782#[derive(Debug, Clone)]
1783struct ExtractFailure {
1784    rel_path: String,
1785    freshness: Option<FileFreshness>,
1786}
1787
1788#[derive(Debug, Clone)]
1789struct BuildExtractsResult {
1790    extracts: Vec<FileExtract>,
1791    failures: Vec<ExtractFailure>,
1792}
1793
1794#[derive(Debug, Clone)]
1795enum StoreForwardCall {
1796    Resolved(StoreCallSite),
1797    Unresolved(StoreUnresolvedCall),
1798}
1799
1800impl StoreForwardCall {
1801    fn byte_start(&self) -> usize {
1802        match self {
1803            Self::Resolved(site) => site.byte_start,
1804            Self::Unresolved(call) => call.byte_start,
1805        }
1806    }
1807
1808    fn line(&self) -> u32 {
1809        match self {
1810            Self::Resolved(site) => site.line,
1811            Self::Unresolved(call) => call.line,
1812        }
1813    }
1814}
1815
1816#[derive(Debug, Clone)]
1817struct FileExtract {
1818    rel_path: String,
1819    freshness: FileFreshness,
1820    lang: LangId,
1821    data: FileCallData,
1822    nodes: Vec<NodeRecord>,
1823    raw_refs: Vec<RawRef>,
1824    dispatch_hints: Vec<DispatchHint>,
1825    surface_fingerprint: String,
1826}
1827
1828#[derive(Debug, Clone)]
1829struct NodeRecord {
1830    id: String,
1831    file_path: String,
1832    name: String,
1833    scoped_name: String,
1834    kind: String,
1835    range: Range,
1836    range_ordinal: u32,
1837    signature: Option<String>,
1838    exported: bool,
1839    is_default_export: bool,
1840    is_type_like: bool,
1841    is_callgraph_entry_point: bool,
1842}
1843
1844#[derive(Debug, Clone)]
1845struct RawRef {
1846    ref_id: String,
1847    caller_node: Option<String>,
1848    caller_symbol: Option<String>,
1849    caller_file: String,
1850    kind: String,
1851    short_name: Option<String>,
1852    full_ref: Option<String>,
1853    module_path: Option<String>,
1854    import_kind: Option<String>,
1855    local_name: Option<String>,
1856    requested_name: Option<String>,
1857    namespace_alias: Option<String>,
1858    wildcard: bool,
1859    line: u32,
1860    byte_start: usize,
1861    byte_end: usize,
1862    dependencies: BTreeSet<String>,
1863}
1864
1865#[derive(Debug, Clone)]
1866struct ResolvedRef {
1867    raw: RawRef,
1868    status: String,
1869    target_node: Option<String>,
1870    target_file: Option<String>,
1871    target_symbol: Option<String>,
1872    dependencies: BTreeSet<String>,
1873    edge: Option<EdgeRecord>,
1874}
1875
1876#[derive(Debug, Clone)]
1877struct EdgeRecord {
1878    edge_id: String,
1879    source_node: String,
1880    target_node: Option<String>,
1881    target_file: String,
1882    target_symbol: String,
1883    kind: String,
1884    line: u32,
1885}
1886
1887#[derive(Debug, Clone)]
1888struct DispatchHint {
1889    id: String,
1890    method_name: String,
1891    caller_node: String,
1892    file: String,
1893    line: u32,
1894    byte_start: usize,
1895    byte_end: usize,
1896}
1897
1898#[derive(Debug, Clone)]
1899struct NameMatchRef {
1900    ref_id: String,
1901    caller_node: String,
1902    caller_file: String,
1903    caller_symbol: String,
1904    caller_signature: Option<String>,
1905    receiver_expression: String,
1906    receiver: String,
1907    method_name: String,
1908    colon_dispatch: bool,
1909    line: u32,
1910    lang: String,
1911}
1912
1913#[derive(Debug, Clone)]
1914struct NameMatchCandidate {
1915    node_id: String,
1916    file_path: String,
1917    scoped_name: String,
1918    kind: String,
1919    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
1920    start_line: u32,
1921}
1922
1923#[derive(Debug, Clone)]
1924struct FileRow {
1925    surface_fingerprint: String,
1926    freshness: FileFreshness,
1927}
1928
1929#[derive(Debug, Clone)]
1930struct DbFileIndex {
1931    lang: Option<LangId>,
1932    exports: HashSet<String>,
1933    default_export: Option<String>,
1934    export_aliases: HashMap<String, String>,
1935    node_by_scoped: HashMap<String, String>,
1936    node_by_bare: HashMap<String, String>,
1937    module_targets: HashMap<String, Option<String>>,
1938    reexports: Vec<ReexportIndex>,
1939}
1940
1941#[derive(Debug, Clone)]
1942struct ReexportIndex {
1943    target_file: Option<String>,
1944    named: HashMap<String, String>,
1945    wildcard: bool,
1946}
1947
1948#[derive(Debug, Clone)]
1949struct ProjectIndex<'a> {
1950    project_root: PathBuf,
1951    files: HashMap<String, DbFileIndex>,
1952    caller_data: HashMap<String, &'a FileCallData>,
1953    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1954    /// watcher events replace the cache before another batch can resolve refs.
1955    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1956    /// its own workspace mapping.
1957    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1958}
1959
1960impl ProjectIndex<'_> {
1961    /// Resolve a crate name to its `src` prefix, building the workspace map on
1962    /// first use. Refresh-worker indexes for the same root share this cache.
1963    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1964        self.workspace_crate_prefixes
1965            .0
1966            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1967            .get(crate_name)
1968            .cloned()
1969    }
1970}
1971
1972impl CallGraphStore {
1973    pub fn open_if_enabled(
1974        options: CallGraphStoreOptions,
1975        callgraph_dir: PathBuf,
1976        project_root: PathBuf,
1977    ) -> Result<Option<Self>> {
1978        if !options.enabled {
1979            return Ok(None);
1980        }
1981        Self::open(callgraph_dir, project_root).map(Some)
1982    }
1983
1984    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1985        let project_key = crate::search_index::artifact_cache_key(&project_root);
1986        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1987        else {
1988            return Err(CallGraphStoreError::Unavailable(
1989                "writer capability denied; use the read-only callgraph opener".to_string(),
1990            ));
1991        };
1992        std::fs::create_dir_all(&callgraph_dir)?;
1993        // Resolve the current generation via the pointer (falling back to the
1994        // legacy single-file DB). If nothing is published yet, open the legacy
1995        // path so a brand-new store still gets a writable DB + schema.
1996        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1997            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1998        let OpenedStore { store, root_repair } = Self::open_at_path(
1999            project_root.clone(),
2000            project_key,
2001            sqlite_path,
2002            generation,
2003            true,
2004            Some(Arc::clone(&writer_lease)),
2005            None,
2006        )?;
2007        match root_repair {
2008            OpenRootRepair::NeedsRebuild { .. } => {
2009                log_root_repair_rebuild(&root_repair);
2010                drop(store);
2011                drop(writer_lease);
2012                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2013                let (store, _stats) =
2014                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2015                Ok(store)
2016            }
2017            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2018        }
2019    }
2020
2021    pub fn open_readonly(
2022        callgraph_dir: PathBuf,
2023        project_root: PathBuf,
2024    ) -> Result<Option<ReadonlyCallGraphStore>> {
2025        let project_key = crate::search_index::artifact_cache_key(&project_root);
2026        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2027        {
2028            let conn = open_readonly_connection(&sqlite_path)?;
2029            if !database_ready(&conn).unwrap_or(false) {
2030                return Ok(None);
2031            }
2032            let marker_label = generation.as_deref().unwrap_or("legacy");
2033            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2034            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2035                Self::from_connection(
2036                    project_root,
2037                    project_key,
2038                    sqlite_path,
2039                    callgraph_dir,
2040                    false,
2041                    generation,
2042                    None,
2043                    Some(read_marker),
2044                    conn,
2045                ),
2046            )));
2047        }
2048
2049        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2050            return Ok(None);
2051        };
2052        crate::slog_warn!(
2053            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2054            target.partition.harness,
2055            target.sqlite_path.display()
2056        );
2057        let conn = open_readonly_connection(&target.sqlite_path)?;
2058        if !database_ready(&conn).unwrap_or(false) {
2059            return Ok(None);
2060        }
2061        let marker_label =
2062            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2063        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2064        Ok(Some(ReadonlyCallGraphStore::from_inner(
2065            Self::from_connection(
2066                project_root,
2067                project_key,
2068                target.sqlite_path,
2069                callgraph_dir,
2070                true,
2071                target.generation,
2072                None,
2073                Some(read_marker),
2074                conn,
2075            ),
2076        )))
2077    }
2078
2079    /// Open the currently-published ready store with write access so moved-root
2080    /// metadata can be repaired before projection readers consume it. Unlike
2081    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2082    /// ready generation exists, it returns `Ok(None)` instead of creating an
2083    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2084    pub fn open_ready_repairing(
2085        callgraph_dir: PathBuf,
2086        project_root: PathBuf,
2087    ) -> Result<Option<Self>> {
2088        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2089    }
2090
2091    /// Open a ready store for bounded maintenance work without repairing root
2092    /// metadata or starting a cold rebuild. A store that needs either action is
2093    /// reported as unavailable so a background build can own that work.
2094    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2095        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2096    }
2097
2098    pub fn open_ready_no_rebuild(
2099        callgraph_dir: PathBuf,
2100        project_root: PathBuf,
2101    ) -> Result<Option<Self>> {
2102        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2103    }
2104
2105    fn open_ready_with_rebuild_policy(
2106        callgraph_dir: PathBuf,
2107        project_root: PathBuf,
2108        allow_cold_build: bool,
2109        allow_root_repair: bool,
2110    ) -> Result<Option<Self>> {
2111        let project_key = crate::search_index::artifact_cache_key(&project_root);
2112        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2113        else {
2114            return Ok(None);
2115        };
2116        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2117        else {
2118            return Ok(None);
2119        };
2120        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2121            project_root.clone(),
2122            project_key.clone(),
2123            sqlite_path,
2124            generation,
2125            true,
2126            Some(Arc::clone(&writer_lease)),
2127            None,
2128            allow_root_repair,
2129        )?;
2130        match root_repair {
2131            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2132                log_root_repair_rebuild(&root_repair);
2133                drop(store);
2134                drop(writer_lease);
2135                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2136                let (store, _stats) =
2137                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2138                Ok(Some(store))
2139            }
2140            OpenRootRepair::NeedsRebuild { .. } => {
2141                if let Some(message) = note_repair_entry(&project_key) {
2142                    crate::slog_warn!("{message}");
2143                }
2144                Ok(None)
2145            }
2146            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2147        }
2148    }
2149
2150    pub fn cold_build_with_lease(
2151        callgraph_dir: PathBuf,
2152        project_root: PathBuf,
2153        files: &[PathBuf],
2154    ) -> Result<(Self, ColdBuildStats)> {
2155        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2156    }
2157
2158    pub fn cold_build_with_lease_chunked(
2159        callgraph_dir: PathBuf,
2160        project_root: PathBuf,
2161        files: &[PathBuf],
2162        chunk_size: usize,
2163    ) -> Result<(Self, ColdBuildStats)> {
2164        Self::cold_build_with_lease_chunked_inner(
2165            callgraph_dir,
2166            project_root,
2167            files,
2168            chunk_size,
2169            false,
2170        )
2171    }
2172
2173    pub(crate) fn force_cold_build_with_lease_chunked(
2174        callgraph_dir: PathBuf,
2175        project_root: PathBuf,
2176        files: &[PathBuf],
2177        chunk_size: usize,
2178    ) -> Result<(Self, ColdBuildStats)> {
2179        Self::cold_build_with_lease_chunked_inner(
2180            callgraph_dir,
2181            project_root,
2182            files,
2183            chunk_size,
2184            true,
2185        )
2186    }
2187
2188    fn cold_build_with_lease_chunked_inner(
2189        callgraph_dir: PathBuf,
2190        project_root: PathBuf,
2191        files: &[PathBuf],
2192        chunk_size: usize,
2193        require_new_publication: bool,
2194    ) -> Result<(Self, ColdBuildStats)> {
2195        let project_key = crate::search_index::artifact_cache_key(&project_root);
2196        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2197        else {
2198            let operation = if require_new_publication {
2199                "forced rebuild"
2200            } else {
2201                "cold build"
2202            };
2203            return Err(CallGraphStoreError::Unavailable(format!(
2204                "{operation} could not acquire writer capability"
2205            )));
2206        };
2207        std::fs::create_dir_all(&callgraph_dir)?;
2208        let (stats, generation) = Self::cold_build_publish_locked(
2209            &callgraph_dir,
2210            &project_root,
2211            &project_key,
2212            files,
2213            chunk_size,
2214            Arc::clone(&writer_lease),
2215        )?;
2216        let store = Self::open_generation(
2217            &callgraph_dir,
2218            project_root,
2219            project_key,
2220            generation,
2221            writer_lease,
2222        )?;
2223        Ok((store, stats))
2224    }
2225
2226    pub fn ensure_built_with_lease(
2227        callgraph_dir: PathBuf,
2228        project_root: PathBuf,
2229        files: &[PathBuf],
2230    ) -> Result<(Self, Option<ColdBuildStats>)> {
2231        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2232    }
2233
2234    pub fn ensure_built_with_lease_chunked(
2235        callgraph_dir: PathBuf,
2236        project_root: PathBuf,
2237        files: &[PathBuf],
2238        chunk_size: usize,
2239    ) -> Result<(Self, Option<ColdBuildStats>)> {
2240        let project_key = crate::search_index::artifact_cache_key(&project_root);
2241        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2242        else {
2243            return Err(CallGraphStoreError::Unavailable(
2244                "callgraph ensure could not acquire writer capability".to_string(),
2245            ));
2246        };
2247        std::fs::create_dir_all(&callgraph_dir)?;
2248        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2249        // Another process may have published a ready generation while we waited
2250        // for the lock — open it instead of rebuilding. If that generation is
2251        // from this same project at an older filesystem root, repair the root
2252        // metadata in-place while still holding the build lease. If data rows
2253        // contain absolute paths, publish a fresh generation under this lease
2254        // rather than recursively reacquiring the same lock.
2255        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2256        {
2257            let OpenedStore { store, root_repair } = Self::open_at_path(
2258                project_root.clone(),
2259                project_key.clone(),
2260                sqlite_path,
2261                generation,
2262                true,
2263                Some(Arc::clone(&writer_lease)),
2264                None,
2265            )?;
2266            match root_repair {
2267                OpenRootRepair::NeedsRebuild { .. } => {
2268                    log_root_repair_rebuild(&root_repair);
2269                    drop(store);
2270                    let (stats, generation) = Self::cold_build_publish_locked(
2271                        &callgraph_dir,
2272                        &project_root,
2273                        &project_key,
2274                        files,
2275                        chunk_size,
2276                        Arc::clone(&writer_lease),
2277                    )?;
2278                    let store = Self::open_generation(
2279                        &callgraph_dir,
2280                        project_root,
2281                        project_key,
2282                        generation,
2283                        writer_lease,
2284                    )?;
2285                    return Ok((store, Some(stats)));
2286                }
2287                OpenRootRepair::None | OpenRootRepair::ReRooted => {
2288                    return Ok((store, None));
2289                }
2290            }
2291        }
2292        if let Some(store) = try_legacy_migration_or_fallback(
2293            &callgraph_dir,
2294            &project_root,
2295            &project_key,
2296            Arc::clone(&writer_lease),
2297        )? {
2298            return Ok((store, None));
2299        }
2300        let (stats, generation) = Self::cold_build_publish_locked(
2301            &callgraph_dir,
2302            &project_root,
2303            &project_key,
2304            files,
2305            chunk_size,
2306            Arc::clone(&writer_lease),
2307        )?;
2308        let store = Self::open_generation(
2309            &callgraph_dir,
2310            project_root,
2311            project_key,
2312            generation,
2313            writer_lease,
2314        )?;
2315        Ok((store, Some(stats)))
2316    }
2317
2318    /// Migrate a legacy harness-partition store without falling through to a
2319    /// cold build. This is used after a query has already opened a read-only
2320    /// fallback: the caller runs it on the same limited background lane as cold
2321    /// builds while queries continue using that fallback. Public so crash/retry
2322    /// tests can drive the migration synchronously on a thread where the
2323    /// thread-local failure seams apply.
2324    pub fn migrate_legacy_with_lease(
2325        callgraph_dir: PathBuf,
2326        project_root: PathBuf,
2327    ) -> Result<Option<Self>> {
2328        let project_key = crate::search_index::artifact_cache_key(&project_root);
2329        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2330        else {
2331            return Ok(None);
2332        };
2333        std::fs::create_dir_all(&callgraph_dir)?;
2334        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2335
2336        // Another writer may have completed the migration while this worker was
2337        // waiting for the lease. Adopt its root-keyed generation rather than
2338        // copying the legacy source a second time.
2339        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2340        {
2341            let OpenedStore { store, root_repair } = Self::open_at_path(
2342                project_root,
2343                project_key,
2344                sqlite_path,
2345                generation,
2346                true,
2347                Some(writer_lease),
2348                None,
2349            )?;
2350            return match root_repair {
2351                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2352                OpenRootRepair::NeedsRebuild { reason, .. } => {
2353                    Err(CallGraphStoreError::Unavailable(format!(
2354                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
2355                    )))
2356                }
2357            };
2358        }
2359
2360        let store = try_legacy_migration_or_fallback(
2361            &callgraph_dir,
2362            &project_root,
2363            &project_key,
2364            writer_lease,
2365        )?;
2366        // A disk-floor or backup-budget failure returns a readable legacy store.
2367        // Keep the already-resident fallback instead of sending this duplicate
2368        // reader through the background-install channel.
2369        Ok(store.filter(|store| !store.is_legacy_fallback()))
2370    }
2371
2372    /// Build a fresh DB and publish it as a new generation, then atomically flip
2373    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
2374    /// succeeds even when other processes hold an older generation open (the
2375    /// multi-TUI Windows case). The builder owns the temp + generation files
2376    /// exclusively (unique pid+nanos names), so it can rename/replace them
2377    /// freely; only the tiny pointer is shared, and only Rust std touches it.
2378    ///
2379    /// Returns the published generation file name so callers open exactly the
2380    /// generation they built (avoiding a race where a concurrent build's flip
2381    /// would otherwise reopen a different generation).
2382    fn cold_build_publish_locked(
2383        callgraph_dir: &Path,
2384        project_root: &Path,
2385        project_key: &str,
2386        files: &[PathBuf],
2387        chunk_size: usize,
2388        writer_lease: Arc<crate::root_cache::WriterLease>,
2389    ) -> Result<(ColdBuildStats, String)> {
2390        if let Some((previous_root, remaining)) =
2391            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
2392        {
2393            return Err(CallGraphStoreError::Unavailable(format!(
2394                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
2395                previous_root.display(),
2396                remaining.as_millis()
2397            )));
2398        }
2399        let generation = generation_file_name(project_key);
2400        let gen_path = callgraph_dir.join(&generation);
2401        let temp_path = callgraph_dir.join(format!(
2402            "{generation}.tmp.{}.{}",
2403            std::process::id(),
2404            now_nanos()
2405        ));
2406        remove_sqlite_file_set(&temp_path);
2407
2408        let stats = {
2409            let temp_store = Self::open_at_path(
2410                project_root.to_path_buf(),
2411                project_key.to_string(),
2412                temp_path.clone(),
2413                None,
2414                false,
2415                Some(Arc::clone(&writer_lease)),
2416                None,
2417            )?
2418            .store;
2419            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
2420            let _ = temp_store.checkpoint_wal_truncate();
2421            temp_store.prepare_for_atomic_swap()?;
2422            stats
2423        };
2424
2425        notify_cold_build_before_publish_observer();
2426        let publication = publish_if_current(|| {
2427            verify_writer_lease(&writer_lease)?;
2428            // Move the finished build to its final generation path. This target is
2429            // brand-new and owned by us, so the rename never hits an open file.
2430            remove_sqlite_file_set(&gen_path);
2431            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
2432            crate::fs_lock::sync_parent(&gen_path);
2433            remove_sqlite_sidecars(&gen_path);
2434
2435            notify_cold_build_swap_observer(&temp_path, &gen_path);
2436
2437            // Atomically publish the new generation, then best-effort GC old ones.
2438            verify_writer_lease(&writer_lease)?;
2439            publish_pointer(callgraph_dir, project_key, &generation)?;
2440            gc_old_generations(callgraph_dir, project_key, &generation);
2441            // Store-wide orphan sweep on the same cadence: reclaims aged build
2442            // temps for roots that no longer build here, which the per-root GC
2443            // above never reaches.
2444            sweep_orphaned_build_temps_store_wide(callgraph_dir);
2445            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
2446                let inspect_root =
2447                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
2448                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
2449                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
2450            }
2451            Ok(())
2452        });
2453        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
2454            remove_sqlite_file_set(&temp_path);
2455        }
2456        publication?;
2457        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
2458        Ok((stats, generation))
2459    }
2460
2461    /// Open a specific just-published generation (read-write, WAL) so a builder
2462    /// returns a store pinned to exactly what it built.
2463    fn open_generation(
2464        callgraph_dir: &Path,
2465        project_root: PathBuf,
2466        project_key: String,
2467        generation: String,
2468        writer_lease: Arc<crate::root_cache::WriterLease>,
2469    ) -> Result<Self> {
2470        let gen_path = callgraph_dir.join(&generation);
2471        Ok(Self::open_at_path(
2472            project_root,
2473            project_key,
2474            gen_path,
2475            Some(generation),
2476            true,
2477            Some(writer_lease),
2478            None,
2479        )?
2480        .store)
2481    }
2482
2483    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
2484        let project_key = crate::search_index::artifact_cache_key(project_root);
2485        // A cold build is needed unless a ready generation (or ready legacy DB)
2486        // is currently published.
2487        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
2488    }
2489
2490    fn open_at_path(
2491        project_root: PathBuf,
2492        project_key: String,
2493        sqlite_path: PathBuf,
2494        generation: Option<String>,
2495        use_wal: bool,
2496        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2497        read_marker: Option<crate::root_cache::ReadMarker>,
2498    ) -> Result<OpenedStore> {
2499        Self::open_at_path_with_root_repair(
2500            project_root,
2501            project_key,
2502            sqlite_path,
2503            generation,
2504            use_wal,
2505            writer_lease,
2506            read_marker,
2507            true,
2508        )
2509    }
2510
2511    fn open_at_path_with_root_repair(
2512        project_root: PathBuf,
2513        project_key: String,
2514        sqlite_path: PathBuf,
2515        generation: Option<String>,
2516        use_wal: bool,
2517        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2518        read_marker: Option<crate::root_cache::ReadMarker>,
2519        allow_root_repair: bool,
2520    ) -> Result<OpenedStore> {
2521        if let Some(lease) = writer_lease.as_ref() {
2522            verify_writer_lease(lease)?;
2523        }
2524        if let Some(parent) = sqlite_path.parent() {
2525            std::fs::create_dir_all(parent)?;
2526        }
2527        let mut conn = Connection::open(&sqlite_path)?;
2528        if use_wal {
2529            configure_connection(&conn)?;
2530        } else {
2531            configure_build_connection(&conn)?;
2532        }
2533        if let Some(lease) = writer_lease.as_ref() {
2534            verify_writer_lease(lease)?;
2535        }
2536        initialize_schema(&conn)?;
2537        if let Some(lease) = writer_lease.as_ref() {
2538            verify_writer_lease(lease)?;
2539        }
2540        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
2541        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
2542            (Some(marker), _, _) => Some(marker),
2543            (None, Some(label), Some(cache_dir)) => {
2544                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
2545            }
2546            (None, _, _) => None,
2547        };
2548        let publication_dir = sqlite_path
2549            .parent()
2550            .map(Path::to_path_buf)
2551            .unwrap_or_default();
2552        let store = Self::from_connection(
2553            project_root,
2554            project_key,
2555            sqlite_path,
2556            publication_dir,
2557            false,
2558            generation,
2559            writer_lease,
2560            read_marker,
2561            conn,
2562        );
2563        Ok(OpenedStore { store, root_repair })
2564    }
2565
2566    fn prepare_for_atomic_swap(&self) -> Result<()> {
2567        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2568        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2569        Ok(())
2570    }
2571
2572    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2573        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2574            self.sqlite_path
2575                .parent()
2576                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2577        });
2578        if protected_reader {
2579            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2580        } else {
2581            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2582        }
2583    }
2584
2585    fn from_connection(
2586        project_root: PathBuf,
2587        project_key: String,
2588        sqlite_path: PathBuf,
2589        publication_dir: PathBuf,
2590        legacy_fallback: bool,
2591        generation: Option<String>,
2592        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2593        read_marker: Option<crate::root_cache::ReadMarker>,
2594        conn: Connection,
2595    ) -> Self {
2596        let write_metrics = callgraph_write_metrics_for_key(&project_key);
2597        Self {
2598            project_root,
2599            project_key,
2600            sqlite_path,
2601            publication_dir,
2602            legacy_fallback,
2603            generation,
2604            writer_lease,
2605            read_marker,
2606            database_ready: AtomicBool::new(false),
2607            write_metrics,
2608            conn: Mutex::new(conn),
2609        }
2610    }
2611
2612    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2613        if self.database_ready.load(AtomicOrdering::Acquire) {
2614            return Ok(());
2615        }
2616        ensure_database_ready(conn)?;
2617        self.database_ready.store(true, AtomicOrdering::Release);
2618        Ok(())
2619    }
2620
2621    pub fn project_root(&self) -> &Path {
2622        &self.project_root
2623    }
2624
2625    pub fn project_key(&self) -> &str {
2626        &self.project_key
2627    }
2628
2629    pub fn sqlite_path(&self) -> &Path {
2630        &self.sqlite_path
2631    }
2632
2633    /// Whether this store is reading from a legacy harness partition because
2634    /// the root-keyed store has not published a generation yet.
2635    pub fn is_legacy_fallback(&self) -> bool {
2636        self.legacy_fallback
2637    }
2638
2639    pub(crate) fn is_legacy_migration(&self) -> bool {
2640        self.generation.as_deref().is_some_and(|generation| {
2641            migration_generation_requires_manifest(generation)
2642                && migration_manifest_valid(&self.publication_dir, generation)
2643        })
2644    }
2645
2646    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2647        self.writer_lease.as_ref().map(|lease| lease.epoch())
2648    }
2649
2650    fn verify_writer_lease(&self) -> Result<()> {
2651        let Some(lease) = self.writer_lease.as_ref() else {
2652            return Err(CallGraphStoreError::Unavailable(
2653                "callgraph store opened read-only; write API is unavailable".to_string(),
2654            ));
2655        };
2656        verify_writer_lease(lease)
2657    }
2658
2659    fn refresh_read_marker(&self) -> Result<()> {
2660        if let Some(marker) = self.read_marker.as_ref() {
2661            marker.touch_if_due()?;
2662        }
2663        Ok(())
2664    }
2665
2666    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
2667        self.write_metrics
2668            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
2669    }
2670
2671    fn checkpoint_wal_truncate(&self) -> bool {
2672        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2673        checkpoint_wal_truncate(&conn)
2674    }
2675
2676    /// True if this store still reflects the currently-published generation.
2677    /// Cheap (one small pointer-file read). When false, another process (or a
2678    /// local cold rebuild) has published a newer generation and the holder
2679    /// should drop this store and reopen via the pointer to converge. A missing
2680    /// pointer keeps the current store (legacy DB still valid, or transient).
2681    pub fn is_current(&self) -> bool {
2682        let _ = self.refresh_read_marker();
2683        match (
2684            read_pointer(&self.publication_dir, &self.project_key),
2685            &self.generation,
2686        ) {
2687            // Even when both generations happen to have the same filename, the
2688            // root-keyed pointer names a different directory from the fallback.
2689            (Some(_), _) if self.legacy_fallback => false,
2690            (Some(published), Some(opened)) => &published == opened,
2691            // A generation now supersedes the legacy single-file DB we opened.
2692            (Some(_), None) => false,
2693            // No pointer: keep serving (legacy DB, or an anomalous pointer
2694            // removal where our open generation file is still valid).
2695            (None, _) => true,
2696        }
2697    }
2698
2699    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2700        self.cold_build_chunked(files, 0)
2701    }
2702
2703    pub fn cold_build_chunked(
2704        &self,
2705        files: &[PathBuf],
2706        chunk_size: usize,
2707    ) -> Result<ColdBuildStats> {
2708        let started = Instant::now();
2709        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2710        macro_rules! phase {
2711            ($label:expr, $t:expr) => {
2712                if bench {
2713                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2714                    let _ = std::io::Write::flush(&mut std::io::stderr());
2715                }
2716            };
2717        }
2718        let files = normalize_file_list(&self.project_root, files)?;
2719
2720        if chunk_size == 0 {
2721            let t = Instant::now();
2722            let build = build_extracts_parallel(&self.project_root, &files);
2723            phase!("extract_parallel", t);
2724            let extracts = build.extracts;
2725            let failures = build.failures;
2726            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2727
2728            let t = Instant::now();
2729            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2730            phase!("build_index", t);
2731            let t = Instant::now();
2732            let mut resolved_refs = Vec::new();
2733            for extract in &extracts {
2734                for raw_ref in &extract.raw_refs {
2735                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2736                }
2737            }
2738            phase!("resolve_refs", t);
2739            let ref_count = resolved_refs.len();
2740            let edge_count = resolved_refs
2741                .iter()
2742                .filter(|item| item.edge.is_some())
2743                .count();
2744
2745            let t = Instant::now();
2746            self.verify_writer_lease()?;
2747            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2748            let total_changes_before = conn.total_changes();
2749            let tx = conn.transaction()?;
2750            clear_tables(&tx)?;
2751            insert_meta(&tx)?;
2752            drop_cold_build_secondary_indexes(&tx)?;
2753            {
2754                let workspace_root = self.project_root.display().to_string();
2755                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2756                for extract in &extracts {
2757                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2758                }
2759                for failure in &failures {
2760                    insert_backend_state_prepared(
2761                        &mut inserts.backend_state,
2762                        &workspace_root,
2763                        &failure.rel_path,
2764                        failure
2765                            .freshness
2766                            .as_ref()
2767                            .map(|freshness| &freshness.content_hash),
2768                        "stale",
2769                    )?;
2770                }
2771                for resolved in &resolved_refs {
2772                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2773                }
2774            }
2775            create_cold_build_secondary_indexes(&tx)?;
2776            let supplemental_edge_count =
2777                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2778            set_meta_ready(&tx, true)?;
2779            tx.commit()?;
2780            self.record_commit(total_changes_before, &conn);
2781            phase!("sqlite_insert", t);
2782
2783            let elapsed_ms = started.elapsed().as_millis();
2784            crate::slog_info!(
2785                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2786                extracts.len(),
2787                node_count,
2788                ref_count,
2789                edge_count + supplemental_edge_count,
2790                elapsed_ms
2791            );
2792            return Ok(ColdBuildStats {
2793                files: extracts.len(),
2794                nodes: node_count,
2795                refs: ref_count,
2796                edges: edge_count + supplemental_edge_count,
2797                failed_files: failures
2798                    .into_iter()
2799                    .map(|failure| failure.rel_path)
2800                    .collect(),
2801                elapsed_ms,
2802            });
2803        }
2804
2805        // Chunked implementation: parse and resolve in batches to reduce peak
2806        // memory during cold build without changing the persisted graph.
2807        let t = Instant::now();
2808        self.verify_writer_lease()?;
2809        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2810        let total_changes_before = conn.total_changes();
2811        let tx = conn.transaction()?;
2812        clear_tables(&tx)?;
2813        insert_meta(&tx)?;
2814        drop_cold_build_secondary_indexes(&tx)?;
2815
2816        let mut all_raw_refs = Vec::new();
2817        let mut failures = Vec::new();
2818        let mut node_count = 0;
2819        let mut files_parsed = 0;
2820
2821        let mut persistent_call_data = Vec::new();
2822        let mut file_to_call_data_index = HashMap::new();
2823        let mut files_index = HashMap::new();
2824
2825        let workspace_root = self.project_root.display().to_string();
2826
2827        {
2828            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2829            for chunk in files.chunks(chunk_size) {
2830                let build = build_extracts_parallel(&self.project_root, chunk);
2831                failures.extend(build.failures.clone());
2832
2833                for extract in build.extracts {
2834                    files_parsed += 1;
2835                    node_count += extract.nodes.len();
2836                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2837
2838                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2839                    files_index.insert(extract.rel_path.clone(), db_file_index);
2840
2841                    persistent_call_data.push(extract.data);
2842                    let idx = persistent_call_data.len() - 1;
2843                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2844
2845                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2846                }
2847                for failure in &build.failures {
2848                    insert_backend_state_prepared(
2849                        &mut inserts.backend_state,
2850                        &workspace_root,
2851                        &failure.rel_path,
2852                        failure
2853                            .freshness
2854                            .as_ref()
2855                            .map(|freshness| &freshness.content_hash),
2856                        "stale",
2857                    )?;
2858                }
2859            }
2860        }
2861
2862        let mut caller_data = HashMap::new();
2863        for (rel_path, idx) in &file_to_call_data_index {
2864            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2865        }
2866        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2867        let index = ProjectIndex::from_parts(
2868            &self.project_root,
2869            files_index,
2870            caller_data,
2871            WorkspaceCratePrefixCache::default(),
2872        );
2873
2874        let mut resolved_refs = Vec::new();
2875        for (_, raw_refs) in all_raw_refs {
2876            for raw_ref in raw_refs {
2877                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2878            }
2879        }
2880
2881        let ref_count = resolved_refs.len();
2882        let edge_count = resolved_refs
2883            .iter()
2884            .filter(|item| item.edge.is_some())
2885            .count();
2886
2887        {
2888            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2889            for resolved in &resolved_refs {
2890                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2891            }
2892        }
2893        create_cold_build_secondary_indexes(&tx)?;
2894        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2895            &tx,
2896            &self.project_root,
2897            &indexed_caller_files,
2898            chunk_size,
2899        )?;
2900        set_meta_ready(&tx, true)?;
2901        tx.commit()?;
2902        self.record_commit(total_changes_before, &conn);
2903        phase!("sqlite_insert", t);
2904
2905        let elapsed_ms = started.elapsed().as_millis();
2906        crate::slog_info!(
2907            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2908            files_parsed,
2909            node_count,
2910            ref_count,
2911            edge_count + supplemental_edge_count,
2912            elapsed_ms
2913        );
2914        Ok(ColdBuildStats {
2915            files: files_parsed,
2916            nodes: node_count,
2917            refs: ref_count,
2918            edges: edge_count + supplemental_edge_count,
2919            failed_files: failures
2920                .into_iter()
2921                .map(|failure| failure.rel_path)
2922                .collect(),
2923            elapsed_ms,
2924        })
2925    }
2926
2927    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2928        self.refresh_files_with_workspace_crate_prefix_cache(
2929            changed_files,
2930            WorkspaceCratePrefixCache::default(),
2931        )
2932    }
2933
2934    fn refresh_files_with_workspace_crate_prefix_cache(
2935        &self,
2936        changed_files: &[PathBuf],
2937        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2938    ) -> Result<IncrementalStats> {
2939        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2940            changed_files,
2941            workspace_crate_prefixes,
2942        )?;
2943        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2944            eprintln!("refresh_files phases: {}", profile.report());
2945        }
2946        Ok(stats)
2947    }
2948
2949    /// Run an incremental refresh and return phase timings for an offline store copy.
2950    #[doc(hidden)]
2951    pub fn refresh_files_profiled(
2952        &self,
2953        changed_files: &[PathBuf],
2954    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2955        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2956            changed_files,
2957            WorkspaceCratePrefixCache::default(),
2958        )
2959    }
2960
2961    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2962        &self,
2963        changed_files: &[PathBuf],
2964        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2965    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2966        let total_started = Instant::now();
2967        let mut profile = RefreshFilesProfile::default();
2968        self.verify_writer_lease()?;
2969        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2970        let total_changes_before = conn.total_changes();
2971        let tx = conn.transaction()?;
2972        ensure_database_ready(&tx)?;
2973        let mut changed = Vec::new();
2974        let mut surface_changed = BTreeSet::new();
2975        let mut deleted = BTreeSet::new();
2976        let mut own_refresh = BTreeSet::new();
2977        let mut candidate_own_refresh = BTreeSet::new();
2978        let mut unchanged_extracts = 0usize;
2979        let mut selected_ref_ids = BTreeSet::new();
2980        let mut selected_refs_by_caller = BTreeMap::new();
2981        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2982
2983        for input in changed_files {
2984            let abs_path = normalize_file_path(&self.project_root, input)?;
2985            let rel_path = relative_path(&self.project_root, &abs_path);
2986            changed.push(rel_path.clone());
2987            let old_row = load_file_row(&tx, &rel_path)?;
2988            if !abs_path.exists() {
2989                if old_row.is_some() {
2990                    surface_changed.insert(rel_path.clone());
2991                    deleted.insert(rel_path.clone());
2992                    let started = Instant::now();
2993                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2994                    profile.dependency_selection += started.elapsed();
2995                    record_dependent_refs(
2996                        &mut selected_ref_ids,
2997                        &mut selected_refs_by_caller,
2998                        dependent_refs,
2999                    );
3000                    let started = Instant::now();
3001                    delete_file_rows(&tx, &rel_path)?;
3002                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3003                    profile.row_deletes += started.elapsed();
3004                }
3005                continue;
3006            }
3007
3008            if let Some(row) = &old_row {
3009                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3010                    FreshnessVerdict::HotFresh => continue,
3011                    FreshnessVerdict::ContentFresh {
3012                        new_mtime,
3013                        new_size,
3014                    } => {
3015                        update_file_fresh_metadata(
3016                            &tx,
3017                            &self.project_root,
3018                            &rel_path,
3019                            &row.freshness.content_hash,
3020                            new_mtime,
3021                            new_size,
3022                        )?;
3023                        continue;
3024                    }
3025                    FreshnessVerdict::Deleted => {
3026                        surface_changed.insert(rel_path.clone());
3027                        deleted.insert(rel_path.clone());
3028                        let started = Instant::now();
3029                        let dependent_refs =
3030                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3031                        profile.dependency_selection += started.elapsed();
3032                        record_dependent_refs(
3033                            &mut selected_ref_ids,
3034                            &mut selected_refs_by_caller,
3035                            dependent_refs,
3036                        );
3037                        let started = Instant::now();
3038                        delete_file_rows(&tx, &rel_path)?;
3039                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
3040                        profile.row_deletes += started.elapsed();
3041                        continue;
3042                    }
3043                    FreshnessVerdict::Stale => {}
3044                }
3045            }
3046
3047            let started = Instant::now();
3048            let extract = build_file_extract(&self.project_root, &abs_path)?;
3049            profile.parse += started.elapsed();
3050            let surface_is_changed = old_row
3051                .as_ref()
3052                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
3053                .unwrap_or(true);
3054            if surface_is_changed {
3055                surface_changed.insert(rel_path.clone());
3056                let started = Instant::now();
3057                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
3058                profile.dependency_selection += started.elapsed();
3059                record_dependent_refs(
3060                    &mut selected_ref_ids,
3061                    &mut selected_refs_by_caller,
3062                    dependent_refs,
3063                );
3064            }
3065            candidate_own_refresh.insert(rel_path.clone());
3066            changed_extracts.insert(rel_path, extract);
3067        }
3068
3069        let dependency_selected_refs = selected_ref_ids.len();
3070        let mut touched_callers: BTreeSet<String> =
3071            selected_refs_by_caller.keys().cloned().collect();
3072        touched_callers.extend(candidate_own_refresh.iter().cloned());
3073
3074        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
3075        for rel_path in &touched_callers {
3076            if deleted.contains(rel_path) {
3077                continue;
3078            }
3079            if let Some(extract) = changed_extracts.get(rel_path) {
3080                caller_extracts.insert(rel_path.clone(), extract.clone());
3081                continue;
3082            }
3083            let abs_path = self.project_root.join(rel_path);
3084            if abs_path.exists() {
3085                let started = Instant::now();
3086                let extract = build_file_extract(&self.project_root, &abs_path)?;
3087                profile.dependent_parse += started.elapsed();
3088                caller_extracts.insert(rel_path.clone(), extract);
3089            }
3090        }
3091
3092        let started = Instant::now();
3093        let index = ProjectIndex::from_db_and_callers(
3094            &tx,
3095            &self.project_root,
3096            &caller_extracts,
3097            workspace_crate_prefixes,
3098        )?;
3099        profile.index_load += started.elapsed();
3100
3101        for rel_path in &candidate_own_refresh {
3102            let Some(extract) = changed_extracts.get(rel_path) else {
3103                continue;
3104            };
3105            if !write_amplification_baseline_enabled()
3106                && stored_extract_matches(&tx, rel_path, extract, &index)?
3107            {
3108                unchanged_extracts += 1;
3109                update_file_fresh_metadata(
3110                    &tx,
3111                    &self.project_root,
3112                    rel_path,
3113                    &extract.freshness.content_hash,
3114                    extract.freshness.mtime,
3115                    extract.freshness.size,
3116                )?;
3117                continue;
3118            }
3119
3120            own_refresh.insert(rel_path.clone());
3121            let started = Instant::now();
3122            delete_file_rows(&tx, rel_path)?;
3123            profile.row_deletes += started.elapsed();
3124            let started = Instant::now();
3125            insert_file_extract(&tx, &self.project_root, extract)?;
3126            profile.row_inserts += started.elapsed();
3127        }
3128
3129        let dependency_callers = touched_callers
3130            .iter()
3131            .filter(|rel_path| {
3132                !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
3133            })
3134            .cloned()
3135            .collect::<Vec<_>>();
3136        for rel_path in dependency_callers {
3137            let Some(extract) = caller_extracts.get(&rel_path) else {
3138                continue;
3139            };
3140            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
3141                continue;
3142            }
3143
3144            own_refresh.insert(rel_path.clone());
3145            let started = Instant::now();
3146            delete_file_rows(&tx, &rel_path)?;
3147            profile.row_deletes += started.elapsed();
3148            let started = Instant::now();
3149            insert_file_extract(&tx, &self.project_root, extract)?;
3150            profile.row_inserts += started.elapsed();
3151        }
3152        let started = Instant::now();
3153        for rel_path in &touched_callers {
3154            if deleted.contains(rel_path) {
3155                continue;
3156            }
3157            let Some(extract) = caller_extracts.get(rel_path) else {
3158                continue;
3159            };
3160            if own_refresh.contains(rel_path) {
3161                delete_refs_for_caller(&tx, rel_path)?;
3162                for raw_ref in &extract.raw_refs {
3163                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3164                    insert_resolved_ref(&tx, &resolved)?;
3165                }
3166                continue;
3167            }
3168
3169            let selected_for_caller = selected_refs_by_caller
3170                .get(rel_path)
3171                .cloned()
3172                .unwrap_or_default();
3173            delete_ref_ids(&tx, &selected_for_caller)?;
3174            for raw_ref in &extract.raw_refs {
3175                if selected_for_caller.contains(&raw_ref.ref_id) {
3176                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
3177                    insert_resolved_ref(&tx, &resolved)?;
3178                }
3179            }
3180        }
3181        profile.ref_resolution += started.elapsed();
3182
3183        let started = Instant::now();
3184        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
3185        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
3186        profile.method_dispatch += started.elapsed();
3187
3188        let started = Instant::now();
3189        commit_incremental_if_current(tx)?;
3190        self.record_commit(total_changes_before, &conn);
3191        profile.commit += started.elapsed();
3192        profile.total = total_started.elapsed();
3193        Ok((
3194            IncrementalStats {
3195                changed_files: changed,
3196                surface_changed: surface_changed.into_iter().collect(),
3197                deleted_files: deleted.into_iter().collect(),
3198                dependency_selected_refs,
3199                refreshed_own_files: own_refresh.len(),
3200                unchanged_extract_files: unchanged_extracts,
3201            },
3202            profile,
3203        ))
3204    }
3205
3206    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
3207        self.cold_build(current_files)
3208    }
3209
3210    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
3211        self.verify_writer_lease()?;
3212        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3213        let total_changes_before = conn.total_changes();
3214        let tx = conn.transaction()?;
3215        let mut marked = Vec::new();
3216        for path in files {
3217            let abs_path = normalize_file_path(&self.project_root, path)?;
3218            let rel_path = relative_path(&self.project_root, &abs_path);
3219            let freshness = cache_freshness::collect(&abs_path).ok();
3220            mark_backend_state(
3221                &tx,
3222                &self.project_root,
3223                &rel_path,
3224                freshness.as_ref().map(|freshness| &freshness.content_hash),
3225                "stale",
3226            )?;
3227            marked.push(rel_path);
3228        }
3229        tx.commit()?;
3230        self.record_commit(total_changes_before, &conn);
3231        marked.sort();
3232        marked.dedup();
3233        Ok(marked)
3234    }
3235
3236    pub fn stale_files(&self) -> Result<Vec<String>> {
3237        self.refresh_read_marker()?;
3238        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3239        let mut stmt = conn.prepare(
3240            "SELECT DISTINCT file_path FROM backend_file_state
3241             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
3242             ORDER BY file_path",
3243        )?;
3244        let rows = stmt.query_map(
3245            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
3246            |row| row.get::<_, String>(0),
3247        )?;
3248        rows.collect::<std::result::Result<Vec<_>, _>>()
3249            .map_err(Into::into)
3250    }
3251
3252    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
3253        self.refresh_read_marker()?;
3254        let rel_path = relative_path(
3255            &self.project_root,
3256            &normalize_file_path(&self.project_root, file)?,
3257        );
3258        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3259        conn.query_row(
3260            "SELECT status FROM backend_file_state
3261             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
3262             ORDER BY updated_at DESC LIMIT 1",
3263            params![
3264                BACKEND_TREESITTER,
3265                self.project_root.display().to_string(),
3266                rel_path
3267            ],
3268            |row| row.get(0),
3269        )
3270        .optional()
3271        .map_err(Into::into)
3272    }
3273
3274    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3275        self.refresh_read_marker()?;
3276        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3277        self.ensure_ready(&conn)?;
3278        edge_snapshot_with_conn(&conn)
3279    }
3280
3281    pub fn indexed_file_count(&self) -> Result<usize> {
3282        self.refresh_read_marker()?;
3283        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3284        self.ensure_ready(&conn)?;
3285        indexed_file_count(&conn)
3286    }
3287
3288    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3289        self.refresh_read_marker()?;
3290        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3291        let rel_path = relative_path(&self.project_root, &abs_path);
3292        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3293        self.ensure_ready(&conn)?;
3294        resolve_node_for_rel(&conn, &rel_path, symbol)
3295    }
3296
3297    /// Return all positional nodes matching a legacy symbol query in a file.
3298    ///
3299    /// Consumers that need legacy compatibility can collapse these by
3300    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
3301    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3302        self.refresh_read_marker()?;
3303        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3304        let rel_path = relative_path(&self.project_root, &abs_path);
3305        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3306        self.ensure_ready(&conn)?;
3307        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
3308    }
3309
3310    /// Return all positional nodes matching a symbol query anywhere in the store.
3311    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3312        self.refresh_read_marker()?;
3313        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3314        self.ensure_ready(&conn)?;
3315        nodes_matching_symbol(&conn, symbol)
3316    }
3317
3318    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
3319    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3320        self.refresh_read_marker()?;
3321        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
3322        let rel_path = relative_path(&self.project_root, &abs_path);
3323        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3324        self.ensure_ready(&conn)?;
3325        direct_callers_for_tuple(&conn, &rel_path, symbol)
3326    }
3327
3328    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
3329    pub fn direct_caller_counts_of(
3330        &self,
3331        targets: &[(String, String)],
3332    ) -> Result<HashMap<(String, String), usize>> {
3333        if targets.is_empty() {
3334            return Ok(HashMap::new());
3335        }
3336        self.refresh_read_marker()?;
3337        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3338        self.ensure_ready(&conn)?;
3339        direct_caller_counts_for_tuples(&conn, targets)
3340    }
3341
3342    pub fn callers_of(
3343        &self,
3344        file_rel: &Path,
3345        symbol: &str,
3346        depth: usize,
3347    ) -> Result<StoreCallersResult> {
3348        let target = self.node_for(file_rel, symbol)?;
3349        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3350        self.ensure_ready(&conn)?;
3351        let effective_depth = depth.max(1);
3352        let mut visited = HashSet::new();
3353        let mut callers = Vec::new();
3354        let mut depth_limited = false;
3355        let mut truncated = 0usize;
3356        collect_callers_recursive(
3357            &conn,
3358            &target.file,
3359            &target.symbol,
3360            effective_depth,
3361            0,
3362            &mut visited,
3363            &mut callers,
3364            &mut depth_limited,
3365            &mut truncated,
3366        )?;
3367        Ok(StoreCallersResult {
3368            target,
3369            callers,
3370            scanned_files: indexed_file_count(&conn)?,
3371            depth_limited,
3372            truncated,
3373        })
3374    }
3375
3376    pub fn impact_of(
3377        &self,
3378        file_rel: &Path,
3379        symbol: &str,
3380        depth: usize,
3381    ) -> Result<StoreImpactResult> {
3382        let callers = self.callers_of(file_rel, symbol, depth)?;
3383        let target_parameters = callers
3384            .target
3385            .signature
3386            .as_deref()
3387            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
3388            .unwrap_or_default();
3389        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
3390        for site in &callers.callers {
3391            source_lines_by_file
3392                .entry(site.caller.file.clone())
3393                .or_insert_with(|| {
3394                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
3395                });
3396        }
3397        let enriched = callers
3398            .callers
3399            .iter()
3400            .map(|site| StoreImpactCaller {
3401                site: site.clone(),
3402                signature: site.caller.signature.clone(),
3403                is_entry_point: site.caller.is_entry_point,
3404                call_expression: source_lines_by_file
3405                    .get(&site.caller.file)
3406                    .and_then(|lines| lines.as_ref())
3407                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
3408                    .cloned(),
3409                parameters: site
3410                    .caller
3411                    .signature
3412                    .as_deref()
3413                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
3414                    .unwrap_or_default(),
3415            })
3416            .collect();
3417        Ok(StoreImpactResult {
3418            target: callers.target,
3419            parameters: target_parameters,
3420            callers: enriched,
3421            depth_limited: callers.depth_limited,
3422            truncated: callers.truncated,
3423        })
3424    }
3425
3426    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3427        self.refresh_read_marker()?;
3428        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3429        self.ensure_ready(&conn)?;
3430        outgoing_calls_for_node(&conn, node)
3431    }
3432
3433    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
3434    pub fn outgoing_calls_for_symbols(
3435        &self,
3436        sources: &[(String, String)],
3437    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3438        if sources.is_empty() {
3439            return Ok(HashMap::new());
3440        }
3441        self.refresh_read_marker()?;
3442        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3443        self.ensure_ready(&conn)?;
3444        outgoing_calls_for_symbol_tuples(&conn, sources)
3445    }
3446
3447    /// Return resolved direct self-call refs suppressed from the general edge table.
3448    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3449        self.refresh_read_marker()?;
3450        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3451        self.ensure_ready(&conn)?;
3452        resolved_self_calls_for_node(&conn, node)
3453    }
3454
3455    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3456        self.refresh_read_marker()?;
3457        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3458        self.ensure_ready(&conn)?;
3459        unresolved_calls_for_node(&conn, node)
3460    }
3461
3462    pub fn call_tree(
3463        &self,
3464        file_rel: &Path,
3465        symbol: &str,
3466        max_depth: usize,
3467    ) -> Result<callgraph::CallTreeNode> {
3468        let node = self.node_for(file_rel, symbol)?;
3469        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3470        self.ensure_ready(&conn)?;
3471        let mut visited = HashSet::new();
3472        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
3473    }
3474
3475    pub fn trace_to(
3476        &self,
3477        file_rel: &Path,
3478        symbol: &str,
3479        max_depth: usize,
3480    ) -> Result<callgraph::TraceToResult> {
3481        let target = self.node_for(file_rel, symbol)?;
3482        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3483        self.ensure_ready(&conn)?;
3484        let effective_max = if max_depth == 0 { 10 } else { max_depth };
3485
3486        #[derive(Clone)]
3487        struct PathElem {
3488            node: StoreNode,
3489        }
3490
3491        let initial = vec![PathElem {
3492            node: target.clone(),
3493        }];
3494        let mut complete_paths = Vec::new();
3495        if target.is_entry_point {
3496            complete_paths.push(initial.clone());
3497        }
3498
3499        let mut queue = vec![(initial, 0usize)];
3500        let mut max_depth_reached = false;
3501        let mut truncated_paths = 0usize;
3502
3503        while let Some((path, depth)) = queue.pop() {
3504            if depth >= effective_max {
3505                max_depth_reached = true;
3506                continue;
3507            }
3508            let Some(current) = path.last() else {
3509                continue;
3510            };
3511            let callers =
3512                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
3513            if callers.is_empty() {
3514                if path.len() > 1 {
3515                    truncated_paths += 1;
3516                }
3517                continue;
3518            }
3519
3520            let mut has_new_path = false;
3521            for site in callers {
3522                if path.iter().any(|elem| {
3523                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
3524                }) {
3525                    continue;
3526                }
3527                has_new_path = true;
3528                let mut new_path = path.clone();
3529                new_path.push(PathElem {
3530                    node: site.caller.clone(),
3531                });
3532                if site.caller.is_entry_point {
3533                    complete_paths.push(new_path.clone());
3534                }
3535                queue.push((new_path, depth + 1));
3536            }
3537            if !has_new_path && path.len() > 1 {
3538                truncated_paths += 1;
3539            }
3540        }
3541
3542        let mut paths: Vec<callgraph::TracePath> = complete_paths
3543            .into_iter()
3544            .map(|mut elems| {
3545                elems.reverse();
3546                let hops = elems
3547                    .iter()
3548                    .enumerate()
3549                    .map(|(index, elem)| callgraph::TraceHop {
3550                        symbol: elem.node.symbol.clone(),
3551                        file: elem.node.file.clone(),
3552                        line: elem.node.line,
3553                        signature: elem.node.signature.clone(),
3554                        is_entry_point: index == 0 && elem.node.is_entry_point,
3555                    })
3556                    .collect();
3557                callgraph::TracePath { hops }
3558            })
3559            .collect();
3560        paths.sort_by(|left, right| {
3561            let left_entry = left
3562                .hops
3563                .first()
3564                .map(|hop| hop.symbol.as_str())
3565                .unwrap_or("");
3566            let right_entry = right
3567                .hops
3568                .first()
3569                .map(|hop| hop.symbol.as_str())
3570                .unwrap_or("");
3571            left_entry
3572                .cmp(right_entry)
3573                .then(left.hops.len().cmp(&right.hops.len()))
3574        });
3575        let entry_points_found = paths
3576            .iter()
3577            .filter_map(|path| path.hops.first())
3578            .filter(|hop| hop.is_entry_point)
3579            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
3580            .collect::<HashSet<_>>()
3581            .len();
3582
3583        Ok(callgraph::TraceToResult {
3584            target_symbol: target.symbol,
3585            target_file: target.file,
3586            total_paths: paths.len(),
3587            paths,
3588            entry_points_found,
3589            max_depth_reached,
3590            truncated_paths,
3591        })
3592    }
3593
3594    pub fn trace_to_symbol_candidates(
3595        &self,
3596        to_symbol: &str,
3597    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3598        self.refresh_read_marker()?;
3599        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3600        self.ensure_ready(&conn)?;
3601        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3602        for node in nodes_matching_symbol(&conn, to_symbol)? {
3603            candidates_by_file
3604                .entry(node.file)
3605                .and_modify(|line| *line = (*line).min(node.line))
3606                .or_insert(node.line);
3607        }
3608        let mut candidates: Vec<_> = candidates_by_file
3609            .into_iter()
3610            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3611            .collect();
3612        candidates
3613            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3614        Ok(candidates)
3615    }
3616
3617    pub fn trace_to_symbol(
3618        &self,
3619        file_rel: &Path,
3620        symbol: &str,
3621        to_symbol: &str,
3622        to_file: Option<&Path>,
3623        max_depth: usize,
3624    ) -> Result<callgraph::TraceToSymbolResult> {
3625        let origin = self.node_for(file_rel, symbol)?;
3626        let target_file = to_file
3627            .map(|path| normalize_file_path(&self.project_root, path))
3628            .transpose()?
3629            .map(|path| relative_path(&self.project_root, &path));
3630        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3631        self.ensure_ready(&conn)?;
3632        let effective_max = if max_depth == 0 {
3633            10
3634        } else {
3635            max_depth.min(16)
3636        };
3637
3638        let start_hop = trace_to_symbol_hop(&origin);
3639        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3640            return Ok(callgraph::TraceToSymbolResult {
3641                path: Some(vec![start_hop]),
3642                complete: true,
3643                reason: None,
3644            });
3645        }
3646
3647        let mut queue = VecDeque::new();
3648        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3649        let mut visited = HashSet::new();
3650        visited.insert((origin.file.clone(), origin.symbol.clone()));
3651        let mut max_depth_exhausted = false;
3652
3653        while let Some((current, path, depth)) = queue.pop_front() {
3654            let callees = outgoing_calls_for_node(&conn, &current)?
3655                .into_iter()
3656                .filter_map(|site| site.target)
3657                .collect::<Vec<_>>();
3658
3659            if depth >= effective_max {
3660                if callees
3661                    .iter()
3662                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3663                {
3664                    max_depth_exhausted = true;
3665                }
3666                continue;
3667            }
3668
3669            for callee in callees {
3670                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3671                    continue;
3672                }
3673                let mut next_path = path.clone();
3674                next_path.push(trace_to_symbol_hop(&callee));
3675                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3676                    return Ok(callgraph::TraceToSymbolResult {
3677                        path: Some(next_path),
3678                        complete: true,
3679                        reason: None,
3680                    });
3681                }
3682                queue.push_back((callee, next_path, depth + 1));
3683            }
3684        }
3685
3686        if max_depth_exhausted {
3687            Ok(callgraph::TraceToSymbolResult {
3688                path: None,
3689                complete: false,
3690                reason: Some("max_depth_exhausted".to_string()),
3691            })
3692        } else {
3693            Ok(callgraph::TraceToSymbolResult {
3694                path: None,
3695                complete: true,
3696                reason: Some("no_path_found".to_string()),
3697            })
3698        }
3699    }
3700}
3701
3702impl ReadonlyCallGraphStore {
3703    fn from_inner(inner: CallGraphStore) -> Self {
3704        Self { inner }
3705    }
3706
3707    pub fn project_root(&self) -> &Path {
3708        self.inner.project_root()
3709    }
3710
3711    pub fn project_key(&self) -> &str {
3712        self.inner.project_key()
3713    }
3714
3715    pub fn sqlite_path(&self) -> &Path {
3716        self.inner.sqlite_path()
3717    }
3718
3719    /// Report the open generation handle. SQLite-owned allocations are measured
3720    /// once by the process-wide SQLite allocator counters.
3721    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3722        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3723    }
3724
3725    /// Whether this reader is temporarily serving a legacy harness partition.
3726    pub fn is_legacy_fallback(&self) -> bool {
3727        self.inner.is_legacy_fallback()
3728    }
3729
3730    pub fn is_current(&self) -> bool {
3731        self.inner.is_current()
3732    }
3733
3734    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3735        self.inner.edge_snapshot()
3736    }
3737
3738    pub fn indexed_file_count(&self) -> Result<usize> {
3739        self.inner.indexed_file_count()
3740    }
3741
3742    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3743        self.inner.node_for(file_rel, symbol)
3744    }
3745
3746    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3747        self.inner.nodes_for(file_rel, symbol)
3748    }
3749
3750    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3751        self.inner.nodes_matching(symbol)
3752    }
3753
3754    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3755        self.inner.direct_callers_of(file_rel, symbol)
3756    }
3757
3758    pub fn direct_caller_counts_of(
3759        &self,
3760        targets: &[(String, String)],
3761    ) -> Result<HashMap<(String, String), usize>> {
3762        self.inner.direct_caller_counts_of(targets)
3763    }
3764
3765    pub fn callers_of(
3766        &self,
3767        file_rel: &Path,
3768        symbol: &str,
3769        depth: usize,
3770    ) -> Result<StoreCallersResult> {
3771        self.inner.callers_of(file_rel, symbol, depth)
3772    }
3773
3774    pub fn impact_of(
3775        &self,
3776        file_rel: &Path,
3777        symbol: &str,
3778        depth: usize,
3779    ) -> Result<StoreImpactResult> {
3780        self.inner.impact_of(file_rel, symbol, depth)
3781    }
3782
3783    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3784        self.inner.outgoing_calls_of(node)
3785    }
3786
3787    pub fn outgoing_calls_for_symbols(
3788        &self,
3789        sources: &[(String, String)],
3790    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3791        self.inner.outgoing_calls_for_symbols(sources)
3792    }
3793
3794    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3795        self.inner.resolved_self_calls_of(node)
3796    }
3797
3798    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3799        self.inner.unresolved_calls_of(node)
3800    }
3801
3802    pub fn call_tree(
3803        &self,
3804        file_rel: &Path,
3805        symbol: &str,
3806        depth: usize,
3807    ) -> Result<callgraph::CallTreeNode> {
3808        self.inner.call_tree(file_rel, symbol, depth)
3809    }
3810
3811    pub fn trace_to(
3812        &self,
3813        file_rel: &Path,
3814        symbol: &str,
3815        max_depth: usize,
3816    ) -> Result<callgraph::TraceToResult> {
3817        self.inner.trace_to(file_rel, symbol, max_depth)
3818    }
3819
3820    pub fn trace_to_symbol_candidates(
3821        &self,
3822        to_symbol: &str,
3823    ) -> Result<Vec<TraceToSymbolCandidate>> {
3824        self.inner.trace_to_symbol_candidates(to_symbol)
3825    }
3826
3827    pub fn trace_to_symbol(
3828        &self,
3829        file_rel: &Path,
3830        symbol: &str,
3831        to_symbol: &str,
3832        to_file: Option<&Path>,
3833        max_depth: usize,
3834    ) -> Result<callgraph::TraceToSymbolResult> {
3835        self.inner
3836            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3837    }
3838}
3839
3840impl CallGraphRead for CallGraphStore {
3841    fn project_root(&self) -> &Path {
3842        CallGraphStore::project_root(self)
3843    }
3844    fn project_key(&self) -> &str {
3845        CallGraphStore::project_key(self)
3846    }
3847    fn sqlite_path(&self) -> &Path {
3848        CallGraphStore::sqlite_path(self)
3849    }
3850    fn is_current(&self) -> bool {
3851        CallGraphStore::is_current(self)
3852    }
3853    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3854        CallGraphStore::edge_snapshot(self)
3855    }
3856    fn indexed_file_count(&self) -> Result<usize> {
3857        CallGraphStore::indexed_file_count(self)
3858    }
3859    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3860        CallGraphStore::node_for(self, file_rel, symbol)
3861    }
3862    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3863        CallGraphStore::nodes_for(self, file_rel, symbol)
3864    }
3865    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3866        CallGraphStore::nodes_matching(self, symbol)
3867    }
3868    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3869        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3870    }
3871    fn direct_caller_counts_of(
3872        &self,
3873        targets: &[(String, String)],
3874    ) -> Result<HashMap<(String, String), usize>> {
3875        CallGraphStore::direct_caller_counts_of(self, targets)
3876    }
3877    fn callers_of(
3878        &self,
3879        file_rel: &Path,
3880        symbol: &str,
3881        depth: usize,
3882    ) -> Result<StoreCallersResult> {
3883        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3884    }
3885    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3886        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3887    }
3888    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3889        CallGraphStore::outgoing_calls_of(self, node)
3890    }
3891    fn outgoing_calls_for_symbols(
3892        &self,
3893        sources: &[(String, String)],
3894    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3895        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3896    }
3897    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3898        CallGraphStore::resolved_self_calls_of(self, node)
3899    }
3900    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3901        CallGraphStore::unresolved_calls_of(self, node)
3902    }
3903    fn call_tree(
3904        &self,
3905        file_rel: &Path,
3906        symbol: &str,
3907        depth: usize,
3908    ) -> Result<callgraph::CallTreeNode> {
3909        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3910    }
3911    fn trace_to(
3912        &self,
3913        file_rel: &Path,
3914        symbol: &str,
3915        max_depth: usize,
3916    ) -> Result<callgraph::TraceToResult> {
3917        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3918    }
3919    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3920        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3921    }
3922    fn trace_to_symbol(
3923        &self,
3924        file_rel: &Path,
3925        symbol: &str,
3926        to_symbol: &str,
3927        to_file: Option<&Path>,
3928        max_depth: usize,
3929    ) -> Result<callgraph::TraceToSymbolResult> {
3930        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3931    }
3932}
3933
3934impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3935    fn project_root(&self) -> &Path {
3936        (**self).project_root()
3937    }
3938    fn project_key(&self) -> &str {
3939        (**self).project_key()
3940    }
3941    fn sqlite_path(&self) -> &Path {
3942        (**self).sqlite_path()
3943    }
3944    fn is_current(&self) -> bool {
3945        (**self).is_current()
3946    }
3947    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3948        (**self).edge_snapshot()
3949    }
3950    fn indexed_file_count(&self) -> Result<usize> {
3951        (**self).indexed_file_count()
3952    }
3953    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3954        (**self).node_for(file_rel, symbol)
3955    }
3956    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3957        (**self).nodes_for(file_rel, symbol)
3958    }
3959    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3960        (**self).nodes_matching(symbol)
3961    }
3962    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3963        (**self).direct_callers_of(file_rel, symbol)
3964    }
3965    fn direct_caller_counts_of(
3966        &self,
3967        targets: &[(String, String)],
3968    ) -> Result<HashMap<(String, String), usize>> {
3969        (**self).direct_caller_counts_of(targets)
3970    }
3971    fn callers_of(
3972        &self,
3973        file_rel: &Path,
3974        symbol: &str,
3975        depth: usize,
3976    ) -> Result<StoreCallersResult> {
3977        (**self).callers_of(file_rel, symbol, depth)
3978    }
3979    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3980        (**self).impact_of(file_rel, symbol, depth)
3981    }
3982    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3983        (**self).outgoing_calls_of(node)
3984    }
3985    fn outgoing_calls_for_symbols(
3986        &self,
3987        sources: &[(String, String)],
3988    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3989        (**self).outgoing_calls_for_symbols(sources)
3990    }
3991    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3992        (**self).resolved_self_calls_of(node)
3993    }
3994    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3995        (**self).unresolved_calls_of(node)
3996    }
3997    fn call_tree(
3998        &self,
3999        file_rel: &Path,
4000        symbol: &str,
4001        depth: usize,
4002    ) -> Result<callgraph::CallTreeNode> {
4003        (**self).call_tree(file_rel, symbol, depth)
4004    }
4005    fn trace_to(
4006        &self,
4007        file_rel: &Path,
4008        symbol: &str,
4009        max_depth: usize,
4010    ) -> Result<callgraph::TraceToResult> {
4011        (**self).trace_to(file_rel, symbol, max_depth)
4012    }
4013    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4014        (**self).trace_to_symbol_candidates(to_symbol)
4015    }
4016    fn trace_to_symbol(
4017        &self,
4018        file_rel: &Path,
4019        symbol: &str,
4020        to_symbol: &str,
4021        to_file: Option<&Path>,
4022        max_depth: usize,
4023    ) -> Result<callgraph::TraceToSymbolResult> {
4024        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4025    }
4026}
4027
4028impl CallGraphRead for ReadonlyCallGraphStore {
4029    fn project_root(&self) -> &Path {
4030        self.project_root()
4031    }
4032    fn project_key(&self) -> &str {
4033        self.project_key()
4034    }
4035    fn sqlite_path(&self) -> &Path {
4036        self.sqlite_path()
4037    }
4038    fn is_current(&self) -> bool {
4039        self.is_current()
4040    }
4041    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4042        self.edge_snapshot()
4043    }
4044    fn indexed_file_count(&self) -> Result<usize> {
4045        self.indexed_file_count()
4046    }
4047    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4048        self.node_for(file_rel, symbol)
4049    }
4050    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4051        self.nodes_for(file_rel, symbol)
4052    }
4053    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4054        self.nodes_matching(symbol)
4055    }
4056    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4057        self.direct_callers_of(file_rel, symbol)
4058    }
4059    fn direct_caller_counts_of(
4060        &self,
4061        targets: &[(String, String)],
4062    ) -> Result<HashMap<(String, String), usize>> {
4063        self.direct_caller_counts_of(targets)
4064    }
4065    fn callers_of(
4066        &self,
4067        file_rel: &Path,
4068        symbol: &str,
4069        depth: usize,
4070    ) -> Result<StoreCallersResult> {
4071        self.callers_of(file_rel, symbol, depth)
4072    }
4073    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4074        self.impact_of(file_rel, symbol, depth)
4075    }
4076    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4077        self.outgoing_calls_of(node)
4078    }
4079    fn outgoing_calls_for_symbols(
4080        &self,
4081        sources: &[(String, String)],
4082    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4083        self.outgoing_calls_for_symbols(sources)
4084    }
4085    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4086        self.resolved_self_calls_of(node)
4087    }
4088    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4089        self.unresolved_calls_of(node)
4090    }
4091    fn call_tree(
4092        &self,
4093        file_rel: &Path,
4094        symbol: &str,
4095        depth: usize,
4096    ) -> Result<callgraph::CallTreeNode> {
4097        self.call_tree(file_rel, symbol, depth)
4098    }
4099    fn trace_to(
4100        &self,
4101        file_rel: &Path,
4102        symbol: &str,
4103        max_depth: usize,
4104    ) -> Result<callgraph::TraceToResult> {
4105        self.trace_to(file_rel, symbol, max_depth)
4106    }
4107    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4108        self.trace_to_symbol_candidates(to_symbol)
4109    }
4110    fn trace_to_symbol(
4111        &self,
4112        file_rel: &Path,
4113        symbol: &str,
4114        to_symbol: &str,
4115        to_file: Option<&Path>,
4116        max_depth: usize,
4117    ) -> Result<callgraph::TraceToSymbolResult> {
4118        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4119    }
4120}
4121
4122fn indexed_file_count(conn: &Connection) -> Result<usize> {
4123    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
4124    Ok(count.max(0) as usize)
4125}
4126
4127fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
4128    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
4129    match candidates.as_slice() {
4130        [candidate] => Ok(candidate.clone()),
4131        [] => Err(AftError::SymbolNotFound {
4132            name: symbol.to_string(),
4133            file: rel_path.to_string(),
4134        }
4135        .into()),
4136        _ => Err(AftError::AmbiguousSymbol {
4137            name: symbol.to_string(),
4138            candidates: candidates
4139                .iter()
4140                .map(|candidate| candidate.symbol.clone())
4141                .collect(),
4142        }
4143        .into()),
4144    }
4145}
4146
4147fn nodes_for_file_matching_symbol(
4148    conn: &Connection,
4149    rel_path: &str,
4150    symbol: &str,
4151) -> Result<Vec<StoreNode>> {
4152    let qualified_query = symbol.contains("::");
4153    let sql = if qualified_query {
4154        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4155                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4156         FROM nodes n JOIN files f ON f.path = n.file_path
4157         WHERE n.file_path = ?1 AND n.scoped_name = ?2
4158         ORDER BY n.scoped_name, n.start_line, n.start_col"
4159    } else {
4160        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4161                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4162         FROM nodes n JOIN files f ON f.path = n.file_path
4163         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
4164         ORDER BY n.scoped_name, n.start_line, n.start_col"
4165    };
4166    let mut stmt = conn.prepare(sql)?;
4167    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
4168    rows.collect::<std::result::Result<Vec<_>, _>>()
4169        .map_err(Into::into)
4170}
4171
4172fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
4173    let qualified_query = symbol.contains("::");
4174    let sql = if qualified_query {
4175        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4176                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4177         FROM nodes n JOIN files f ON f.path = n.file_path
4178         WHERE n.scoped_name = ?1
4179         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4180    } else {
4181        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
4182                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
4183         FROM nodes n JOIN files f ON f.path = n.file_path
4184         WHERE n.scoped_name = ?1 OR n.name = ?1
4185         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
4186    };
4187    let mut stmt = conn.prepare(sql)?;
4188    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
4189    rows.collect::<std::result::Result<Vec<_>, _>>()
4190        .map_err(Into::into)
4191}
4192
4193fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
4194    store_node_from_row_at(row, 0)
4195}
4196
4197fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
4198    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
4199    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
4200    let lang_label_value: String = row.get(offset + 10)?;
4201    Ok(StoreNode {
4202        node_id: row.get(offset)?,
4203        file: row.get(offset + 1)?,
4204        symbol: row.get(offset + 2)?,
4205        name: row.get(offset + 3)?,
4206        kind: row.get(offset + 4)?,
4207        line: start_line.saturating_add(1),
4208        end_line: end_line.saturating_add(1),
4209        signature: row.get(offset + 7)?,
4210        exported: row.get::<_, i64>(offset + 8)? != 0,
4211        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
4212        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
4213    })
4214}
4215
4216fn optional_store_node_from_row_at(
4217    row: &rusqlite::Row<'_>,
4218    offset: usize,
4219) -> rusqlite::Result<Option<StoreNode>> {
4220    if row.get::<_, Option<String>>(offset)?.is_some() {
4221        store_node_from_row_at(row, offset).map(Some)
4222    } else {
4223        Ok(None)
4224    }
4225}
4226
4227#[allow(clippy::too_many_arguments)]
4228fn collect_callers_recursive(
4229    conn: &Connection,
4230    file: &str,
4231    symbol: &str,
4232    max_depth: usize,
4233    current_depth: usize,
4234    visited: &mut HashSet<(String, String)>,
4235    result: &mut Vec<StoreCallSite>,
4236    depth_limited: &mut bool,
4237    truncated: &mut usize,
4238) -> Result<()> {
4239    if current_depth >= max_depth {
4240        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
4241        if omitted > 0 {
4242            *depth_limited = true;
4243            *truncated += omitted;
4244        }
4245        return Ok(());
4246    }
4247
4248    if !visited.insert((file.to_string(), symbol.to_string())) {
4249        return Ok(());
4250    }
4251
4252    let sites = direct_callers_for_tuple(conn, file, symbol)?;
4253    for site in sites {
4254        result.push(site.clone());
4255        if current_depth + 1 < max_depth {
4256            collect_callers_recursive(
4257                conn,
4258                &site.caller.file,
4259                &site.caller.symbol,
4260                max_depth,
4261                current_depth + 1,
4262                visited,
4263                result,
4264                depth_limited,
4265                truncated,
4266            )?;
4267        } else {
4268            let omitted =
4269                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
4270            if omitted > 0 {
4271                *depth_limited = true;
4272                *truncated += omitted;
4273            }
4274        }
4275    }
4276    Ok(())
4277}
4278
4279// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4280const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
4281
4282fn direct_caller_counts_for_tuples(
4283    conn: &Connection,
4284    targets: &[(String, String)],
4285) -> Result<HashMap<(String, String), usize>> {
4286    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
4287    let mut counts = unique_targets
4288        .iter()
4289        .cloned()
4290        .map(|target| (target, 0usize))
4291        .collect::<HashMap<_, _>>();
4292
4293    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
4294    for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
4295        let requested_values = (0..chunk.len())
4296            .map(|_| "(?, ?)")
4297            .collect::<Vec<_>>()
4298            .join(", ");
4299        let sql = format!(
4300            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
4301             deduped AS (
4302                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
4303                 FROM requested requested
4304                 JOIN edges e
4305                   ON e.target_file = requested.target_file
4306                  AND e.target_symbol = requested.target_symbol
4307                  AND e.kind = 'call'
4308                 JOIN refs r ON r.ref_id = e.ref_id
4309                 JOIN nodes src ON src.id = e.source_node
4310                 JOIN files src_file ON src_file.path = src.file_path
4311                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
4312             )
4313             SELECT target_file, target_symbol, COUNT(*)
4314             FROM deduped
4315             GROUP BY target_file, target_symbol"
4316        );
4317        let bindings = chunk
4318            .iter()
4319            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4320        let mut stmt = conn.prepare(&sql)?;
4321        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4322            Ok((
4323                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4324                row.get::<_, i64>(2)?,
4325            ))
4326        })?;
4327        for row in rows {
4328            let (target, count) = row?;
4329            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
4330        }
4331    }
4332
4333    Ok(counts)
4334}
4335
4336fn direct_caller_count_for_tuple(
4337    conn: &Connection,
4338    target_file: &str,
4339    target_symbol: &str,
4340) -> Result<usize> {
4341    let count: i64 = conn.query_row(
4342        "SELECT COUNT(*)
4343         FROM edges e
4344         JOIN refs r ON r.ref_id = e.ref_id
4345         JOIN nodes src ON src.id = e.source_node
4346         JOIN files src_file ON src_file.path = src.file_path
4347         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
4348        params![target_file, target_symbol],
4349        |row| row.get(0),
4350    )?;
4351    Ok(usize::try_from(count).unwrap_or(usize::MAX))
4352}
4353
4354fn direct_callers_for_tuple(
4355    conn: &Connection,
4356    target_file: &str,
4357    target_symbol: &str,
4358) -> Result<Vec<StoreCallSite>> {
4359    let mut stmt = conn.prepare(
4360        "SELECT e.target_file, e.target_symbol, e.line,
4361                r.byte_start, r.byte_end, r.status, e.provenance,
4362                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
4363                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
4364                src_file.lang,
4365                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4366                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4367                tgt_file.lang
4368         FROM edges e
4369         JOIN refs r ON r.ref_id = e.ref_id
4370         JOIN nodes src ON src.id = e.source_node
4371         JOIN files src_file ON src_file.path = src.file_path
4372         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4373             ON tgt.id = e.target_node
4374         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
4375         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
4376    )?;
4377    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
4378        let caller = store_node_from_row_at(row, 7)?;
4379        let target = optional_store_node_from_row_at(row, 18)?;
4380        Ok(StoreCallSite {
4381            caller,
4382            target_file: row.get(0)?,
4383            target_symbol: row.get(1)?,
4384            target,
4385            line: row.get::<_, i64>(2)?.max(0) as u32,
4386            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4387            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4388            resolved: row.get::<_, String>(5)? == "resolved",
4389            provenance: row.get(6)?,
4390        })
4391    })?;
4392    rows.collect::<std::result::Result<Vec<_>, _>>()
4393        .map_err(Into::into)
4394}
4395
4396// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
4397const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
4398// Outgoing-edge batches bind one source node per parameter.
4399const OUTGOING_NODE_BATCH_SIZE: usize = 999;
4400
4401fn outgoing_calls_for_symbol_tuples(
4402    conn: &Connection,
4403    sources: &[(String, String)],
4404) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4405    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
4406    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
4407    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
4408    let source_nodes = unique_sources
4409        .iter()
4410        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
4411        .cloned()
4412        .collect::<Vec<_>>();
4413    let source_nodes_by_id = source_nodes
4414        .iter()
4415        .cloned()
4416        .map(|node| (node.node_id.clone(), node))
4417        .collect::<HashMap<_, _>>();
4418    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
4419
4420    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
4421        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
4422        let sql = format!(
4423            "SELECT e.source_node,
4424                    e.target_file, e.target_symbol, e.line,
4425                    r.byte_start, r.byte_end, r.status, e.provenance,
4426                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
4427                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4428                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4429                    tgt_file.lang
4430             FROM edges e
4431             JOIN refs r ON r.ref_id = e.ref_id
4432             LEFT JOIN nodes tgt ON tgt.id = e.target_node
4433             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
4434             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
4435             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
4436        );
4437        let bindings = chunk.iter().map(|node| node.node_id.as_str());
4438        let mut stmt = conn.prepare(&sql)?;
4439        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4440            let source_node_id = row.get::<_, String>(0)?;
4441            let caller = source_nodes_by_id
4442                .get(&source_node_id)
4443                .expect("batched outgoing row belongs to a requested source node")
4444                .clone();
4445            let target = optional_store_node_from_row_at(row, 8)?;
4446            Ok((
4447                source_node_id,
4448                StoreCallSite {
4449                    caller,
4450                    target_file: row.get(1)?,
4451                    target_symbol: row.get(2)?,
4452                    target,
4453                    line: row.get::<_, i64>(3)?.max(0) as u32,
4454                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
4455                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
4456                    resolved: row.get::<_, String>(6)? == "resolved",
4457                    provenance: row.get(7)?,
4458                },
4459            ))
4460        })?;
4461        for row in rows {
4462            let (source_node_id, call) = row?;
4463            calls_by_node.entry(source_node_id).or_default().push(call);
4464        }
4465    }
4466
4467    let mut calls_by_source = HashMap::new();
4468    for source in &unique_sources {
4469        let mut calls = Vec::new();
4470        if let Some(nodes) = source_nodes_by_symbol.get(source) {
4471            for node in nodes {
4472                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
4473                    calls.extend(node_calls);
4474                }
4475            }
4476        }
4477        calls_by_source.insert(source.clone(), calls);
4478    }
4479
4480    // Resolve each logical target once for the whole frontier. Keeping this separate
4481    // preserves positional-symbol representatives without a correlated lookup per edge.
4482    let target_tuples = calls_by_source
4483        .values()
4484        .flatten()
4485        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
4486        .collect::<Vec<_>>();
4487    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
4488    for calls in calls_by_source.values_mut() {
4489        for call in calls {
4490            if let Some(target) = target_nodes
4491                .get(&(call.target_file.clone(), call.target_symbol.clone()))
4492                .and_then(|nodes| nodes.first())
4493            {
4494                call.target = Some(target.clone());
4495            }
4496        }
4497    }
4498
4499    Ok(calls_by_source)
4500}
4501
4502fn nodes_for_symbol_tuples(
4503    conn: &Connection,
4504    symbols: &[(String, String)],
4505) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
4506    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
4507    let mut nodes_by_symbol = unique_symbols
4508        .iter()
4509        .cloned()
4510        .map(|symbol| (symbol, Vec::new()))
4511        .collect::<HashMap<_, _>>();
4512    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
4513
4514    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
4515        let requested_values = (0..chunk.len())
4516            .map(|_| "(?, ?)")
4517            .collect::<Vec<_>>()
4518            .join(", ");
4519        let sql = format!(
4520            "WITH requested(file, symbol) AS (VALUES {requested_values})
4521             SELECT requested.file, requested.symbol,
4522                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
4523                    node.start_line, node.end_line, node.signature, node.exported,
4524                    node.is_callgraph_entry_point, node_file.lang
4525             FROM requested
4526             JOIN nodes node INDEXED BY idx_nodes_file
4527               ON node.file_path = requested.file
4528              AND node.scoped_name = requested.symbol
4529             JOIN files node_file ON node_file.path = node.file_path
4530             ORDER BY requested.file, requested.symbol,
4531                      node.scoped_name, node.start_line, node.end_line,
4532                      node.start_col, node.range_ordinal"
4533        );
4534        let bindings = chunk
4535            .iter()
4536            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
4537        let mut stmt = conn.prepare(&sql)?;
4538        let rows = stmt.query_map(params_from_iter(bindings), |row| {
4539            Ok((
4540                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
4541                store_node_from_row_at(row, 2)?,
4542            ))
4543        })?;
4544        for row in rows {
4545            let (symbol, node) = row?;
4546            nodes_by_symbol.entry(symbol).or_default().push(node);
4547        }
4548    }
4549
4550    Ok(nodes_by_symbol)
4551}
4552
4553fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4554    let mut stmt = conn.prepare(
4555        "SELECT e.target_file, e.target_symbol, e.line,
4556                r.byte_start, r.byte_end, r.status, e.provenance,
4557                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4558                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4559                tgt_file.lang
4560         FROM edges e
4561         JOIN refs r ON r.ref_id = e.ref_id
4562         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4563             ON tgt.id = e.target_node
4564         WHERE e.kind = 'call' AND e.source_node = ?1
4565         ORDER BY r.byte_start, r.line, r.ref_id",
4566    )?;
4567    let rows = stmt.query_map(params![node.node_id], |row| {
4568        let target = optional_store_node_from_row_at(row, 7)?;
4569        Ok(StoreCallSite {
4570            caller: node.clone(),
4571            target_file: row.get(0)?,
4572            target_symbol: row.get(1)?,
4573            target,
4574            line: row.get::<_, i64>(2)?.max(0) as u32,
4575            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4576            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4577            resolved: row.get::<_, String>(5)? == "resolved",
4578            provenance: row.get(6)?,
4579        })
4580    })?;
4581    rows.collect::<std::result::Result<Vec<_>, _>>()
4582        .map_err(Into::into)
4583}
4584
4585fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4586    let mut stmt = conn.prepare(
4587        "SELECT r.target_file, r.target_symbol, r.line,
4588                r.byte_start, r.byte_end, r.status, r.provenance,
4589                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4590                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4591                tgt_file.lang
4592         FROM refs r
4593         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4594             ON tgt.id = r.target_node
4595         WHERE r.caller_node = ?1
4596           AND r.kind = 'call'
4597           AND r.status <> 'unresolved'
4598           AND r.target_file = ?2
4599           AND r.target_symbol = ?3
4600           AND r.provenance = ?4
4601           AND NOT EXISTS (
4602               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4603           )
4604         ORDER BY r.byte_start, r.line, r.ref_id",
4605    )?;
4606    let rows = stmt.query_map(
4607        params![
4608            &node.node_id,
4609            &node.file,
4610            &node.symbol,
4611            PROVENANCE_TREESITTER
4612        ],
4613        |row| {
4614            let target = optional_store_node_from_row_at(row, 7)?;
4615            Ok(StoreCallSite {
4616                caller: node.clone(),
4617                target_file: row.get(0)?,
4618                target_symbol: row.get(1)?,
4619                target,
4620                line: row.get::<_, i64>(2)?.max(0) as u32,
4621                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4622                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4623                resolved: row.get::<_, String>(5)? == "resolved",
4624                provenance: row.get(6)?,
4625            })
4626        },
4627    )?;
4628    rows.collect::<std::result::Result<Vec<_>, _>>()
4629        .map_err(Into::into)
4630}
4631
4632fn unresolved_calls_for_node(
4633    conn: &Connection,
4634    node: &StoreNode,
4635) -> Result<Vec<StoreUnresolvedCall>> {
4636    let mut stmt = conn.prepare(
4637        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4638         FROM refs
4639         WHERE caller_node = ?1
4640           AND kind = 'call'
4641           AND status = 'unresolved'
4642           AND NOT EXISTS (
4643               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4644           )
4645         ORDER BY byte_start, line, ref_id",
4646    )?;
4647    let rows = stmt.query_map(params![node.node_id], |row| {
4648        Ok(StoreUnresolvedCall {
4649            caller: node.clone(),
4650            symbol: row.get(0)?,
4651            full_ref: row.get(1)?,
4652            line: row.get::<_, i64>(2)?.max(0) as u32,
4653            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4654            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4655        })
4656    })?;
4657    rows.collect::<std::result::Result<Vec<_>, _>>()
4658        .map_err(Into::into)
4659}
4660
4661fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4662    let mut calls = Vec::new();
4663    calls.extend(
4664        outgoing_calls_for_node(conn, node)?
4665            .into_iter()
4666            .map(StoreForwardCall::Resolved),
4667    );
4668    calls.extend(
4669        unresolved_calls_for_node(conn, node)?
4670            .into_iter()
4671            .map(StoreForwardCall::Unresolved),
4672    );
4673    calls.sort_by(|left, right| {
4674        left.byte_start()
4675            .cmp(&right.byte_start())
4676            .then(left.line().cmp(&right.line()))
4677    });
4678    Ok(calls)
4679}
4680
4681fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4682    let resolved_count: i64 = conn.query_row(
4683        "SELECT COUNT(*)
4684         FROM edges e
4685         JOIN refs r ON r.ref_id = e.ref_id
4686         WHERE e.kind = 'call' AND e.source_node = ?1",
4687        params![&node.node_id],
4688        |row| row.get(0),
4689    )?;
4690    let unresolved_count: i64 = conn.query_row(
4691        "SELECT COUNT(*)
4692         FROM refs
4693         WHERE caller_node = ?1
4694           AND kind = 'call'
4695           AND status = 'unresolved'
4696           AND NOT EXISTS (
4697               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4698           )",
4699        params![&node.node_id],
4700        |row| row.get(0),
4701    )?;
4702    let total = resolved_count.saturating_add(unresolved_count);
4703    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4704}
4705
4706fn call_tree_inner(
4707    conn: &Connection,
4708    node: &StoreNode,
4709    max_depth: usize,
4710    current_depth: usize,
4711    visited: &mut HashSet<(String, String)>,
4712) -> Result<callgraph::CallTreeNode> {
4713    let visit_key = (node.file.clone(), node.symbol.clone());
4714    if visited.contains(&visit_key) {
4715        return Ok(callgraph::CallTreeNode {
4716            name: node.symbol.clone(),
4717            file: node.file.clone(),
4718            line: node.line,
4719            signature: node.signature.clone(),
4720            resolved: true,
4721            children: Vec::new(),
4722            depth_limited: false,
4723            truncated: 0,
4724        });
4725    }
4726    visited.insert(visit_key.clone());
4727
4728    let mut children = Vec::new();
4729    let mut depth_limited = false;
4730    let mut truncated = 0usize;
4731
4732    if current_depth < max_depth {
4733        let calls = forward_calls_for_node(conn, node)?;
4734        for call in calls {
4735            match call {
4736                StoreForwardCall::Resolved(site) => {
4737                    if let Some(target) = site.target {
4738                        let child =
4739                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4740                        depth_limited |= child.depth_limited;
4741                        truncated += child.truncated;
4742                        children.push(child);
4743                    } else {
4744                        children.push(callgraph::CallTreeNode {
4745                            name: site.target_symbol,
4746                            file: site.target_file,
4747                            line: site.line,
4748                            signature: None,
4749                            resolved: false,
4750                            children: Vec::new(),
4751                            depth_limited: false,
4752                            truncated: 0,
4753                        });
4754                    }
4755                }
4756                StoreForwardCall::Unresolved(call) => {
4757                    children.push(callgraph::CallTreeNode {
4758                        name: call.symbol,
4759                        file: call.caller.file,
4760                        line: call.line,
4761                        signature: None,
4762                        resolved: false,
4763                        children: Vec::new(),
4764                        depth_limited: false,
4765                        truncated: 0,
4766                    });
4767                }
4768            }
4769        }
4770    } else {
4771        truncated = forward_call_count_for_node(conn, node)?;
4772        depth_limited = truncated > 0;
4773    }
4774
4775    visited.remove(&visit_key);
4776    Ok(callgraph::CallTreeNode {
4777        name: node.symbol.clone(),
4778        file: node.file.clone(),
4779        line: node.line,
4780        signature: node.signature.clone(),
4781        resolved: true,
4782        children,
4783        depth_limited,
4784        truncated,
4785    })
4786}
4787
4788fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4789    callgraph::TraceToSymbolHop {
4790        symbol: node.symbol.clone(),
4791        file: node.file.clone(),
4792        line: node.line,
4793    }
4794}
4795
4796fn trace_to_symbol_matches_target(
4797    node: &StoreNode,
4798    to_symbol: &str,
4799    to_file: Option<&str>,
4800) -> bool {
4801    if !symbol_query_matches(&node.symbol, to_symbol) {
4802        return false;
4803    }
4804    match to_file {
4805        Some(file) => node.file == file,
4806        None => true,
4807    }
4808}
4809
4810fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4811    symbol == query || unqualified_name(symbol) == query
4812}
4813
4814fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4815    let source = std::fs::read_to_string(path).ok()?;
4816    Some(source.lines().map(|line| line.trim().to_string()).collect())
4817}
4818
4819#[doc(hidden)]
4820pub fn live_callgraph_edge_snapshot(
4821    project_root: &Path,
4822    files: &[PathBuf],
4823) -> Result<BTreeSet<StoredEdge>> {
4824    let files = normalize_file_list(project_root, files)?;
4825    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4826    let mut file_data = Vec::new();
4827    for file in &files {
4828        let canon = canonicalize_path(file);
4829        let data = graph.build_file(&canon)?.clone();
4830        file_data.push((canon, data));
4831    }
4832
4833    let mut edges = BTreeSet::new();
4834    for (caller_file, data) in &file_data {
4835        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4836            for call_site in call_sites {
4837                let resolution = graph.resolve_cross_file_edge(
4838                    &call_site.full_callee,
4839                    &call_site.callee_name,
4840                    caller_file,
4841                    &data.import_block,
4842                );
4843                let (target_file, target_symbol) = match resolution {
4844                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4845                    EdgeResolution::Unresolved { callee_name } => {
4846                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4847                            continue;
4848                        }
4849                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4850                            data,
4851                            caller_file,
4852                            &callee_name,
4853                        ) else {
4854                            continue;
4855                        };
4856                        (caller_file.clone(), target_symbol)
4857                    }
4858                };
4859                if target_file == *caller_file && target_symbol == *caller_symbol {
4860                    continue;
4861                }
4862                edges.insert(StoredEdge {
4863                    source_file: relative_path(project_root, caller_file),
4864                    source_symbol: caller_symbol.clone(),
4865                    target_file: relative_path(project_root, &target_file),
4866                    target_symbol,
4867                    kind: "call".to_string(),
4868                    line: call_site.line,
4869                });
4870            }
4871        }
4872    }
4873    Ok(edges)
4874}
4875
4876fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
4877{
4878    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
4879}
4880
4881fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
4882    RebuildCooldownKey {
4883        callgraph_dir: std::fs::canonicalize(callgraph_dir)
4884            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
4885        project_key: project_key.to_string(),
4886    }
4887}
4888
4889fn rebuild_cooldown_denial(
4890    callgraph_dir: &Path,
4891    project_key: &str,
4892    project_root: &Path,
4893    now: Instant,
4894) -> Option<(PathBuf, Duration)> {
4895    let key = rebuild_cooldown_key(callgraph_dir, project_key);
4896    let records = rebuild_cooldown_records()
4897        .lock()
4898        .unwrap_or_else(std::sync::PoisonError::into_inner);
4899    let record = records.get(&key)?;
4900    if record.project_root == project_root || !record.cross_root_cooldown_armed {
4901        return None;
4902    }
4903    let elapsed = now.saturating_duration_since(record.published_at);
4904    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
4905}
4906
4907fn record_successful_rebuild(
4908    callgraph_dir: &Path,
4909    project_key: &str,
4910    project_root: &Path,
4911    published_at: Instant,
4912) {
4913    let key = rebuild_cooldown_key(callgraph_dir, project_key);
4914    let mut records = rebuild_cooldown_records()
4915        .lock()
4916        .unwrap_or_else(std::sync::PoisonError::into_inner);
4917    if records.len() >= 4_096 && !records.contains_key(&key) {
4918        if let Some(evict) = records.keys().next().cloned() {
4919            records.remove(&evict);
4920        }
4921    }
4922    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
4923        previous.cross_root_cooldown_armed || previous.project_root != project_root
4924    });
4925    records.insert(
4926        key,
4927        RebuildCooldownRecord {
4928            project_root: project_root.to_path_buf(),
4929            published_at,
4930            cross_root_cooldown_armed,
4931        },
4932    );
4933}
4934
4935fn acquire_writer_lease(
4936    callgraph_dir: &Path,
4937    project_key: &str,
4938    project_root: &Path,
4939) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4940    crate::root_cache::WriterLease::acquire_shared(
4941        crate::root_cache::RootCacheDomain::Callgraph,
4942        callgraph_dir,
4943        project_key,
4944        project_root,
4945    )
4946    .map_err(CallGraphStoreError::from)
4947}
4948
4949fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4950    if lease.verify()? {
4951        Ok(())
4952    } else {
4953        Err(CallGraphStoreError::Unavailable(format!(
4954            "callgraph writer lease for key {} lost epoch {}; aborting write",
4955            lease.key(),
4956            lease.epoch()
4957        )))
4958    }
4959}
4960
4961fn legacy_migration_completion_line(
4962    project_key: &str,
4963    method: &str,
4964    legacy_bytes: u64,
4965    migrated_bytes: u64,
4966) -> String {
4967    format!(
4968        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4969    )
4970}
4971
4972fn log_legacy_migration_completion(
4973    project_key: &str,
4974    method: &str,
4975    legacy_bytes: u64,
4976    migrated_bytes: u64,
4977) {
4978    crate::slog_info!(
4979        "{}",
4980        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4981    );
4982}
4983
4984fn try_legacy_migration_or_fallback(
4985    callgraph_dir: &Path,
4986    project_root: &Path,
4987    project_key: &str,
4988    writer_lease: Arc<crate::root_cache::WriterLease>,
4989) -> Result<Option<CallGraphStore>> {
4990    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4991    if partitions.is_empty() {
4992        return Ok(None);
4993    }
4994
4995    for partition in &partitions {
4996        if let Some(source) = newest_superseded_legacy_generation(partition)? {
4997            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4998                return open_legacy_fallback_store(
4999                    callgraph_dir,
5000                    project_root,
5001                    project_key,
5002                    &partitions,
5003                );
5004            }
5005            match publish_generation_copy_migration(
5006                callgraph_dir,
5007                project_key,
5008                &source,
5009                Arc::clone(&writer_lease),
5010            ) {
5011                Ok(published) => {
5012                    log_legacy_migration_completion(
5013                        project_key,
5014                        "generation_copy",
5015                        source.source_bytes,
5016                        published.migrated_bytes,
5017                    );
5018                    return CallGraphStore::open_generation(
5019                        callgraph_dir,
5020                        project_root.to_path_buf(),
5021                        project_key.to_string(),
5022                        published.generation,
5023                        writer_lease,
5024                    )
5025                    .map(Some);
5026                }
5027                Err(error) => {
5028                    crate::slog_warn!(
5029                        "root-keyed callgraph generation-copy migration failed from {}: {}",
5030                        source.sqlite_path.display(),
5031                        error
5032                    );
5033                    return open_legacy_fallback_store(
5034                        callgraph_dir,
5035                        project_root,
5036                        project_key,
5037                        &partitions,
5038                    );
5039                }
5040            }
5041        }
5042
5043        if let Some(source) = current_legacy_generation(partition)? {
5044            if !migration_disk_floor_allows(&source, callgraph_dir)? {
5045                return open_legacy_fallback_store(
5046                    callgraph_dir,
5047                    project_root,
5048                    project_key,
5049                    &partitions,
5050                );
5051            }
5052            match publish_backup_migration(
5053                callgraph_dir,
5054                project_key,
5055                &source,
5056                Arc::clone(&writer_lease),
5057            ) {
5058                Ok(published) => {
5059                    log_legacy_migration_completion(
5060                        project_key,
5061                        "sqlite_backup",
5062                        source.source_bytes,
5063                        published.migrated_bytes,
5064                    );
5065                    return CallGraphStore::open_generation(
5066                        callgraph_dir,
5067                        project_root.to_path_buf(),
5068                        project_key.to_string(),
5069                        published.generation,
5070                        writer_lease,
5071                    )
5072                    .map(Some);
5073                }
5074                Err(error) => {
5075                    crate::slog_warn!(
5076                        "root-keyed callgraph backup migration failed from {}: {}",
5077                        source.sqlite_path.display(),
5078                        error
5079                    );
5080                    return open_legacy_fallback_store(
5081                        callgraph_dir,
5082                        project_root,
5083                        project_key,
5084                        &partitions,
5085                    );
5086                }
5087            }
5088        }
5089    }
5090
5091    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
5092}
5093
5094fn open_legacy_fallback_store(
5095    callgraph_dir: &Path,
5096    project_root: &Path,
5097    project_key: &str,
5098    partitions: &[LegacyCallgraphPartition],
5099) -> Result<Option<CallGraphStore>> {
5100    let Some(target) = first_ready_legacy_target(partitions)? else {
5101        return Ok(None);
5102    };
5103    crate::slog_warn!(
5104        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
5105        target.partition.harness,
5106        target.sqlite_path.display()
5107    );
5108    let conn = open_readonly_connection(&target.sqlite_path)?;
5109    if !database_ready(&conn).unwrap_or(false) {
5110        return Ok(None);
5111    }
5112    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
5113    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
5114    Ok(Some(CallGraphStore::from_connection(
5115        project_root.to_path_buf(),
5116        project_key.to_string(),
5117        target.sqlite_path,
5118        callgraph_dir.to_path_buf(),
5119        true,
5120        target.generation,
5121        None,
5122        Some(read_marker),
5123        conn,
5124    )))
5125}
5126
5127fn migration_disk_floor_allows(
5128    source: &LegacyCallgraphTarget,
5129    callgraph_dir: &Path,
5130) -> Result<bool> {
5131    let available = migration_available_disk(callgraph_dir)?;
5132    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
5133        source.source_bytes,
5134        available,
5135    );
5136    if decision.should_skip_copy() {
5137        crate::slog_warn!(
5138            "{}",
5139            decision.warning_message(&source.sqlite_path, callgraph_dir)
5140        );
5141        return Ok(false);
5142    }
5143    Ok(true)
5144}
5145
5146fn migration_available_disk(path: &Path) -> Result<u64> {
5147    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
5148        return Ok(bytes);
5149    }
5150    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
5151}
5152
5153fn legacy_callgraph_partitions(
5154    callgraph_dir: &Path,
5155    project_key: &str,
5156) -> Result<Vec<LegacyCallgraphPartition>> {
5157    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5158        return Ok(Vec::new());
5159    };
5160    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
5161    let mut partitions = inventory
5162        .into_iter()
5163        .filter(|entry| {
5164            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5165                && entry.key == project_key
5166        })
5167        .map(|entry| {
5168            let dir = if entry.path.is_dir() {
5169                entry.path.clone()
5170            } else {
5171                entry
5172                    .path
5173                    .parent()
5174                    .map(Path::to_path_buf)
5175                    .unwrap_or_else(|| entry.path.clone())
5176            };
5177            LegacyCallgraphPartition {
5178                harness: entry.harness,
5179                dir,
5180                key: entry.key,
5181                bytes: entry.bytes,
5182                freshness: entry.callgraph_pointer_mtime,
5183            }
5184        })
5185        .collect::<Vec<_>>();
5186    partitions.sort_by(|left, right| {
5187        right
5188            .freshness
5189            .cmp(&left.freshness)
5190            .then_with(|| right.bytes.cmp(&left.bytes))
5191            .then_with(|| left.harness.cmp(&right.harness))
5192    });
5193    Ok(partitions)
5194}
5195
5196fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
5197    let domain_dir = callgraph_dir.parent()?;
5198    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
5199        return None;
5200    }
5201    domain_dir.parent().map(Path::to_path_buf)
5202}
5203
5204pub(crate) fn all_legacy_partitions_migrated_for_keys(
5205    callgraph_dir: &Path,
5206    configured_keys: &BTreeSet<String>,
5207) -> Result<bool> {
5208    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5209        return Ok(false);
5210    };
5211    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
5212        .into_iter()
5213        .filter(|entry| {
5214            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
5215                && configured_keys.contains(&entry.key)
5216        })
5217        .map(|entry| entry.key)
5218        .collect::<BTreeSet<_>>();
5219    if legacy_keys.is_empty() {
5220        return Ok(false);
5221    }
5222
5223    for key in legacy_keys {
5224        let migrated_dir = storage_root.join("callgraph").join(&key);
5225        let Some(generation) = read_pointer(&migrated_dir, &key) else {
5226            return Ok(false);
5227        };
5228        if !migration_generation_requires_manifest(&generation)
5229            || !migration_manifest_valid(&migrated_dir, &generation)
5230        {
5231            return Ok(false);
5232        }
5233    }
5234    Ok(true)
5235}
5236
5237fn newest_superseded_legacy_generation(
5238    partition: &LegacyCallgraphPartition,
5239) -> Result<Option<LegacyCallgraphTarget>> {
5240    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
5241        return Ok(None);
5242    };
5243    let prefix = format!("{}.g", partition.key);
5244    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
5245        return Ok(None);
5246    };
5247    let mut candidates = Vec::new();
5248    for entry in entries.flatten() {
5249        let name = entry.file_name().to_string_lossy().to_string();
5250        if name == current
5251            || name.contains(".tmp.")
5252            || !name.starts_with(&prefix)
5253            || !name.ends_with(".sqlite")
5254        {
5255            continue;
5256        }
5257        let path = entry.path();
5258        if !db_path_ready(&path) {
5259            continue;
5260        }
5261        let modified = entry
5262            .metadata()
5263            .and_then(|metadata| metadata.modified())
5264            .unwrap_or(SystemTime::UNIX_EPOCH);
5265        candidates.push((modified, path, name));
5266    }
5267    candidates.sort_by(|left, right| right.0.cmp(&left.0));
5268    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
5269        return Ok(None);
5270    };
5271    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5272    Ok(Some(LegacyCallgraphTarget {
5273        partition: partition.clone(),
5274        sqlite_path,
5275        generation: Some(generation),
5276        source_bytes,
5277        source_blake3: String::new(),
5278    }))
5279}
5280
5281fn current_legacy_generation(
5282    partition: &LegacyCallgraphPartition,
5283) -> Result<Option<LegacyCallgraphTarget>> {
5284    let Some(target) = ready_legacy_target(partition)? else {
5285        return Ok(None);
5286    };
5287    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
5288    if has_superseded {
5289        return Ok(None);
5290    }
5291    Ok(Some(target))
5292}
5293
5294fn freshest_legacy_fallback_target(
5295    callgraph_dir: &Path,
5296    project_key: &str,
5297) -> Result<Option<LegacyCallgraphTarget>> {
5298    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
5299    first_ready_legacy_target(&partitions)
5300}
5301
5302fn first_ready_legacy_target(
5303    partitions: &[LegacyCallgraphPartition],
5304) -> Result<Option<LegacyCallgraphTarget>> {
5305    for partition in partitions {
5306        if let Some(target) = ready_legacy_target(partition)? {
5307            return Ok(Some(target));
5308        }
5309    }
5310    Ok(None)
5311}
5312
5313fn ready_legacy_target(
5314    partition: &LegacyCallgraphPartition,
5315) -> Result<Option<LegacyCallgraphTarget>> {
5316    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
5317        let sqlite_path = partition.dir.join(&generation);
5318        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5319            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5320            return Ok(Some(LegacyCallgraphTarget {
5321                partition: partition.clone(),
5322                sqlite_path,
5323                generation: Some(generation),
5324                source_bytes,
5325                source_blake3: String::new(),
5326            }));
5327        }
5328    }
5329
5330    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
5331    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
5332        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
5333        return Ok(Some(LegacyCallgraphTarget {
5334            partition: partition.clone(),
5335            sqlite_path,
5336            generation: None,
5337            source_bytes,
5338            source_blake3: String::new(),
5339        }));
5340    }
5341    Ok(None)
5342}
5343
5344fn publish_generation_copy_migration(
5345    callgraph_dir: &Path,
5346    project_key: &str,
5347    source: &LegacyCallgraphTarget,
5348    writer_lease: Arc<crate::root_cache::WriterLease>,
5349) -> Result<PublishedLegacyMigration> {
5350    let generation = migration_generation_file_name(project_key, "copy");
5351    let temp_path = migration_temp_path(callgraph_dir, &generation);
5352    remove_sqlite_file_set(&temp_path);
5353    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
5354    fail_after_temp_copy_for_test()?;
5355
5356    let mut source = source.clone();
5357    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5358    source.source_blake3 = fingerprint.blake3;
5359    let generation = publish_migrated_generation(
5360        callgraph_dir,
5361        project_key,
5362        &generation,
5363        &temp_path,
5364        &source,
5365        fingerprint.bytes,
5366        writer_lease,
5367        "generation_copy",
5368    )?;
5369    Ok(PublishedLegacyMigration {
5370        generation,
5371        migrated_bytes: fingerprint.bytes,
5372    })
5373}
5374
5375fn publish_backup_migration(
5376    callgraph_dir: &Path,
5377    project_key: &str,
5378    source: &LegacyCallgraphTarget,
5379    writer_lease: Arc<crate::root_cache::WriterLease>,
5380) -> Result<PublishedLegacyMigration> {
5381    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
5382        return Err(CallGraphStoreError::Unavailable(
5383            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
5384        ));
5385    }
5386
5387    let generation = migration_generation_file_name(project_key, "backup");
5388    let temp_path = migration_temp_path(callgraph_dir, &generation);
5389    remove_sqlite_file_set(&temp_path);
5390
5391    let source_conn = open_readonly_connection(&source.sqlite_path)?;
5392    let mut destination = Connection::open(&temp_path)?;
5393    destination.busy_timeout(Duration::from_secs(5))?;
5394    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
5395    let started = Instant::now();
5396    let mut retries = 0;
5397    loop {
5398        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
5399            rusqlite::backup::StepResult::Done => break,
5400            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
5401            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
5402                retries += 1;
5403                if retries > MIGRATION_BACKUP_RETRY_BUDGET
5404                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
5405                {
5406                    return Err(CallGraphStoreError::Unavailable(format!(
5407                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
5408                    )));
5409                }
5410                std::thread::sleep(Duration::from_millis(20));
5411            }
5412            _ => {
5413                return Err(CallGraphStoreError::Unavailable(
5414                    "legacy callgraph backup returned an unknown step result".to_string(),
5415                ));
5416            }
5417        }
5418    }
5419    drop(backup);
5420
5421    let integrity: String =
5422        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
5423    if integrity != "ok" {
5424        return Err(CallGraphStoreError::Unavailable(format!(
5425            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
5426        )));
5427    }
5428    if !database_ready(&destination)? {
5429        return Err(CallGraphStoreError::Unavailable(
5430            "legacy callgraph backup produced a database without ready metadata".to_string(),
5431        ));
5432    }
5433    destination.execute_batch("PRAGMA optimize;")?;
5434    drop(destination);
5435    sync_file(&temp_path)?;
5436    fail_after_temp_copy_for_test()?;
5437
5438    let mut source = source.clone();
5439    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
5440    source.source_blake3 = fingerprint.blake3;
5441    let generation = publish_migrated_generation(
5442        callgraph_dir,
5443        project_key,
5444        &generation,
5445        &temp_path,
5446        &source,
5447        fingerprint.bytes,
5448        writer_lease,
5449        "sqlite_backup",
5450    )?;
5451    Ok(PublishedLegacyMigration {
5452        generation,
5453        migrated_bytes: fingerprint.bytes,
5454    })
5455}
5456
5457fn publish_migrated_generation(
5458    callgraph_dir: &Path,
5459    project_key: &str,
5460    generation: &str,
5461    temp_path: &Path,
5462    source: &LegacyCallgraphTarget,
5463    migrated_bytes: u64,
5464    writer_lease: Arc<crate::root_cache::WriterLease>,
5465    method: &str,
5466) -> Result<String> {
5467    let gen_path = callgraph_dir.join(generation);
5468    checkpoint_sqlite_before_publication(temp_path);
5469    let publication = publish_if_current(|| {
5470        verify_writer_lease(&writer_lease)?;
5471        remove_sqlite_file_set(&gen_path);
5472        rename_sqlite_file_set(temp_path, &gen_path)?;
5473        crate::fs_lock::sync_parent(&gen_path);
5474
5475        verify_writer_lease(&writer_lease)?;
5476        publish_pointer(callgraph_dir, project_key, generation)?;
5477        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
5478        Ok(generation.to_string())
5479    });
5480    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
5481        remove_sqlite_file_set(temp_path);
5482    }
5483    publication
5484}
5485
5486fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5487    if let Some(parent) = destination.parent() {
5488        std::fs::create_dir_all(parent)?;
5489    }
5490    for suffix in SQLITE_FILE_SET_SUFFIXES {
5491        let source_path = sqlite_file_set_path(source, suffix);
5492        if !source_path.is_file() {
5493            continue;
5494        }
5495        let destination_path = sqlite_file_set_path(destination, suffix);
5496        std::fs::copy(&source_path, &destination_path)?;
5497        sync_file(&destination_path)?;
5498    }
5499    Ok(())
5500}
5501
5502fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
5503    for suffix in SQLITE_FILE_SET_SUFFIXES {
5504        let source_path = sqlite_file_set_path(source, suffix);
5505        if !source_path.exists() {
5506            continue;
5507        }
5508        let destination_path = sqlite_file_set_path(destination, suffix);
5509        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
5510            let _ = std::fs::remove_file(&source_path);
5511            return Err(error.into());
5512        }
5513    }
5514    Ok(())
5515}
5516
5517fn sqlite_file_set_size(path: &Path) -> Result<u64> {
5518    let mut bytes = 0_u64;
5519    for suffix in SQLITE_FILE_SET_SUFFIXES {
5520        let member = sqlite_file_set_path(path, suffix);
5521        if !member.is_file() {
5522            continue;
5523        }
5524        bytes = bytes.saturating_add(member.metadata()?.len());
5525    }
5526    Ok(bytes)
5527}
5528
5529fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
5530    let mut hasher = blake3::Hasher::new();
5531    let mut bytes = 0_u64;
5532    let mut buffer = [0_u8; 64 * 1024];
5533    for suffix in SQLITE_FILE_SET_SUFFIXES {
5534        let member = sqlite_file_set_path(path, suffix);
5535        if !member.is_file() {
5536            continue;
5537        }
5538        hasher.update(suffix.as_bytes());
5539        let mut file = std::fs::File::open(&member)?;
5540        loop {
5541            let read = file.read(&mut buffer)?;
5542            if read == 0 {
5543                break;
5544            }
5545            bytes = bytes.saturating_add(read as u64);
5546            hasher.update(&buffer[..read]);
5547        }
5548    }
5549    Ok(SourceFingerprint {
5550        bytes,
5551        blake3: hash_to_hex(hasher.finalize()),
5552    })
5553}
5554
5555fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
5556    if suffix.is_empty() {
5557        path.to_path_buf()
5558    } else {
5559        PathBuf::from(format!("{}{suffix}", path.display()))
5560    }
5561}
5562
5563fn sync_file(path: &Path) -> Result<()> {
5564    let file = std::fs::OpenOptions::new()
5565        .read(true)
5566        .write(true)
5567        .open(path)?;
5568    file.sync_all()?;
5569    Ok(())
5570}
5571
5572fn fail_after_temp_copy_for_test() -> Result<()> {
5573    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
5574        return Err(CallGraphStoreError::Unavailable(
5575            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
5576        ));
5577    }
5578    Ok(())
5579}
5580
5581fn migration_generation_file_name(project_key: &str, method: &str) -> String {
5582    format!(
5583        "{project_key}.g{}.{}{}{}.sqlite",
5584        now_nanos(),
5585        std::process::id(),
5586        MIGRATION_GENERATION_TAG,
5587        method
5588    )
5589}
5590
5591fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5592    callgraph_dir.join(format!(
5593        "{generation}.tmp.{}.{}",
5594        std::process::id(),
5595        now_nanos()
5596    ))
5597}
5598
5599fn write_migration_manifest(
5600    callgraph_dir: &Path,
5601    generation: &str,
5602    source: &LegacyCallgraphTarget,
5603    migrated_bytes: u64,
5604    method: &str,
5605) -> Result<()> {
5606    let manifest_path = migration_manifest_path(callgraph_dir, generation);
5607    let temp_path = manifest_path.with_extension(format!(
5608        "migration.json.tmp.{}.{}",
5609        std::process::id(),
5610        now_nanos()
5611    ));
5612    let manifest = serde_json::json!({
5613        "version": MIGRATION_MANIFEST_VERSION,
5614        "method": method,
5615        "target_generation": generation,
5616        "source_harness": source.partition.harness,
5617        "source_path": source.sqlite_path.display().to_string(),
5618        "source_generation": source.generation,
5619        "source_bytes": source.source_bytes,
5620        "source_blake3": source.source_blake3,
5621        "migrated_bytes": migrated_bytes,
5622    });
5623    {
5624        use std::io::Write as _;
5625        let mut file = std::fs::File::create(&temp_path)?;
5626        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
5627        file.write_all(b"\n")?;
5628        file.sync_all()?;
5629    }
5630    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
5631        let _ = std::fs::remove_file(&temp_path);
5632        return Err(error.into());
5633    }
5634    crate::fs_lock::sync_parent(&manifest_path);
5635    Ok(())
5636}
5637
5638fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
5639    callgraph_dir.join(format!("{generation}.migration.json"))
5640}
5641
5642fn migration_generation_requires_manifest(generation: &str) -> bool {
5643    generation.contains(MIGRATION_GENERATION_TAG)
5644}
5645
5646fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5647    if !migration_generation_requires_manifest(generation) {
5648        return true;
5649    }
5650    let path = migration_manifest_path(callgraph_dir, generation);
5651    let Ok(bytes) = std::fs::read(path) else {
5652        return false;
5653    };
5654    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5655        return false;
5656    };
5657    value.get("version").and_then(serde_json::Value::as_u64)
5658        == Some(MIGRATION_MANIFEST_VERSION as u64)
5659        && value
5660            .get("target_generation")
5661            .and_then(serde_json::Value::as_str)
5662            == Some(generation)
5663        && value
5664            .get("source_bytes")
5665            .and_then(serde_json::Value::as_u64)
5666            .is_some_and(|bytes| bytes > 0)
5667        && value
5668            .get("source_blake3")
5669            .and_then(serde_json::Value::as_str)
5670            .is_some_and(|hash| hash.len() == 64)
5671}
5672
5673fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5674    let pointer_generation = read_pointer(callgraph_dir, project_key);
5675    if let Some(generation) = pointer_generation.as_deref() {
5676        if migration_generation_requires_manifest(generation)
5677            && !migration_manifest_valid(callgraph_dir, generation)
5678        {
5679            let path = callgraph_dir.join(generation);
5680            remove_sqlite_file_set(&path);
5681            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5682            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5683        }
5684    }
5685
5686    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5687        return;
5688    };
5689    for entry in entries.flatten() {
5690        let name = entry.file_name().to_string_lossy().to_string();
5691        let path = entry.path();
5692        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5693            let _ = std::fs::remove_file(path);
5694            continue;
5695        }
5696        if name.starts_with(&format!("{project_key}.g"))
5697            && name.ends_with(".sqlite")
5698            && name.contains(MIGRATION_GENERATION_TAG)
5699            && pointer_generation.as_deref() != Some(&name)
5700            && !migration_manifest_valid(callgraph_dir, &name)
5701        {
5702            remove_sqlite_file_set(&path);
5703            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5704        }
5705    }
5706    crate::fs_lock::sync_parent(callgraph_dir);
5707}
5708
5709fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5710    let mut hasher = blake3::Hasher::new();
5711    hasher.update(path.to_string_lossy().as_bytes());
5712    if let Some(generation) = generation {
5713        hasher.update(generation.as_bytes());
5714    }
5715    let digest = hash_to_hex(hasher.finalize());
5716    format!("legacy-{}", &digest[..16])
5717}
5718
5719fn open_readonly_connection(path: &Path) -> Result<Connection> {
5720    let uri = sqlite_readonly_uri(path);
5721    let conn = Connection::open_with_flags(
5722        &uri,
5723        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5724    )?;
5725    conn.pragma_update(
5726        None,
5727        "synchronous",
5728        if write_amplification_baseline_enabled() {
5729            "FULL"
5730        } else {
5731            "NORMAL"
5732        },
5733    )?;
5734    conn.busy_timeout(reader_busy_timeout())?;
5735    conn.execute_batch("PRAGMA query_only=ON;")?;
5736    Ok(conn)
5737}
5738
5739fn reader_busy_timeout() -> Duration {
5740    let jitter = (now_nanos() % 500) as u64;
5741    Duration::from_millis(250 + jitter)
5742}
5743
5744fn sqlite_readonly_uri(path: &Path) -> String {
5745    let raw = path.to_string_lossy().replace('\\', "/");
5746    let encoded = percent_encode_sqlite_uri_path(&raw);
5747    if raw.starts_with('/') {
5748        format!("file://{encoded}?mode=ro")
5749    } else if raw.as_bytes().get(1) == Some(&b':') {
5750        format!("file:///{encoded}?mode=ro")
5751    } else {
5752        format!("file:{encoded}?mode=ro")
5753    }
5754}
5755
5756fn percent_encode_sqlite_uri_path(path: &str) -> String {
5757    let mut encoded = String::with_capacity(path.len());
5758    for byte in path.bytes() {
5759        match byte {
5760            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5761                encoded.push(byte as char)
5762            }
5763            _ => encoded.push_str(&format!("%{byte:02X}")),
5764        }
5765    }
5766    encoded
5767}
5768
5769fn configure_connection(conn: &Connection) -> Result<()> {
5770    conn.pragma_update(None, "journal_mode", "WAL")?;
5771    let baseline = write_amplification_baseline_enabled();
5772    conn.pragma_update(
5773        None,
5774        "synchronous",
5775        if baseline { "FULL" } else { "NORMAL" },
5776    )?;
5777    conn.pragma_update(
5778        None,
5779        "wal_autocheckpoint",
5780        if baseline {
5781            1_000
5782        } else {
5783            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
5784        },
5785    )?;
5786    conn.pragma_update(None, "busy_timeout", 5_000)?;
5787    Ok(())
5788}
5789
5790fn configure_build_connection(conn: &Connection) -> Result<()> {
5791    conn.pragma_update(None, "journal_mode", "DELETE")?;
5792    conn.pragma_update(
5793        None,
5794        "synchronous",
5795        if write_amplification_baseline_enabled() {
5796            "FULL"
5797        } else {
5798            "NORMAL"
5799        },
5800    )?;
5801    conn.pragma_update(None, "busy_timeout", 5_000)?;
5802    Ok(())
5803}
5804
5805/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
5806/// private temporary copy before publishing it; a busy reader is harmless because
5807/// the next publication or cleanup pass can retry without affecting the source.
5808fn checkpoint_sqlite_before_publication(path: &Path) {
5809    let Ok(conn) = Connection::open(path) else {
5810        return;
5811    };
5812    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
5813    let _ = conn.busy_timeout(Duration::from_secs(5));
5814    let _ = checkpoint_wal_truncate(&conn);
5815}
5816
5817fn checkpoint_wal_truncate(conn: &Connection) -> bool {
5818    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
5819        row.get::<_, i64>(0)
5820    }) {
5821        Ok(0) => true,
5822        Ok(_) => false,
5823        Err(rusqlite::Error::SqliteFailure(error, _))
5824            if matches!(
5825                error.code,
5826                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
5827            ) =>
5828        {
5829            false
5830        }
5831        Err(error) => {
5832            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
5833            false
5834        }
5835    }
5836}
5837
5838fn initialize_schema(conn: &Connection) -> Result<()> {
5839    conn.execute_batch(
5840        "CREATE TABLE IF NOT EXISTS files (
5841            path                TEXT PRIMARY KEY,
5842            content_hash        TEXT NOT NULL,
5843            mtime_ns            INTEGER NOT NULL,
5844            size                INTEGER NOT NULL,
5845            lang                TEXT NOT NULL,
5846            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5847            is_public_api       INTEGER NOT NULL DEFAULT 0,
5848            surface_fingerprint TEXT NOT NULL,
5849            indexed_at          INTEGER NOT NULL
5850        );
5851
5852        CREATE TABLE IF NOT EXISTS nodes (
5853            id                         TEXT PRIMARY KEY,
5854            file_path                  TEXT NOT NULL,
5855            name                       TEXT NOT NULL,
5856            scoped_name                TEXT NOT NULL,
5857            kind                       TEXT NOT NULL,
5858            start_line                 INTEGER NOT NULL,
5859            start_col                  INTEGER NOT NULL,
5860            end_line                   INTEGER NOT NULL,
5861            end_col                    INTEGER NOT NULL,
5862            range_ordinal              INTEGER NOT NULL,
5863            signature                  TEXT,
5864            exported                   INTEGER NOT NULL,
5865            is_default_export          INTEGER NOT NULL,
5866            is_type_like               INTEGER NOT NULL,
5867            is_callgraph_entry_point   INTEGER NOT NULL,
5868            provenance                 TEXT NOT NULL,
5869            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5870        );
5871        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5872        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5873        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5874
5875        CREATE TABLE IF NOT EXISTS refs (
5876            ref_id          TEXT PRIMARY KEY,
5877            caller_node     TEXT,
5878            caller_file     TEXT NOT NULL,
5879            kind            TEXT NOT NULL,
5880            short_name      TEXT,
5881            full_ref        TEXT,
5882            module_path     TEXT,
5883            import_kind     TEXT,
5884            local_name      TEXT,
5885            requested_name  TEXT,
5886            namespace_alias TEXT,
5887            wildcard        INTEGER NOT NULL DEFAULT 0,
5888            line            INTEGER NOT NULL,
5889            byte_start      INTEGER NOT NULL,
5890            byte_end        INTEGER NOT NULL,
5891            status          TEXT NOT NULL,
5892            target_node     TEXT,
5893            target_file     TEXT,
5894            target_symbol   TEXT,
5895            provenance      TEXT NOT NULL
5896        );
5897        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5898        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5899        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5900        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5901        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5902
5903        CREATE TABLE IF NOT EXISTS file_dependencies (
5904            file_path   TEXT NOT NULL,
5905            dep_file    TEXT NOT NULL,
5906            PRIMARY KEY(file_path, dep_file)
5907        );
5908        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5909
5910        CREATE TABLE IF NOT EXISTS edges (
5911            edge_id       TEXT PRIMARY KEY,
5912            ref_id        TEXT NOT NULL,
5913            source_node   TEXT NOT NULL,
5914            target_node   TEXT,
5915            target_file   TEXT NOT NULL,
5916            target_symbol TEXT NOT NULL,
5917            kind          TEXT NOT NULL,
5918            line          INTEGER NOT NULL,
5919            provenance    TEXT NOT NULL
5920        );
5921        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5922        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5923        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5924        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5925
5926        CREATE TABLE IF NOT EXISTS dispatch_hints (
5927            id           TEXT PRIMARY KEY,
5928            method_name  TEXT NOT NULL,
5929            caller_node  TEXT NOT NULL,
5930            file         TEXT NOT NULL,
5931            line         INTEGER NOT NULL,
5932            byte_start   INTEGER NOT NULL,
5933            byte_end     INTEGER NOT NULL,
5934            provenance   TEXT NOT NULL
5935        );
5936        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5937
5938        CREATE TABLE IF NOT EXISTS type_ref_names (
5939            name TEXT PRIMARY KEY
5940        );
5941
5942        CREATE TABLE IF NOT EXISTS backend_file_state (
5943            backend        TEXT NOT NULL,
5944            workspace_root TEXT NOT NULL,
5945            file_path      TEXT NOT NULL,
5946            content_hash   TEXT NOT NULL,
5947            status         TEXT NOT NULL,
5948            updated_at     INTEGER NOT NULL,
5949            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5950        );
5951        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5952
5953        CREATE TABLE IF NOT EXISTS meta (
5954            k TEXT PRIMARY KEY,
5955            v TEXT NOT NULL
5956        );",
5957    )?;
5958    insert_meta(conn)?;
5959    Ok(())
5960}
5961
5962fn insert_meta(conn: &Connection) -> Result<()> {
5963    conn.execute(
5964        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5965        params![SCHEMA_VERSION.to_string()],
5966    )?;
5967    conn.execute(
5968        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5969        params![schema_fingerprint()],
5970    )?;
5971    Ok(())
5972}
5973
5974fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5975    conn.execute(
5976        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5977        params![if ready { "1" } else { "0" }],
5978    )?;
5979    Ok(())
5980}
5981
5982fn database_ready(conn: &Connection) -> Result<bool> {
5983    let schema_version: Option<String> = conn
5984        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5985            row.get(0)
5986        })
5987        .optional()?;
5988    let fingerprint: Option<String> = conn
5989        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5990            row.get(0)
5991        })
5992        .optional()?;
5993    let ready: Option<String> = conn
5994        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5995        .optional()?;
5996
5997    let expected_schema = SCHEMA_VERSION.to_string();
5998    let expected_fingerprint = schema_fingerprint();
5999    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
6000        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
6001        && ready.as_deref() == Some("1"))
6002}
6003
6004fn ensure_database_ready(conn: &Connection) -> Result<()> {
6005    if database_ready(conn)? {
6006        Ok(())
6007    } else {
6008        Err(CallGraphStoreError::Unavailable(
6009            "database is missing, stale, or mid-build".to_string(),
6010        ))
6011    }
6012}
6013
6014fn schema_fingerprint() -> String {
6015    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
6016    // edge sources, broader call extraction) even if the table SHAPE is
6017    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
6018    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
6019    let input =
6020        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
6021    hash_to_hex(blake3::hash(input.as_bytes()))
6022}
6023
6024fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
6025    tx.execute_batch(
6026        "DELETE FROM edges;
6027         DELETE FROM file_dependencies;
6028         DELETE FROM refs;
6029         DELETE FROM dispatch_hints;
6030         DELETE FROM type_ref_names;
6031         DELETE FROM backend_file_state;
6032         DELETE FROM nodes;
6033         DELETE FROM files;",
6034    )?;
6035    Ok(())
6036}
6037
6038fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6039    tx.execute_batch(
6040        "DROP INDEX IF EXISTS idx_nodes_file;
6041         DROP INDEX IF EXISTS idx_nodes_name;
6042         DROP INDEX IF EXISTS idx_nodes_scoped;
6043         DROP INDEX IF EXISTS idx_refs_short_name;
6044         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
6045         DROP INDEX IF EXISTS idx_refs_caller_file;
6046         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
6047         DROP INDEX IF EXISTS idx_refs_target_file;
6048         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
6049         DROP INDEX IF EXISTS idx_edges_source_kind;
6050         DROP INDEX IF EXISTS idx_edges_target_kind;
6051         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
6052         DROP INDEX IF EXISTS idx_edges_ref_id;
6053         DROP INDEX IF EXISTS idx_dispatch_hints_method;
6054         DROP INDEX IF EXISTS idx_backend_file_state_file;",
6055    )?;
6056    Ok(())
6057}
6058
6059fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
6060    tx.execute_batch(
6061        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
6062         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
6063         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
6064         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
6065         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
6066         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
6067         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
6068         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
6069         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
6070         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
6071         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
6072         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
6073         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
6074         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
6075         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
6076    )?;
6077    Ok(())
6078}
6079
6080const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
6081    ("files", "path"),
6082    ("nodes", "file_path"),
6083    ("refs", "caller_file"),
6084    ("refs", "target_file"),
6085    ("file_dependencies", "file_path"),
6086    ("file_dependencies", "dep_file"),
6087    ("edges", "target_file"),
6088    ("dispatch_hints", "file"),
6089    ("backend_file_state", "file_path"),
6090];
6091
6092/// Reconcile `backend_file_state.workspace_root` when the opener's project root
6093/// differs from what is stored. The store key is the git-root commit hash, so
6094/// multiple live checkouts/clones share one on-disk generation.
6095///
6096/// Cheap in-place re-root is only safe when every previously stored root path is
6097/// gone from disk (true move/rename). If any stale root still exists, another
6098/// clone is still alive and rewriting metadata would ping-pong relative rows
6099/// between trees (possibly on different branches). We then return
6100/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
6101/// opener. That can make each clone rebuild on open when they alternate — bounded
6102/// by open frequency — but each rebuild is correct for its opener, unlike silent
6103/// cross-clone corruption.
6104fn reconcile_workspace_roots(
6105    conn: &mut Connection,
6106    project_root: &Path,
6107    allow_repair: bool,
6108) -> Result<OpenRootRepair> {
6109    let roots = stored_workspace_roots(conn)?;
6110    let current_root = project_root.display().to_string();
6111    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
6112        return Ok(OpenRootRepair::None);
6113    }
6114
6115    if let Some(sample) = sample_absolute_data_path(conn)? {
6116        return Ok(OpenRootRepair::NeedsRebuild {
6117            previous_roots: roots,
6118            current_root,
6119            reason: format!("absolute store data path row {sample}"),
6120        });
6121    }
6122
6123    for stored_root in roots.iter() {
6124        if stored_root == &current_root {
6125            continue;
6126        }
6127        if Path::new(stored_root).exists() {
6128            let reason = format!(
6129                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
6130            );
6131            return Ok(OpenRootRepair::NeedsRebuild {
6132                previous_roots: roots,
6133                current_root,
6134                reason,
6135            });
6136        }
6137    }
6138
6139    if !allow_repair {
6140        return Ok(OpenRootRepair::NeedsRebuild {
6141            previous_roots: roots,
6142            current_root,
6143            reason: "workspace root metadata requires deferred repair".to_string(),
6144        });
6145    }
6146
6147    publish_if_current(|| {
6148        let tx = conn.transaction()?;
6149        tx.execute(
6150            "UPDATE OR IGNORE backend_file_state
6151             SET workspace_root = ?1
6152             WHERE workspace_root <> ?1",
6153            params![&current_root],
6154        )?;
6155        tx.execute(
6156            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
6157            params![&current_root],
6158        )?;
6159        tx.commit()?;
6160        Ok(())
6161    })?;
6162
6163    crate::slog_info!(
6164        "callgraph store re-rooted from {} to {}",
6165        roots.join(", "),
6166        current_root
6167    );
6168    Ok(OpenRootRepair::ReRooted)
6169}
6170
6171fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
6172    let mut stmt = conn.prepare(
6173        "SELECT DISTINCT workspace_root
6174         FROM backend_file_state
6175         ORDER BY workspace_root",
6176    )?;
6177    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6178    rows.collect::<std::result::Result<Vec<_>, _>>()
6179        .map_err(Into::into)
6180}
6181
6182fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
6183    for (table, column) in STORE_DATA_PATH_COLUMNS {
6184        let sql = format!(
6185            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
6186        );
6187        let mut stmt = conn.prepare(&sql)?;
6188        let mut rows = stmt.query([])?;
6189        while let Some(row) = rows.next()? {
6190            let value: String = row.get(0)?;
6191            if stored_path_is_absolute(&value) {
6192                return Ok(Some(format!("{table}.{column}={value}")));
6193            }
6194        }
6195    }
6196    Ok(None)
6197}
6198
6199fn stored_path_is_absolute(value: &str) -> bool {
6200    if value.is_empty() {
6201        return false;
6202    }
6203    if Path::new(value).is_absolute() || value.starts_with('/') {
6204        return true;
6205    }
6206    let bytes = value.as_bytes();
6207    if bytes.len() >= 3
6208        && bytes[1] == b':'
6209        && (bytes[2] == b'/' || bytes[2] == b'\\')
6210        && bytes[0].is_ascii_alphabetic()
6211    {
6212        return true;
6213    }
6214    value.starts_with("\\\\") || value.starts_with("//")
6215}
6216
6217fn log_root_repair_rebuild(repair: &OpenRootRepair) {
6218    if let OpenRootRepair::NeedsRebuild {
6219        previous_roots,
6220        current_root,
6221        reason,
6222    } = repair
6223    {
6224        crate::slog_info!(
6225            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
6226            previous_roots.join(", "),
6227            current_root,
6228            reason
6229        );
6230    }
6231}
6232
6233/// Nanosecond clock used to make temp/generation file names unique.
6234fn now_nanos() -> u128 {
6235    SystemTime::now()
6236        .duration_since(UNIX_EPOCH)
6237        .unwrap_or(Duration::ZERO)
6238        .as_nanos()
6239}
6240
6241/// The pointer file `<dir>/<key>.current`. Its single line names the current
6242/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
6243/// can always be atomically replaced via rename even on Windows — Rust opens
6244/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
6245fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6246    callgraph_dir.join(format!("{project_key}.current"))
6247}
6248
6249/// The legacy single-file DB path used before the generation scheme. Still read
6250/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
6251/// build publishes a generation.
6252fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
6253    callgraph_dir.join(format!("{project_key}.sqlite"))
6254}
6255
6256/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
6257/// cold build writes a brand-new generation file, so publishing NEVER replaces
6258/// a file another process holds open (the root Windows fix).
6259fn generation_file_name(project_key: &str) -> String {
6260    format!(
6261        "{project_key}.g{}.{}.sqlite",
6262        now_nanos(),
6263        std::process::id()
6264    )
6265}
6266
6267/// Read the pointer; returns the generation file name if present and non-empty.
6268fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
6269    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
6270    let name = text.trim();
6271    if name.is_empty() {
6272        None
6273    } else {
6274        Some(name.to_string())
6275    }
6276}
6277
6278/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
6279/// `ready` flag). Uses a throwaway read-only connection.
6280fn db_path_ready(path: &Path) -> bool {
6281    (|| -> Result<bool> {
6282        let conn = open_readonly_connection(path)?;
6283        database_ready(&conn)
6284    })()
6285    .unwrap_or(false)
6286}
6287
6288/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
6289/// where `generation` is `Some(name)` for a pointer-published generation or
6290/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
6291/// published (caller treats that as "needs cold build").
6292///
6293/// Handles the GC race (the pointer names a generation that was just deleted) by
6294/// re-reading the pointer and retrying a few times.
6295fn resolve_ready_target(
6296    callgraph_dir: &Path,
6297    project_key: &str,
6298) -> Option<(PathBuf, Option<String>)> {
6299    for _ in 0..5 {
6300        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
6301            let gen_path = callgraph_dir.join(&generation);
6302            if gen_path.is_file() {
6303                return (migration_manifest_valid(callgraph_dir, &generation)
6304                    && db_path_ready(&gen_path))
6305                .then_some((gen_path, Some(generation)));
6306            }
6307            // Pointer names a missing generation (a GC/publish race): re-read the
6308            // pointer and retry rather than failing the reader.
6309            std::thread::sleep(Duration::from_millis(5));
6310            continue;
6311        }
6312        // No pointer: fall back to the legacy single-file DB if it is ready.
6313        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
6314        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
6315    }
6316    None
6317}
6318
6319/// Atomically publish `generation` as the current store by flipping the pointer
6320/// file. Writes a temp file, fsyncs, then renames over the pointer — never
6321/// replacing an open DB file, so it succeeds cross-platform.
6322fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
6323    let pointer = pointer_path(callgraph_dir, project_key);
6324    let tmp = callgraph_dir.join(format!(
6325        "{project_key}.current.tmp.{}.{}",
6326        std::process::id(),
6327        now_nanos()
6328    ));
6329    {
6330        use std::io::Write as _;
6331        let mut file = std::fs::File::create(&tmp)?;
6332        file.write_all(generation.as_bytes())?;
6333        file.write_all(b"\n")?;
6334        file.sync_all()?;
6335    }
6336    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
6337        let _ = std::fs::remove_file(&tmp);
6338        return Err(error.into());
6339    }
6340    crate::fs_lock::sync_parent(&pointer);
6341    Ok(())
6342}
6343
6344#[derive(Clone, Debug)]
6345struct GenerationGcCandidate {
6346    name: String,
6347    path: PathBuf,
6348    modified: SystemTime,
6349}
6350
6351/// Best-effort GC of superseded generation files. The current pointer target and
6352/// newest previous generation are always retained. Older generations are removed
6353/// when they have no protected read marker, or after the absolute retention TTL
6354/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
6355/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
6356fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
6357    let temp_grace = Duration::from_secs(60);
6358    let now = SystemTime::now();
6359    let pointer_current =
6360        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
6361    let gen_prefix = format!("{project_key}.g");
6362    let tmp_prefixes = [
6363        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
6364        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
6365        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
6366    ];
6367    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6368        return;
6369    };
6370    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
6371    for entry in entries.flatten() {
6372        let name = entry.file_name();
6373        let name = name.to_string_lossy().to_string();
6374        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
6375        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
6376
6377        // Orphaned temp files from a crashed build/publish: remove once aged out.
6378        if name.contains(".tmp.") {
6379            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
6380                let _ = std::fs::remove_file(entry.path());
6381            }
6382            continue;
6383        }
6384
6385        // Superseded legacy single-file DB: best-effort delete once a generation
6386        // is published (ignored if another process still holds it open).
6387        if name == format!("{project_key}.sqlite") {
6388            remove_sqlite_file_set(&entry.path());
6389            continue;
6390        }
6391
6392        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
6393            gens.push(GenerationGcCandidate {
6394                name,
6395                path: entry.path(),
6396                modified: mtime,
6397            });
6398        }
6399    }
6400
6401    let mut superseded = gens
6402        .iter()
6403        .filter(|generation| generation.name != pointer_current)
6404        .collect::<Vec<_>>();
6405    superseded.sort_by(|left, right| {
6406        right
6407            .modified
6408            .cmp(&left.modified)
6409            .then_with(|| right.name.cmp(&left.name))
6410    });
6411    let previous = superseded.first().map(|generation| generation.name.clone());
6412
6413    for generation in gens {
6414        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
6415        if generation.name == pointer_current
6416            || Some(generation.name.as_str()) == previous.as_deref()
6417        {
6418            continue;
6419        }
6420
6421        let age = now
6422            .duration_since(generation.modified)
6423            .unwrap_or(Duration::ZERO);
6424        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
6425            continue;
6426        }
6427
6428        remove_sqlite_file_set(&generation.path);
6429        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
6430        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
6431            callgraph_dir,
6432            &generation.name,
6433        ));
6434    }
6435}
6436
6437fn remove_sqlite_file_set(path: &Path) {
6438    let _ = std::fs::remove_file(path);
6439    remove_sqlite_sidecars(path);
6440}
6441
6442fn remove_sqlite_sidecars(path: &Path) {
6443    let path_text = path.to_string_lossy();
6444    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
6445    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
6446    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
6447}
6448
6449/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
6450///
6451/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
6452/// place on success; a build that dies (process kill, crash, host restart) leaves
6453/// the temporary behind. The largest observed cold build finishes well under a
6454/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
6455/// never rename. A live build's temporary is minutes old at most.
6456///
6457/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
6458/// liveness check read false-positive on exactly the oldest files — the ones most
6459/// worth deleting: in production an orphan's embedded pid had been recycled to an
6460/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
6461/// cannot lie that way, so it is the honest orphan predicate.
6462const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
6463
6464/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
6465/// same cadence as [`gc_old_generations`] (after a generation is published) but,
6466/// unlike it, is not scoped to the building root: it covers every directory in the
6467/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
6468///
6469/// That last case is the production hole this fixes. The per-root cleanup in
6470/// [`gc_old_generations`] only fires when a root actually builds, so when activity
6471/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
6472/// store's orphans become permanent — gigabytes accumulated in a legacy store
6473/// whose roots no longer built there, while the active store stayed clean. A
6474/// sibling root that still builds triggers this pass and cleans both layouts.
6475fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
6476    sweep_orphaned_build_temps(callgraph_dir);
6477    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6478        return;
6479    };
6480    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
6481
6482    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
6483    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
6484        for entry in entries.flatten() {
6485            if entry.path().is_dir() {
6486                sweep_orphaned_build_temps(&entry.path());
6487            }
6488        }
6489    }
6490
6491    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
6492    if let Ok(entries) = std::fs::read_dir(&storage_root) {
6493        for entry in entries.flatten() {
6494            let legacy_dir = entry.path().join(domain);
6495            if legacy_dir.is_dir() {
6496                sweep_orphaned_build_temps(&legacy_dir);
6497            }
6498        }
6499    }
6500}
6501
6502/// Sweep one callgraph directory, removing build temporaries older than
6503/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
6504fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
6505    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
6506}
6507
6508/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
6509/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
6510fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
6511    let now = SystemTime::now();
6512    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6513        return;
6514    };
6515    let mut removed_any = false;
6516    for entry in entries.flatten() {
6517        let name = entry.file_name().to_string_lossy().to_string();
6518        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
6519        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
6520        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
6521        // without that substring — a completed `.sqlite` generation, a pointer, a
6522        // read-marker dir — is left alone: those belong to generation GC.
6523        if !name.contains(".sqlite.tmp.") {
6524            continue;
6525        }
6526        let mtime = entry
6527            .metadata()
6528            .and_then(|meta| meta.modified())
6529            .unwrap_or(now);
6530        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
6531            continue;
6532        }
6533        // Deletion races a concurrent build finishing: that build renames the temp
6534        // into place, so the file is gone by the time we unlink. The 24h age makes
6535        // this overlap practically impossible, but treat a missing file as success
6536        // (the rename won) rather than an error, and never touch a path that does
6537        // not match the temporary shape above.
6538        match std::fs::remove_file(entry.path()) {
6539            Ok(()) => removed_any = true,
6540            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
6541            Err(_) => {}
6542        }
6543    }
6544    if removed_any {
6545        crate::fs_lock::sync_parent(callgraph_dir);
6546    }
6547}
6548
6549/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
6550/// the global all-cores rayon pool. The store cold-build is the heaviest
6551/// background pass (parse-dominated) and runs on a separate thread off the
6552/// single-threaded request loop; left unbounded it monopolizes every core and
6553/// starves the bridge so interactive tools time out (the same starvation the
6554/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
6555/// match the main thread, since the extract walks tree-sitter ASTs.
6556fn build_pool_size() -> usize {
6557    std::thread::available_parallelism()
6558        .map(|parallelism| parallelism.get())
6559        .unwrap_or(1)
6560        .div_ceil(2)
6561        .clamp(1, 8)
6562}
6563
6564fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
6565    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
6566        Ok(extract) => Ok(extract),
6567        Err(error) => {
6568            let abs_path =
6569                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
6570            let rel_path = relative_path(project_root, &abs_path);
6571            let freshness = cache_freshness::collect(&abs_path).ok();
6572            log::debug!(
6573                "callgraph store: skipping {} during cold build: {}",
6574                abs_path.display(),
6575                error
6576            );
6577            Err(ExtractFailure {
6578                rel_path,
6579                freshness,
6580            })
6581        }
6582    };
6583
6584    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
6585        files.par_iter().map(extract_one).collect()
6586    };
6587
6588    // Run inside a dedicated bounded pool when one builds; fall back to the
6589    // global pool only if the bounded pool can't be constructed.
6590    let results = match rayon::ThreadPoolBuilder::new()
6591        .num_threads(build_pool_size())
6592        .thread_name(|index| format!("aft-callgraph-build-{index}"))
6593        .stack_size(8 * 1024 * 1024)
6594        .build()
6595    {
6596        Ok(pool) => pool.install(run),
6597        Err(error) => {
6598            log::warn!(
6599                "callgraph store: bounded build pool unavailable ({error}); using global pool"
6600            );
6601            run()
6602        }
6603    };
6604
6605    let mut extracts = Vec::new();
6606    let mut failures = Vec::new();
6607    for result in results {
6608        match result {
6609            Ok(extract) => extracts.push(extract),
6610            Err(failure) => failures.push(failure),
6611        }
6612    }
6613    BuildExtractsResult { extracts, failures }
6614}
6615
6616fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
6617    let metadata = std::fs::metadata(path)?;
6618    let size = metadata.len();
6619    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
6620        cache_freshness::zero_hash()
6621    } else if source.len() as u64 == size {
6622        cache_freshness::hash_bytes(source.as_bytes())
6623    } else {
6624        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
6625    };
6626    Ok(FileFreshness {
6627        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
6628        size,
6629        content_hash,
6630    })
6631}
6632
6633fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
6634    let abs_path = normalize_file_path(project_root, path)?;
6635    let rel_path = relative_path(project_root, &abs_path);
6636    let source = std::fs::read_to_string(&abs_path)?;
6637    let freshness = collect_source_freshness(&abs_path, &source)?;
6638    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
6639    let lang = data.lang;
6640    if lang == LangId::Rust {
6641        extend_rust_imports_with_nested_uses(&source, &mut data);
6642    }
6643    let mut nodes = build_node_records(&rel_path, &source, &data)?;
6644    let node_by_scoped: HashMap<String, String> = nodes
6645        .iter()
6646        .map(|node| (node.scoped_name.clone(), node.id.clone()))
6647        .collect();
6648    let import_dependencies =
6649        import_dependencies(project_root, &abs_path, &data.import_block.imports);
6650    let line_index = LineIndex::new(&source);
6651    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
6652    let rust_reexports = if lang == LangId::Rust {
6653        collect_rust_pub_use_reexport_refs(
6654            project_root,
6655            &abs_path,
6656            &rel_path,
6657            &data.import_block.imports,
6658            &line_index,
6659        )
6660    } else {
6661        ReexportRefs {
6662            raw_refs: Vec::new(),
6663            surface_parts: Vec::new(),
6664        }
6665    };
6666    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
6667    let mut raw_refs = Vec::new();
6668    raw_refs.extend(build_call_refs(
6669        &rel_path,
6670        &data,
6671        &node_by_scoped,
6672        &import_dependencies,
6673    ));
6674    raw_refs.extend(build_import_refs(
6675        project_root,
6676        &abs_path,
6677        &rel_path,
6678        &data.import_block.imports,
6679        &line_index,
6680    ));
6681    let mut surface_parts = reexports.surface_parts;
6682    surface_parts.extend(rust_reexports.surface_parts);
6683    surface_parts.extend(source_less_exports.surface_parts);
6684    raw_refs.extend(reexports.raw_refs);
6685    raw_refs.extend(rust_reexports.raw_refs);
6686    raw_refs.extend(source_less_exports.raw_refs);
6687    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
6688    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
6689
6690    Ok(FileExtract {
6691        rel_path,
6692        freshness,
6693        lang,
6694        data,
6695        nodes,
6696        raw_refs,
6697        dispatch_hints,
6698        surface_fingerprint,
6699    })
6700}
6701
6702fn build_node_records(
6703    rel_path: &str,
6704    source: &str,
6705    data: &FileCallData,
6706) -> Result<Vec<NodeRecord>> {
6707    let mut records = Vec::new();
6708    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6709    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6710    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6711
6712    for (scoped_name, meta) in metadata {
6713        let name = unqualified_name(scoped_name).to_string();
6714        let range = selection_range(source, scoped_name, &name, &meta.range);
6715        let range_key = (
6716            range.start_line,
6717            range.start_col,
6718            range.end_line,
6719            range.end_col,
6720        );
6721        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6722        let range_ordinal = *ordinal;
6723        *ordinal += 1;
6724        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6725        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6726        let is_default_export = data
6727            .default_export_symbol
6728            .as_deref()
6729            .map(|default| default == scoped_name || default == name)
6730            .unwrap_or(false);
6731        records.push(NodeRecord {
6732            id,
6733            file_path: rel_path.to_string(),
6734            name: name.clone(),
6735            scoped_name: scoped_name.clone(),
6736            kind: symbol_kind_label(&meta.kind).to_string(),
6737            range,
6738            range_ordinal,
6739            signature: meta.signature.clone(),
6740            exported,
6741            is_default_export,
6742            is_type_like: is_type_like(&meta.kind),
6743            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6744                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6745        });
6746    }
6747
6748    Ok(records)
6749}
6750
6751fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6752    if scoped_name == TOP_LEVEL_SYMBOL {
6753        return Range {
6754            start_line: 0,
6755            start_col: 0,
6756            end_line: 0,
6757            end_col: 0,
6758        };
6759    }
6760    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6761        return fallback.clone();
6762    };
6763    let start_col = fallback.start_col as usize;
6764    let search_start = start_col.min(line.len());
6765    if let Some(offset) = line[search_start..].find(name) {
6766        let col = search_start + offset;
6767        return Range {
6768            start_line: fallback.start_line,
6769            start_col: col as u32,
6770            end_line: fallback.start_line,
6771            end_col: (col + name.len()) as u32,
6772        };
6773    }
6774    if let Some(offset) = line.find(name) {
6775        return Range {
6776            start_line: fallback.start_line,
6777            start_col: offset as u32,
6778            end_line: fallback.start_line,
6779            end_col: (offset + name.len()) as u32,
6780        };
6781    }
6782    Range {
6783        start_line: fallback.start_line,
6784        start_col: fallback.start_col,
6785        end_line: fallback.start_line,
6786        end_col: fallback.start_col.saturating_add(name.len() as u32),
6787    }
6788}
6789
6790fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6791    if scoped_name == TOP_LEVEL_SYMBOL {
6792        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6793    }
6794    let input = format!(
6795        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6796        range.start_line, range.start_col, range.end_line, range.end_col
6797    );
6798    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6799}
6800
6801fn build_call_refs(
6802    rel_path: &str,
6803    data: &FileCallData,
6804    node_by_scoped: &HashMap<String, String>,
6805    import_dependencies: &BTreeSet<String>,
6806) -> Vec<RawRef> {
6807    let mut refs = Vec::new();
6808    let mut ordinal = 0usize;
6809    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6810    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6811    for (caller_symbol, call_sites) in symbols {
6812        let caller_node = node_by_scoped.get(caller_symbol).cloned();
6813        for call_site in call_sites {
6814            ordinal += 1;
6815            let ref_id = ref_id(&[
6816                rel_path,
6817                "call",
6818                caller_symbol,
6819                &call_site.line.to_string(),
6820                &call_site.byte_start.to_string(),
6821                &call_site.byte_end.to_string(),
6822                &call_site.full_callee,
6823                &ordinal.to_string(),
6824            ]);
6825            refs.push(RawRef {
6826                ref_id,
6827                caller_node: caller_node.clone(),
6828                caller_symbol: Some(caller_symbol.clone()),
6829                caller_file: rel_path.to_string(),
6830                kind: "call".to_string(),
6831                short_name: Some(call_site.callee_name.clone()),
6832                full_ref: Some(call_site.full_callee.clone()),
6833                module_path: None,
6834                import_kind: None,
6835                local_name: Some(call_site.callee_name.clone()),
6836                requested_name: Some(call_site.callee_name.clone()),
6837                namespace_alias: namespace_alias(&call_site.full_callee),
6838                wildcard: false,
6839                line: call_site.line,
6840                byte_start: call_site.byte_start,
6841                byte_end: call_site.byte_end,
6842                dependencies: import_dependencies.clone(),
6843            });
6844        }
6845    }
6846    refs
6847}
6848
6849fn build_import_refs(
6850    project_root: &Path,
6851    abs_path: &Path,
6852    rel_path: &str,
6853    imports: &[ImportStatement],
6854    line_index: &LineIndex,
6855) -> Vec<RawRef> {
6856    let mut refs = Vec::new();
6857    for (index, import) in imports.iter().enumerate() {
6858        let import_kind = import_kind_label(import.kind).to_string();
6859        let local_name = import_local_names(import).join(",");
6860        let requested_name = import_requested_names(import).join(",");
6861        let ref_id = ref_id(&[
6862            rel_path,
6863            "import",
6864            &import.byte_range.start.to_string(),
6865            &import.byte_range.end.to_string(),
6866            &import.module_path,
6867            &index.to_string(),
6868        ]);
6869        refs.push(RawRef {
6870            ref_id,
6871            caller_node: None,
6872            caller_symbol: None,
6873            caller_file: rel_path.to_string(),
6874            kind: "import".to_string(),
6875            short_name: None,
6876            full_ref: Some(import.raw_text.clone()),
6877            module_path: Some(import.module_path.clone()),
6878            import_kind: Some(import_kind),
6879            local_name: empty_to_none(local_name),
6880            requested_name: empty_to_none(requested_name),
6881            namespace_alias: import.namespace_import.clone(),
6882            wildcard: import_is_wildcard(import),
6883            line: line_index.byte_to_line(import.byte_range.start),
6884            byte_start: import.byte_range.start,
6885            byte_end: import.byte_range.end,
6886            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6887        });
6888    }
6889    refs
6890}
6891
6892fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6893    let grammar = grammar_for(LangId::Rust);
6894    let mut parser = Parser::new();
6895    if parser.set_language(&grammar).is_err() {
6896        return;
6897    }
6898    let Some(tree) = parser.parse(source, None) else {
6899        return;
6900    };
6901
6902    let mut seen = data
6903        .import_block
6904        .imports
6905        .iter()
6906        .map(|import| (import.byte_range.start, import.byte_range.end))
6907        .collect::<HashSet<_>>();
6908    let mut nested_imports = Vec::new();
6909    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6910    if nested_imports.is_empty() {
6911        return;
6912    }
6913
6914    data.import_block.imports.extend(nested_imports);
6915    data.import_block
6916        .imports
6917        .sort_by_key(|import| import.byte_range.start);
6918    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6919}
6920
6921fn collect_rust_use_imports(
6922    source: &str,
6923    node: Node<'_>,
6924    seen: &mut HashSet<(usize, usize)>,
6925    imports: &mut Vec<ImportStatement>,
6926) {
6927    if node.kind() == "use_declaration" {
6928        let range = node.byte_range();
6929        if seen.insert((range.start, range.end)) {
6930            if let Some(import) = rust_import_from_use_node(source, node) {
6931                imports.push(import);
6932            }
6933        }
6934    }
6935
6936    let mut cursor = node.walk();
6937    if !cursor.goto_first_child() {
6938        return;
6939    }
6940    loop {
6941        collect_rust_use_imports(source, cursor.node(), seen, imports);
6942        if !cursor.goto_next_sibling() {
6943            break;
6944        }
6945    }
6946}
6947
6948fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6949    let raw_text = source[node.byte_range()].to_string();
6950    let body = rust_use_body(&raw_text)?.to_string();
6951    let visibility = rust_use_visibility(&raw_text);
6952    let names = rust_use_list_names(&body);
6953    let group = classify_rust_import_group(&body);
6954    let byte_range = node.byte_range();
6955
6956    Some(ImportStatement {
6957        module_path: body,
6958        names: names.clone(),
6959        default_import: visibility.clone(),
6960        namespace_import: None,
6961        kind: ImportKind::Value,
6962        group,
6963        byte_range,
6964        raw_text,
6965        form: ImportForm::RustUse {
6966            visibility,
6967            named: names,
6968        },
6969    })
6970}
6971
6972fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6973    let start = imports.iter().map(|import| import.byte_range.start).min()?;
6974    let end = imports.iter().map(|import| import.byte_range.end).max()?;
6975    Some(start..end)
6976}
6977
6978fn rust_use_visibility(raw_text: &str) -> Option<String> {
6979    let use_pos = raw_text.find("use ")?;
6980    let prefix = raw_text[..use_pos].trim();
6981    if prefix.is_empty() {
6982        None
6983    } else {
6984        Some(prefix.to_string())
6985    }
6986}
6987
6988fn rust_use_body(raw_text: &str) -> Option<&str> {
6989    let use_pos = raw_text.find("use ")?;
6990    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6991}
6992
6993fn rust_use_list_names(body: &str) -> Vec<String> {
6994    let Some(open) = body.find("::{") else {
6995        return Vec::new();
6996    };
6997    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6998        return Vec::new();
6999    };
7000    body[open + 3..close]
7001        .split(',')
7002        .filter_map(|spec| {
7003            let spec = spec.trim();
7004            if spec.is_empty() {
7005                None
7006            } else {
7007                Some(spec.to_string())
7008            }
7009        })
7010        .collect()
7011}
7012
7013fn classify_rust_import_group(body: &str) -> ImportGroup {
7014    let first = body
7015        .split("::")
7016        .next()
7017        .unwrap_or(body)
7018        .split_whitespace()
7019        .next()
7020        .unwrap_or(body);
7021    match first.trim() {
7022        "std" | "core" | "alloc" => ImportGroup::Stdlib,
7023        "crate" | "self" | "super" => ImportGroup::Internal,
7024        _ => ImportGroup::External,
7025    }
7026}
7027
7028#[derive(Debug, Clone)]
7029struct ReexportRefs {
7030    raw_refs: Vec<RawRef>,
7031    surface_parts: Vec<String>,
7032}
7033
7034fn collect_reexport_refs(
7035    project_root: &Path,
7036    abs_path: &Path,
7037    rel_path: &str,
7038    source: &str,
7039) -> ReexportRefs {
7040    let mut raw_refs = Vec::new();
7041    let mut surface_parts = Vec::new();
7042    let mut search_start = 0usize;
7043    let mut ordinal = 0usize;
7044    while let Some(export_offset) = source[search_start..].find("export") {
7045        let start = search_start + export_offset;
7046        let Some(statement_end_offset) = source[start..].find(';') else {
7047            break;
7048        };
7049        let end = start + statement_end_offset + 1;
7050        let statement = &source[start..end];
7051        search_start = end;
7052        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
7053            continue;
7054        }
7055        let Some(module_path) = quoted_module_path(statement) else {
7056            continue;
7057        };
7058        ordinal += 1;
7059        let wildcard = statement.contains('*');
7060        let line = source[..start]
7061            .bytes()
7062            .filter(|byte| *byte == b'\n')
7063            .count() as u32
7064            + 1;
7065        let ref_id = ref_id(&[
7066            rel_path,
7067            "reexport",
7068            &start.to_string(),
7069            &end.to_string(),
7070            &module_path,
7071            &ordinal.to_string(),
7072        ]);
7073        surface_parts.push(format!("reexport\t{statement}"));
7074        raw_refs.push(RawRef {
7075            ref_id,
7076            caller_node: None,
7077            caller_symbol: None,
7078            caller_file: rel_path.to_string(),
7079            kind: "reexport".to_string(),
7080            short_name: None,
7081            full_ref: Some(statement.to_string()),
7082            module_path: Some(module_path.clone()),
7083            import_kind: Some("reexport".to_string()),
7084            local_name: None,
7085            requested_name: None,
7086            namespace_alias: None,
7087            wildcard,
7088            line,
7089            byte_start: start,
7090            byte_end: end,
7091            dependencies: module_dependencies(project_root, abs_path, &module_path),
7092        });
7093    }
7094    ReexportRefs {
7095        raw_refs,
7096        surface_parts,
7097    }
7098}
7099
7100fn collect_rust_pub_use_reexport_refs(
7101    project_root: &Path,
7102    abs_path: &Path,
7103    rel_path: &str,
7104    imports: &[ImportStatement],
7105    line_index: &LineIndex,
7106) -> ReexportRefs {
7107    let mut raw_refs = Vec::new();
7108    let mut surface_parts = Vec::new();
7109    let mut ordinal = 0usize;
7110
7111    for import in imports {
7112        let Some(visibility) = &import.default_import else {
7113            continue;
7114        };
7115        if !visibility.starts_with("pub") {
7116            continue;
7117        }
7118        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
7119            continue;
7120        };
7121        ordinal += 1;
7122        let ref_id = ref_id(&[
7123            rel_path,
7124            "rust_reexport",
7125            &import.byte_range.start.to_string(),
7126            &import.byte_range.end.to_string(),
7127            &module_path,
7128            &ordinal.to_string(),
7129        ]);
7130        surface_parts.push(format!("reexport\t{}", import.raw_text));
7131        raw_refs.push(RawRef {
7132            ref_id,
7133            caller_node: None,
7134            caller_symbol: None,
7135            caller_file: rel_path.to_string(),
7136            kind: "reexport".to_string(),
7137            short_name: None,
7138            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
7139            module_path: Some(module_path.clone()),
7140            import_kind: Some("reexport".to_string()),
7141            local_name: None,
7142            requested_name: None,
7143            namespace_alias: None,
7144            wildcard,
7145            line: line_index.byte_to_line(import.byte_range.start),
7146            byte_start: import.byte_range.start,
7147            byte_end: import.byte_range.end,
7148            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
7149        });
7150    }
7151
7152    ReexportRefs {
7153        raw_refs,
7154        surface_parts,
7155    }
7156}
7157
7158fn rust_pub_use_reexport_parts(
7159    import: &ImportStatement,
7160) -> Option<(String, HashMap<String, String>, bool)> {
7161    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
7162    let body = body.trim();
7163    if let Some(module_path) = body.strip_suffix("::*") {
7164        return Some((module_path.trim().to_string(), HashMap::new(), true));
7165    }
7166
7167    if let Some(brace_start) = body.find("::{") {
7168        let module_path = body[..brace_start].trim().to_string();
7169        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
7170        if names.is_empty() {
7171            return None;
7172        }
7173        return Some((module_path, names, false));
7174    }
7175
7176    let (module_path, spec) = body.rsplit_once("::")?;
7177    let names = rust_reexport_names_from_specs(spec);
7178    if names.is_empty() {
7179        return None;
7180    }
7181    Some((module_path.trim().to_string(), names, false))
7182}
7183
7184fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
7185    let mut names = HashMap::new();
7186    for spec in specs.split(',') {
7187        let spec = spec.trim();
7188        if spec.is_empty() || spec == "self" {
7189            continue;
7190        }
7191        if let Some((source, local)) = spec.split_once(" as ") {
7192            let source = source.trim();
7193            let local = local.trim();
7194            if !source.is_empty() && !local.is_empty() && source != "self" {
7195                names.insert(local.to_string(), source.to_string());
7196            }
7197        } else {
7198            names.insert(spec.to_string(), spec.to_string());
7199        }
7200    }
7201    names
7202}
7203
7204fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
7205    if named.is_empty() {
7206        return fallback.to_string();
7207    }
7208    let mut specs = named
7209        .iter()
7210        .map(|(local, source)| {
7211            if local == source {
7212                source.clone()
7213            } else {
7214                format!("{source} as {local}")
7215            }
7216        })
7217        .collect::<Vec<_>>();
7218    specs.sort();
7219    format!("pub use {{{}}};", specs.join(", "))
7220}
7221
7222fn quoted_module_path(statement: &str) -> Option<String> {
7223    let quote = match (statement.find('\''), statement.find('"')) {
7224        (Some(single), Some(double)) if single < double => '\'',
7225        (Some(_), Some(_)) => '"',
7226        (Some(_), None) => '\'',
7227        (None, Some(_)) => '"',
7228        (None, None) => return None,
7229    };
7230    let start = statement.find(quote)? + 1;
7231    let end = statement[start..].find(quote)? + start;
7232    Some(statement[start..end].to_string())
7233}
7234
7235#[derive(Debug, Clone)]
7236struct SourceLessExportRefs {
7237    raw_refs: Vec<RawRef>,
7238    surface_parts: Vec<String>,
7239}
7240
7241fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
7242    let mut raw_refs = Vec::new();
7243    let mut surface_parts = Vec::new();
7244    let mut search_start = 0usize;
7245    let mut ordinal = 0usize;
7246    while let Some(export_offset) = source[search_start..].find("export") {
7247        let start = search_start + export_offset;
7248        let Some(statement_end_offset) = source[start..].find(';') else {
7249            break;
7250        };
7251        let end = start + statement_end_offset + 1;
7252        let statement = &source[start..end];
7253        search_start = end;
7254        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
7255            continue;
7256        }
7257        let aliases = parse_reexport_names(statement);
7258        if aliases.is_empty() {
7259            continue;
7260        }
7261        let line = source[..start]
7262            .bytes()
7263            .filter(|byte| *byte == b'\n')
7264            .count() as u32
7265            + 1;
7266        for (exported, source_symbol) in aliases {
7267            ordinal += 1;
7268            let ref_id = ref_id(&[
7269                rel_path,
7270                "export_alias",
7271                &start.to_string(),
7272                &end.to_string(),
7273                &exported,
7274                &source_symbol,
7275                &ordinal.to_string(),
7276            ]);
7277            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
7278            raw_refs.push(RawRef {
7279                ref_id,
7280                caller_node: None,
7281                caller_symbol: None,
7282                caller_file: rel_path.to_string(),
7283                kind: "export_alias".to_string(),
7284                short_name: None,
7285                full_ref: Some(statement.to_string()),
7286                module_path: None,
7287                import_kind: Some("export_alias".to_string()),
7288                local_name: Some(exported),
7289                requested_name: Some(source_symbol),
7290                namespace_alias: None,
7291                wildcard: false,
7292                line,
7293                byte_start: start,
7294                byte_end: end,
7295                dependencies: BTreeSet::new(),
7296            });
7297        }
7298    }
7299    SourceLessExportRefs {
7300        raw_refs,
7301        surface_parts,
7302    }
7303}
7304
7305fn build_dispatch_hints(
7306    rel_path: &str,
7307    data: &FileCallData,
7308    node_by_scoped: &HashMap<String, String>,
7309) -> Vec<DispatchHint> {
7310    let mut hints = Vec::new();
7311    let mut ordinal = 0usize;
7312    for (caller_symbol, call_sites) in &data.calls_by_symbol {
7313        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
7314            continue;
7315        };
7316        for call_site in call_sites {
7317            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
7318                continue;
7319            }
7320            ordinal += 1;
7321            hints.push(DispatchHint {
7322                id: ref_id(&[
7323                    rel_path,
7324                    "dispatch",
7325                    caller_symbol,
7326                    &call_site.line.to_string(),
7327                    &call_site.byte_start.to_string(),
7328                    &call_site.byte_end.to_string(),
7329                    &ordinal.to_string(),
7330                ]),
7331                method_name: call_site.callee_name.clone(),
7332                caller_node: caller_node.clone(),
7333                file: rel_path.to_string(),
7334                line: call_site.line,
7335                byte_start: call_site.byte_start,
7336                byte_end: call_site.byte_end,
7337            });
7338        }
7339    }
7340    hints
7341}
7342
7343fn surface_fingerprint(
7344    nodes: &mut [NodeRecord],
7345    data: &FileCallData,
7346    reexport_parts: &[String],
7347) -> String {
7348    nodes.sort_by(|left, right| {
7349        (left.file_path.as_str(), left.scoped_name.as_str())
7350            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
7351    });
7352    let mut parts = Vec::new();
7353    for node in nodes.iter() {
7354        parts.push(format!(
7355            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
7356            node.scoped_name,
7357            node.name,
7358            node.kind,
7359            node.exported,
7360            node.range.start_line,
7361            node.range.start_col,
7362            node.range.end_line,
7363            node.range.end_col,
7364            node.range_ordinal,
7365            node.signature.as_deref().unwrap_or("")
7366        ));
7367    }
7368    let mut exports = data.exported_symbols.clone();
7369    exports.sort();
7370    for export in exports {
7371        parts.push(format!("export\t{export}"));
7372    }
7373    if let Some(default_export) = &data.default_export_symbol {
7374        parts.push(format!("default\t{default_export}"));
7375    }
7376    let mut imports: Vec<String> = data
7377        .import_block
7378        .imports
7379        .iter()
7380        .map(|import| {
7381            format!(
7382                "import\t{}\t{:?}\t{}",
7383                import.module_path, import.form, import.raw_text
7384            )
7385        })
7386        .collect();
7387    imports.sort();
7388    parts.extend(imports);
7389    parts.extend(reexport_parts.iter().cloned());
7390    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
7391}
7392
7393fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
7394    if raw.kind != "call" {
7395        return Ok(ResolvedRef {
7396            dependencies: raw.dependencies.clone(),
7397            raw,
7398            status: "unresolved".to_string(),
7399            target_node: None,
7400            target_file: None,
7401            target_symbol: None,
7402            edge: None,
7403        });
7404    }
7405
7406    let caller_file = raw.caller_file.clone();
7407    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
7408        CallGraphStoreError::MissingCallerData {
7409            file: caller_file.clone(),
7410        }
7411    })?;
7412    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
7413    let short_name = raw.short_name.as_deref().unwrap_or_default();
7414    let mut dependencies = raw.dependencies.clone();
7415
7416    let resolved = match index.lang_for(&caller_file) {
7417        Some(LangId::Rust) => {
7418            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
7419        }
7420        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
7421            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
7422        }
7423        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
7424    };
7425
7426    let Some((status, target_file, target_symbol)) = resolved else {
7427        return Ok(ResolvedRef {
7428            raw,
7429            status: "unresolved".to_string(),
7430            target_node: None,
7431            target_file: None,
7432            target_symbol: None,
7433            dependencies,
7434            edge: None,
7435        });
7436    };
7437
7438    dependencies.insert(target_file.clone());
7439    let target_node = index.node_for_symbol(&target_file, &target_symbol);
7440    let source_node = raw.caller_node.clone();
7441    let edge = if let Some(source_node) = source_node {
7442        if target_file == caller_file
7443            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
7444        {
7445            None
7446        } else {
7447            Some(EdgeRecord {
7448                edge_id: ref_id(&[&raw.ref_id, "edge"]),
7449                source_node,
7450                target_node: target_node.clone(),
7451                target_file: target_file.clone(),
7452                target_symbol: target_symbol.clone(),
7453                kind: "call".to_string(),
7454                line: raw.line,
7455            })
7456        }
7457    } else {
7458        None
7459    };
7460
7461    Ok(ResolvedRef {
7462        raw,
7463        status,
7464        target_node,
7465        target_file: Some(target_file),
7466        target_symbol: Some(target_symbol),
7467        dependencies,
7468        edge,
7469    })
7470}
7471
7472fn resolve_js_ts_target(
7473    index: &ProjectIndex<'_>,
7474    caller_file: &str,
7475    full_ref: &str,
7476    short_name: &str,
7477    caller_data: &FileCallData,
7478) -> Option<(String, String, String)> {
7479    if let Some((namespace, member)) = full_ref.split_once('.') {
7480        for import in &caller_data.import_block.imports {
7481            if import.namespace_import.as_deref() == Some(namespace) {
7482                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7483                    if let Some((file, symbol)) =
7484                        resolve_exported_symbol(index, &target_file, member, 0)
7485                    {
7486                        return Some(("resolved".to_string(), file, symbol));
7487                    }
7488                }
7489            }
7490        }
7491    }
7492
7493    for import in &caller_data.import_block.imports {
7494        for spec in &import.names {
7495            if crate::imports::specifier_local_name(spec) == short_name {
7496                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7497                    let requested = crate::imports::specifier_imported_name(spec);
7498                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
7499                        .unwrap_or_else(|| (target_file, requested.to_string()));
7500                    return Some(("resolved".to_string(), file, symbol));
7501                }
7502            }
7503        }
7504
7505        if import.default_import.as_deref() == Some(short_name) {
7506            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7507                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
7508                    .or_else(|| {
7509                        index
7510                            .files
7511                            .get(&target_file)
7512                            .and_then(|file| file.default_export.clone())
7513                            .map(|symbol| (target_file.clone(), symbol))
7514                    })
7515                    .unwrap_or_else(|| {
7516                        let file_name = Path::new(&target_file)
7517                            .file_name()
7518                            .and_then(|name| name.to_str())
7519                            .unwrap_or("unknown")
7520                            .to_string();
7521                        (target_file, format!("<default:{file_name}>"))
7522                    });
7523                return Some(("resolved".to_string(), file, symbol));
7524            }
7525        }
7526    }
7527
7528    for import in &caller_data.import_block.imports {
7529        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
7530            if index
7531                .files
7532                .get(&target_file)
7533                .map(|file| file.exports.contains(short_name))
7534                .unwrap_or(false)
7535            {
7536                return Some(("resolved".to_string(), target_file, short_name.to_string()));
7537            }
7538        }
7539    }
7540
7541    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7542}
7543
7544fn resolve_exported_symbol(
7545    index: &ProjectIndex<'_>,
7546    file: &str,
7547    requested: &str,
7548    depth: usize,
7549) -> Option<(String, String)> {
7550    let mut visited = std::collections::HashMap::new();
7551    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
7552}
7553
7554/// Re-export graphs are frequently cyclic (barrel files re-exporting each
7555/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
7556/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
7557/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
7558/// (file, symbol) pair — but only when the earlier visit had at least as much
7559/// remaining depth budget (a shallower re-visit can reach leaves the deeper
7560/// first visit had to cut off at the cap, so plain visited-set pruning would
7561/// lose resolutions the capped walk finds).
7562fn resolve_exported_symbol_inner(
7563    index: &ProjectIndex<'_>,
7564    file: &str,
7565    requested: &str,
7566    depth: usize,
7567    visited: &mut std::collections::HashMap<(String, String), usize>,
7568) -> Option<(String, String)> {
7569    if depth > 16 {
7570        return None;
7571    }
7572    if requested != "default" {
7573        if let Some(source_symbol) = index
7574            .files
7575            .get(file)
7576            .and_then(|item| item.export_aliases.get(requested))
7577        {
7578            return Some((file.to_string(), source_symbol.clone()));
7579        }
7580        if index
7581            .files
7582            .get(file)
7583            .map(|item| item.exports.contains(requested))
7584            .unwrap_or(false)
7585        {
7586            return Some((file.to_string(), requested.to_string()));
7587        }
7588    } else if let Some(default) = index
7589        .files
7590        .get(file)
7591        .and_then(|item| item.default_export.clone())
7592    {
7593        return Some((file.to_string(), default));
7594    }
7595
7596    // Memo check sits after the local-export fast paths: the common direct
7597    // hit never allocates the key, and a hit through the memo would have
7598    // returned above anyway.
7599    match visited.entry((file.to_string(), requested.to_string())) {
7600        std::collections::hash_map::Entry::Occupied(mut seen) => {
7601            if *seen.get() <= depth {
7602                return None;
7603            }
7604            seen.insert(depth);
7605        }
7606        std::collections::hash_map::Entry::Vacant(slot) => {
7607            slot.insert(depth);
7608        }
7609    }
7610
7611    for reexport in index.reexports_for(file) {
7612        let mut next_requested = requested.to_string();
7613        let matches = if reexport.wildcard {
7614            true
7615        } else if let Some(source_name) = reexport.named.get(requested) {
7616            next_requested = source_name.clone();
7617            true
7618        } else {
7619            false
7620        };
7621        if !matches {
7622            continue;
7623        }
7624        if let Some(target_file) = &reexport.target_file {
7625            if let Some(target) = resolve_exported_symbol_inner(
7626                index,
7627                target_file,
7628                &next_requested,
7629                depth + 1,
7630                visited,
7631            ) {
7632                return Some(target);
7633            }
7634        }
7635    }
7636    None
7637}
7638
7639fn resolve_rust_target(
7640    index: &ProjectIndex<'_>,
7641    caller_file: &str,
7642    full_ref: &str,
7643    short_name: &str,
7644    caller_data: &FileCallData,
7645    raw: &RawRef,
7646) -> Option<(String, String, String)> {
7647    if full_ref.contains("::") {
7648        if let Some((target_file, target_symbol)) =
7649            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
7650        {
7651            return Some(("resolved".to_string(), target_file, target_symbol));
7652        }
7653    }
7654
7655    for import in &caller_data.import_block.imports {
7656        if let Some((target_file, target_symbol)) =
7657            rust_target_for_use(index, caller_file, import, short_name)
7658        {
7659            return Some(("resolved".to_string(), target_file, target_symbol));
7660        }
7661    }
7662
7663    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
7664}
7665
7666fn rust_target_for_qualified(
7667    index: &ProjectIndex<'_>,
7668    caller_file: &str,
7669    full_ref: &str,
7670    short_name: &str,
7671    caller_data: &FileCallData,
7672    raw: &RawRef,
7673) -> Option<(String, String)> {
7674    let mut segments: Vec<&str> = full_ref.split("::").collect();
7675    if segments.len() < 2 {
7676        return None;
7677    }
7678    segments.pop();
7679    let requested_symbol = rust_target_symbol(full_ref, short_name);
7680
7681    for path in rust_module_path_candidates(&segments, caller_data, raw) {
7682        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
7683        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
7684            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
7685                return Some(rust_resolve_reexport_if_symbol_missing(
7686                    index,
7687                    target_file,
7688                    requested_symbol.clone(),
7689                ));
7690            }
7691        }
7692
7693        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
7694        if let Some(target) =
7695            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
7696        {
7697            return Some(target);
7698        }
7699        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
7700            return Some(rust_resolve_reexport_if_symbol_missing(
7701                index,
7702                target_file,
7703                requested_symbol.clone(),
7704            ));
7705        }
7706    }
7707    None
7708}
7709
7710fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7711    full_ref
7712        .rsplit("::")
7713        .next()
7714        .filter(|name| !name.is_empty())
7715        .unwrap_or(short_name)
7716        .to_string()
7717}
7718
7719fn rust_resolve_reexport_if_symbol_missing(
7720    index: &ProjectIndex<'_>,
7721    target_file: String,
7722    target_symbol: String,
7723) -> (String, String) {
7724    if index
7725        .node_for_symbol(&target_file, &target_symbol)
7726        .is_some()
7727    {
7728        return (target_file, target_symbol);
7729    }
7730    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7731        resolved
7732    } else {
7733        (target_file, target_symbol)
7734    }
7735}
7736
7737fn rust_module_path_candidates(
7738    segments: &[&str],
7739    caller_data: &FileCallData,
7740    raw: &RawRef,
7741) -> Vec<Vec<String>> {
7742    let mut candidates = Vec::new();
7743    if let Some(first) = segments.first().copied() {
7744        for import in &caller_data.import_block.imports {
7745            if !rust_import_is_visible_to_call(import, raw) {
7746                continue;
7747            }
7748            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7749                continue;
7750            };
7751            if local_name == first {
7752                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7753                rust_push_unique_path_candidate(&mut candidates, path_segments);
7754            }
7755        }
7756    }
7757    rust_push_unique_path_candidate(
7758        &mut candidates,
7759        segments
7760            .iter()
7761            .map(|segment| (*segment).to_string())
7762            .collect(),
7763    );
7764    candidates
7765}
7766
7767fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7768    if !candidates.iter().any(|existing| existing == &candidate) {
7769        candidates.push(candidate);
7770    }
7771}
7772
7773fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7774    import.byte_range.start <= raw.byte_start
7775}
7776
7777fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7778    let path = import.module_path.trim().trim_end_matches(';').trim();
7779    if path.contains("::{") || path.contains('{') || path.contains('*') {
7780        return None;
7781    }
7782    let (path_without_alias, alias) = path
7783        .split_once(" as ")
7784        .map(|(left, right)| (left.trim(), Some(right.trim())))
7785        .unwrap_or((path, None));
7786    let segments = path_without_alias
7787        .split("::")
7788        .map(str::trim)
7789        .filter(|segment| !segment.is_empty())
7790        .collect::<Vec<_>>();
7791    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7792    if local_name.chars().next().is_some_and(char::is_uppercase) {
7793        return None;
7794    }
7795    Some((
7796        local_name,
7797        segments
7798            .into_iter()
7799            .map(|segment| segment.to_string())
7800            .collect(),
7801    ))
7802}
7803
7804fn rust_inline_scoped_target(
7805    index: &ProjectIndex<'_>,
7806    caller_file: &str,
7807    module_segments: &[String],
7808    short_name: &str,
7809) -> Option<(String, String)> {
7810    let src_prefix = rust_src_prefix(caller_file);
7811    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7812    file_paths.sort();
7813    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7814        let caller = file_paths.remove(position);
7815        file_paths.insert(0, caller);
7816    }
7817
7818    for file_path in file_paths {
7819        if index.lang_for(&file_path) != Some(LangId::Rust)
7820            || rust_src_prefix(&file_path) != src_prefix
7821        {
7822            continue;
7823        }
7824        let file_module_segments = rust_module_segments_for_rel(&file_path);
7825        if !module_segments.starts_with(&file_module_segments) {
7826            continue;
7827        }
7828        let scoped_segments = &module_segments[file_module_segments.len()..];
7829        if scoped_segments.is_empty() {
7830            continue;
7831        }
7832        let mut scoped_symbol = scoped_segments.join("::");
7833        scoped_symbol.push_str("::");
7834        scoped_symbol.push_str(short_name);
7835        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7836            return Some((file_path, scoped_symbol));
7837        }
7838    }
7839    None
7840}
7841
7842fn rust_target_for_use(
7843    index: &ProjectIndex<'_>,
7844    caller_file: &str,
7845    import: &ImportStatement,
7846    short_name: &str,
7847) -> Option<(String, String)> {
7848    let path = import.module_path.trim().trim_end_matches(';');
7849    if let Some(brace_start) = path.find("::{") {
7850        let prefix = &path[..brace_start];
7851        if import.names.iter().any(|name| name == short_name) {
7852            let prefix_segments: Vec<&str> = prefix.split("::").collect();
7853            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7854            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7855            return Some((file, short_name.to_string()));
7856        }
7857        return None;
7858    }
7859
7860    let (path_without_alias, alias) = path
7861        .split_once(" as ")
7862        .map(|(left, right)| (left.trim(), Some(right.trim())))
7863        .unwrap_or((path, None));
7864    let segments: Vec<&str> = path_without_alias.split("::").collect();
7865    let imported = alias.or_else(|| segments.last().copied())?;
7866    if imported != short_name {
7867        return None;
7868    }
7869    if segments.len() < 2 {
7870        return None;
7871    }
7872    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7873    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7874    Some((file, segments.last().unwrap_or(&short_name).to_string()))
7875}
7876
7877fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7878    let crate_name = segments.first().copied()?;
7879    let src_prefix = index.crate_src_prefix(crate_name)?;
7880    let module_segments = segments[1..]
7881        .iter()
7882        .map(|segment| segment.to_string())
7883        .collect::<Vec<_>>();
7884    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7885}
7886
7887#[cfg(test)]
7888static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7889    OnceLock::new();
7890
7891#[cfg(test)]
7892fn note_workspace_crate_prefix_build(project_root: &Path) {
7893    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7894        .get_or_init(|| Mutex::new(HashMap::new()))
7895        .lock()
7896        .expect("workspace crate prefix build counts mutex poisoned");
7897    *counts.entry(project_root.to_path_buf()).or_default() += 1;
7898}
7899
7900#[cfg(not(test))]
7901fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7902
7903#[cfg(test)]
7904fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7905    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7906        .get_or_init(|| Mutex::new(HashMap::new()))
7907        .lock()
7908        .expect("workspace crate prefix build counts mutex poisoned")
7909        .remove(project_root);
7910}
7911
7912#[cfg(test)]
7913fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7914    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7915        .get_or_init(|| Mutex::new(HashMap::new()))
7916        .lock()
7917        .expect("workspace crate prefix build counts mutex poisoned")
7918        .get(project_root)
7919        .copied()
7920        .unwrap_or(0)
7921}
7922
7923/// Walk the project tree once and map every Rust crate name (package name with
7924/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
7925/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
7926/// longer re-walks the filesystem once per ref.
7927fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7928    note_workspace_crate_prefix_build(project_root);
7929    let mut prefixes = HashMap::new();
7930    let mut stack = vec![project_root.to_path_buf()];
7931    while let Some(dir) = stack.pop() {
7932        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7933        if matches!(name, "target" | "node_modules" | ".git") {
7934            continue;
7935        }
7936        let manifest = dir.join("Cargo.toml");
7937        if manifest.is_file() {
7938            let crate_names = rust_manifest_crate_names(&manifest);
7939            if !crate_names.is_empty() {
7940                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7941                for crate_name in crate_names {
7942                    prefixes
7943                        .entry(crate_name)
7944                        .or_insert_with(|| src_prefix.clone());
7945                }
7946            }
7947        }
7948        let Ok(entries) = std::fs::read_dir(&dir) else {
7949            continue;
7950        };
7951        for entry in entries.flatten() {
7952            let path = entry.path();
7953            if path.is_dir() {
7954                stack.push(path);
7955            }
7956        }
7957    }
7958    prefixes
7959}
7960
7961/// Extract the crate names a manifest defines: the normalized package name
7962/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
7963/// reachable by either spelling, matching the previous match semantics.
7964fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7965    let Ok(source) = std::fs::read_to_string(manifest) else {
7966        return Vec::new();
7967    };
7968    let mut in_lib = false;
7969    let mut package_name = None;
7970    let mut lib_name = None;
7971    for line in source.lines() {
7972        let trimmed = line.trim();
7973        if trimmed.starts_with('[') {
7974            in_lib = trimmed == "[lib]";
7975            continue;
7976        }
7977        let Some((key, value)) = trimmed.split_once('=') else {
7978            continue;
7979        };
7980        let key = key.trim();
7981        let value = value.trim().trim_matches('"');
7982        if in_lib && key == "name" {
7983            lib_name = Some(value.to_string());
7984        } else if !in_lib && key == "name" && package_name.is_none() {
7985            package_name = Some(value.to_string());
7986        }
7987    }
7988    let mut names = Vec::new();
7989    if let Some(lib) = lib_name {
7990        names.push(lib);
7991    }
7992    if let Some(package) = package_name {
7993        let normalized = package.replace('-', "_");
7994        if !names.contains(&normalized) {
7995            names.push(normalized);
7996        }
7997    }
7998    names
7999}
8000
8001fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
8002    if segments.is_empty() {
8003        return Some(Vec::new());
8004    }
8005    let caller_segments = rust_module_segments_for_rel(caller_file);
8006    match segments[0] {
8007        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
8008        "self" => {
8009            let mut resolved = caller_segments;
8010            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8011            Some(resolved)
8012        }
8013        "super" => {
8014            let mut resolved = caller_segments;
8015            resolved.pop();
8016            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
8017            Some(resolved)
8018        }
8019        _ => {
8020            let mut resolved = caller_segments;
8021            resolved.pop();
8022            resolved.extend(segments.iter().map(|item| item.to_string()));
8023            Some(resolved)
8024        }
8025    }
8026}
8027
8028fn rust_file_for_segments(
8029    index: &ProjectIndex<'_>,
8030    caller_file: &str,
8031    segments: &[String],
8032) -> Option<String> {
8033    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
8034}
8035
8036fn rust_file_for_src_prefix(
8037    index: &ProjectIndex<'_>,
8038    src_prefix: &str,
8039    segments: &[String],
8040) -> Option<String> {
8041    let candidate = if segments.is_empty() {
8042        [src_prefix, "lib.rs"].join("/")
8043    } else {
8044        format!("{}/{}.rs", src_prefix, segments.join("/"))
8045    };
8046    if index.files.contains_key(&candidate) {
8047        return Some(candidate);
8048    }
8049    if !segments.is_empty() {
8050        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
8051        if index.files.contains_key(&mod_candidate) {
8052            return Some(mod_candidate);
8053        }
8054    }
8055    None
8056}
8057
8058fn rust_src_prefix(rel_path: &str) -> String {
8059    rel_path
8060        .split_once("/src/")
8061        .map(|(prefix, _)| format!("{prefix}/src"))
8062        .unwrap_or_else(|| "src".to_string())
8063}
8064
8065fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
8066    let after_src = rel_path
8067        .split_once("/src/")
8068        .map(|(_, rest)| rest)
8069        .or_else(|| rel_path.strip_prefix("src/"))
8070        .unwrap_or(rel_path);
8071    if matches!(after_src, "lib.rs" | "main.rs") {
8072        return Vec::new();
8073    }
8074    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
8075        return prefix.split('/').map(|item| item.to_string()).collect();
8076    }
8077    after_src
8078        .strip_suffix(".rs")
8079        .unwrap_or(after_src)
8080        .split('/')
8081        .map(|item| item.to_string())
8082        .collect()
8083}
8084
8085fn resolve_local_target(
8086    _index: &ProjectIndex<'_>,
8087    caller_file: &str,
8088    full_ref: &str,
8089    short_name: &str,
8090    caller_data: &FileCallData,
8091) -> Option<(String, String, String)> {
8092    if !callgraph::is_bare_callee(full_ref, short_name) {
8093        return None;
8094    }
8095    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
8096        .ok()
8097        .map(|symbol| {
8098            (
8099                "resolved_local".to_string(),
8100                caller_file.to_string(),
8101                symbol,
8102            )
8103        })
8104}
8105
8106impl<'a> ProjectIndex<'a> {
8107    fn from_parts(
8108        project_root: &Path,
8109        files: HashMap<String, DbFileIndex>,
8110        caller_data: HashMap<String, &'a FileCallData>,
8111        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8112    ) -> Self {
8113        Self {
8114            project_root: project_root.to_path_buf(),
8115            files,
8116            caller_data,
8117            workspace_crate_prefixes,
8118        }
8119    }
8120
8121    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
8122        let mut files = HashMap::new();
8123        let mut caller_data = HashMap::new();
8124        for extract in extracts {
8125            let index = DbFileIndex::from_extract(project_root, extract);
8126            caller_data.insert(extract.rel_path.clone(), &extract.data);
8127            files.insert(extract.rel_path.clone(), index);
8128        }
8129        Self::from_parts(
8130            project_root,
8131            files,
8132            caller_data,
8133            WorkspaceCratePrefixCache::default(),
8134        )
8135    }
8136
8137    fn from_db_and_callers(
8138        tx: &Transaction<'_>,
8139        project_root: &Path,
8140        caller_extracts: &'a HashMap<String, FileExtract>,
8141        workspace_crate_prefixes: WorkspaceCratePrefixCache,
8142    ) -> Result<Self> {
8143        let mut files = load_db_file_indexes(tx, project_root)?;
8144        let mut caller_data = HashMap::new();
8145        for (rel_path, extract) in caller_extracts {
8146            files.insert(
8147                rel_path.clone(),
8148                DbFileIndex::from_extract(project_root, extract),
8149            );
8150            caller_data.insert(rel_path.clone(), &extract.data);
8151        }
8152        Ok(Self::from_parts(
8153            project_root,
8154            files,
8155            caller_data,
8156            workspace_crate_prefixes,
8157        ))
8158    }
8159
8160    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
8161        self.files.get(rel_path).and_then(|file| file.lang)
8162    }
8163
8164    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
8165        self.files
8166            .get(caller_file)
8167            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
8168    }
8169
8170    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
8171        self.files
8172            .get(rel_path)
8173            .map(|file| file.reexports.as_slice())
8174            .unwrap_or(&[])
8175    }
8176
8177    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
8178        self.files.get(rel_path).and_then(|file| {
8179            file.node_by_scoped
8180                .get(symbol)
8181                .cloned()
8182                .or_else(|| file.node_by_bare.get(symbol).cloned())
8183        })
8184    }
8185}
8186
8187impl DbFileIndex {
8188    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
8189        let mut node_by_scoped = HashMap::new();
8190        let mut node_by_bare = HashMap::new();
8191        for node in &extract.nodes {
8192            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
8193            node_by_bare
8194                .entry(node.name.clone())
8195                .or_insert(node.id.clone());
8196        }
8197        let mut export_aliases = HashMap::new();
8198        for raw_ref in &extract.raw_refs {
8199            if raw_ref.kind == "export_alias" {
8200                if let (Some(exported), Some(source_symbol)) =
8201                    (&raw_ref.local_name, &raw_ref.requested_name)
8202                {
8203                    export_aliases.insert(exported.clone(), source_symbol.clone());
8204                }
8205            }
8206        }
8207        let mut module_targets = HashMap::new();
8208        let mut reexports = Vec::new();
8209        for raw_ref in &extract.raw_refs {
8210            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
8211                continue;
8212            }
8213            let Some(module_path) = &raw_ref.module_path else {
8214                continue;
8215            };
8216            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
8217            module_targets
8218                .entry(module_path.clone())
8219                .or_insert_with(|| target_file.clone());
8220            if raw_ref.kind == "reexport" {
8221                reexports.push(reexport_index_from_raw(raw_ref, target_file));
8222            }
8223        }
8224        Self {
8225            lang: Some(extract.lang),
8226            exports: extract.data.exported_symbols.iter().cloned().collect(),
8227            default_export: extract.data.default_export_symbol.clone(),
8228            export_aliases,
8229            node_by_scoped,
8230            node_by_bare,
8231            module_targets,
8232            reexports,
8233        }
8234    }
8235}
8236
8237fn load_db_file_indexes(
8238    tx: &Transaction<'_>,
8239    project_root: &Path,
8240) -> Result<HashMap<String, DbFileIndex>> {
8241    let mut files = HashMap::new();
8242    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
8243    let rows = stmt.query_map([], |row| {
8244        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8245    })?;
8246    for row in rows {
8247        let (rel_path, lang) = row?;
8248        files.insert(
8249            rel_path.clone(),
8250            DbFileIndex {
8251                lang: lang_from_label(&lang),
8252                exports: HashSet::new(),
8253                default_export: None,
8254                export_aliases: HashMap::new(),
8255                node_by_scoped: HashMap::new(),
8256                node_by_bare: HashMap::new(),
8257                module_targets: HashMap::new(),
8258                reexports: Vec::new(),
8259            },
8260        );
8261    }
8262
8263    let mut node_stmt = tx.prepare(
8264        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
8265    )?;
8266    let nodes = node_stmt.query_map([], |row| {
8267        Ok((
8268            row.get::<_, String>(0)?,
8269            row.get::<_, String>(1)?,
8270            row.get::<_, String>(2)?,
8271            row.get::<_, String>(3)?,
8272            row.get::<_, i64>(4)? != 0,
8273            row.get::<_, i64>(5)? != 0,
8274        ))
8275    })?;
8276    for row in nodes {
8277        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
8278        let file = files
8279            .entry(file_path.clone())
8280            .or_insert_with(|| DbFileIndex {
8281                lang: None,
8282                exports: HashSet::new(),
8283                default_export: None,
8284                export_aliases: HashMap::new(),
8285                node_by_scoped: HashMap::new(),
8286                node_by_bare: HashMap::new(),
8287                module_targets: HashMap::new(),
8288                reexports: Vec::new(),
8289            });
8290        if exported {
8291            file.exports.insert(name.clone());
8292            file.exports.insert(scoped_name.clone());
8293        }
8294        if is_default_export {
8295            file.default_export = Some(scoped_name.clone());
8296        }
8297        file.node_by_scoped.insert(scoped_name, id.clone());
8298        file.node_by_bare.entry(name).or_insert(id);
8299    }
8300    let file_keys: HashSet<String> = files.keys().cloned().collect();
8301    // Persisted caller extracts supply import targets. Only reexports from other
8302    // files need dependency reconstruction, and their caller dependencies are
8303    // loaded once instead of issuing repeated SQLite queries per reference.
8304    let dependencies_by_file = load_file_dependencies_index(tx)?;
8305    let mut ref_stmt = tx.prepare(
8306        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
8307         FROM refs WHERE kind IN ('reexport', 'export_alias')",
8308    )?;
8309    let ref_rows = ref_stmt.query_map([], |row| {
8310        Ok((
8311            row.get::<_, String>(0)?,
8312            row.get::<_, String>(1)?,
8313            row.get::<_, String>(2)?,
8314            row.get::<_, Option<String>>(3)?,
8315            row.get::<_, Option<String>>(4)?,
8316            row.get::<_, i64>(5)? != 0,
8317            row.get::<_, Option<String>>(6)?,
8318            row.get::<_, Option<String>>(7)?,
8319        ))
8320    })?;
8321    for row in ref_rows {
8322        let (
8323            ref_id,
8324            caller_file,
8325            kind,
8326            module_path,
8327            full_ref,
8328            wildcard,
8329            local_name,
8330            requested_name,
8331        ) = row?;
8332        if kind == "export_alias" {
8333            if let (Some(exported), Some(source_symbol), Some(file)) =
8334                (local_name, requested_name, files.get_mut(&caller_file))
8335            {
8336                file.export_aliases.insert(exported, source_symbol);
8337            }
8338            continue;
8339        }
8340        let Some(module_path) = module_path else {
8341            continue;
8342        };
8343        let file_deps = dependencies_by_file
8344            .get(&caller_file)
8345            .cloned()
8346            .unwrap_or_default();
8347        let deps = stored_dependencies_for_module(
8348            project_root,
8349            &caller_file,
8350            &module_path,
8351            &file_deps,
8352            &file_keys,
8353        );
8354        let target_file = deps
8355            .iter()
8356            .find(|dep| file_keys.contains(*dep))
8357            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
8358        if let Some(file) = files.get_mut(&caller_file) {
8359            file.module_targets
8360                .entry(module_path.clone())
8361                .or_insert_with(|| target_file.clone());
8362            if kind == "reexport" {
8363                let raw = RawRef {
8364                    ref_id,
8365                    caller_node: None,
8366                    caller_symbol: None,
8367                    caller_file,
8368                    kind,
8369                    short_name: None,
8370                    full_ref,
8371                    module_path: Some(module_path),
8372                    import_kind: Some("reexport".to_string()),
8373                    local_name: None,
8374                    requested_name: None,
8375                    namespace_alias: None,
8376                    wildcard,
8377                    line: 0,
8378                    byte_start: 0,
8379                    byte_end: 0,
8380                    dependencies: deps,
8381                };
8382                file.reexports
8383                    .push(reexport_index_from_raw(&raw, target_file));
8384            }
8385        }
8386    }
8387
8388    Ok(files)
8389}
8390
8391fn stored_dependencies_for_module(
8392    project_root: &Path,
8393    caller_file: &str,
8394    module_path: &str,
8395    caller_dependencies: &BTreeSet<String>,
8396    indexed_files: &HashSet<String>,
8397) -> BTreeSet<String> {
8398    let caller_path = project_root.join(caller_file);
8399    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
8400    if module_path.starts_with('.') {
8401        let caller_dir = caller_path.parent().unwrap_or(project_root);
8402        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
8403            let normalized = if candidate.is_file() {
8404                canonicalize_path(&candidate)
8405            } else {
8406                candidate
8407            };
8408            candidates.insert(relative_path(project_root, &normalized));
8409        }
8410    }
8411    let exact = candidates
8412        .intersection(caller_dependencies)
8413        .filter(|dependency| indexed_files.contains(*dependency))
8414        .cloned()
8415        .collect::<BTreeSet<_>>();
8416    if !exact.is_empty() || module_path.starts_with('.') {
8417        return exact;
8418    }
8419
8420    let module_path = rust_module_path_without_alias_or_use_list(module_path)
8421        .trim_matches(|character| matches!(character, '\'' | '"'));
8422    let package_name = module_path
8423        .split('/')
8424        .next_back()
8425        .unwrap_or(module_path)
8426        .replace('_', "-");
8427    let matched = caller_dependencies
8428        .iter()
8429        .filter(|dependency| indexed_files.contains(*dependency))
8430        .filter(|dependency| {
8431            dependency.as_str() == module_path
8432                || dependency.ends_with(&format!("/{module_path}"))
8433                || Path::new(dependency).components().any(|component| {
8434                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
8435                })
8436        })
8437        .cloned()
8438        .collect::<BTreeSet<_>>();
8439    if matched.len() == 1 {
8440        matched
8441    } else {
8442        BTreeSet::new()
8443    }
8444}
8445
8446fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
8447    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
8448    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
8449    let rows = stmt.query_map([], |row| {
8450        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8451    })?;
8452    for row in rows {
8453        let (file_path, dependency) = row?;
8454        by_file.entry(file_path).or_default().insert(dependency);
8455    }
8456    Ok(by_file)
8457}
8458
8459struct ColdBuildInsertStatements<'stmt> {
8460    file: Statement<'stmt>,
8461    node: Statement<'stmt>,
8462    file_dependency: Statement<'stmt>,
8463    dispatch_hint: Statement<'stmt>,
8464    backend_state: Statement<'stmt>,
8465    reference: Statement<'stmt>,
8466    edge: Statement<'stmt>,
8467}
8468
8469impl<'stmt> ColdBuildInsertStatements<'stmt> {
8470    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
8471        Ok(Self {
8472            file: tx.prepare(
8473                "INSERT OR REPLACE INTO files(
8474                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8475                    is_public_api, surface_fingerprint, indexed_at
8476                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8477            )?,
8478            node: tx.prepare(
8479                "INSERT OR REPLACE INTO nodes(
8480                    id, file_path, name, scoped_name, kind, start_line, start_col,
8481                    end_line, end_col, range_ordinal, signature, exported,
8482                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
8483                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8484            )?,
8485            file_dependency: tx.prepare(
8486                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8487            )?,
8488            dispatch_hint: tx.prepare(
8489                "INSERT OR REPLACE INTO dispatch_hints(
8490                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8491                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8492            )?,
8493            backend_state: tx.prepare(
8494                "INSERT OR REPLACE INTO backend_file_state(
8495                    backend, workspace_root, file_path, content_hash, status, updated_at
8496                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
8497            )?,
8498            reference: tx.prepare(
8499                "INSERT OR REPLACE INTO refs(
8500                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8501                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8502                    byte_start, byte_end, status, target_node, target_file, target_symbol,
8503                    provenance
8504                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8505            )?,
8506            edge: tx.prepare(
8507                "INSERT OR REPLACE INTO edges(
8508                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8509                    kind, line, provenance
8510                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8511            )?,
8512        })
8513    }
8514}
8515
8516fn insert_file_extract_prepared(
8517    statements: &mut ColdBuildInsertStatements<'_>,
8518    workspace_root: &str,
8519    extract: &FileExtract,
8520) -> Result<()> {
8521    statements.file.execute(params![
8522        extract.rel_path,
8523        hash_to_hex(extract.freshness.content_hash),
8524        system_time_to_ns(extract.freshness.mtime),
8525        extract.freshness.size as i64,
8526        lang_label(extract.lang),
8527        extract.surface_fingerprint,
8528        unix_seconds_now(),
8529    ])?;
8530    for node in &extract.nodes {
8531        statements.node.execute(params![
8532            node.id,
8533            node.file_path,
8534            node.name,
8535            node.scoped_name,
8536            node.kind,
8537            node.range.start_line as i64,
8538            node.range.start_col as i64,
8539            node.range.end_line as i64,
8540            node.range.end_col as i64,
8541            node.range_ordinal as i64,
8542            node.signature,
8543            bool_int(node.exported),
8544            bool_int(node.is_default_export),
8545            bool_int(node.is_type_like),
8546            bool_int(node.is_callgraph_entry_point),
8547            PROVENANCE_TREESITTER,
8548        ])?;
8549    }
8550
8551    let mut dependencies = BTreeSet::new();
8552    for raw_ref in &extract.raw_refs {
8553        dependencies.extend(raw_ref.dependencies.iter().cloned());
8554    }
8555    for dep_file in &dependencies {
8556        statements
8557            .file_dependency
8558            .execute(params![extract.rel_path, dep_file])?;
8559    }
8560
8561    for hint in &extract.dispatch_hints {
8562        statements.dispatch_hint.execute(params![
8563            hint.id,
8564            hint.method_name,
8565            hint.caller_node,
8566            hint.file,
8567            hint.line as i64,
8568            hint.byte_start as i64,
8569            hint.byte_end as i64,
8570            PROVENANCE_TREESITTER,
8571        ])?;
8572    }
8573    insert_backend_state_prepared(
8574        &mut statements.backend_state,
8575        workspace_root,
8576        &extract.rel_path,
8577        Some(&extract.freshness.content_hash),
8578        "fresh",
8579    )?;
8580    Ok(())
8581}
8582
8583fn insert_backend_state_prepared(
8584    stmt: &mut Statement<'_>,
8585    workspace_root: &str,
8586    rel_path: &str,
8587    content_hash: Option<&blake3::Hash>,
8588    status: &str,
8589) -> Result<()> {
8590    let hash = content_hash
8591        .map(|hash| hash_to_hex(*hash))
8592        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
8593    stmt.execute(params![
8594        BACKEND_TREESITTER,
8595        workspace_root,
8596        rel_path,
8597        hash,
8598        status,
8599        unix_seconds_now(),
8600    ])?;
8601    Ok(())
8602}
8603
8604fn insert_resolved_ref_prepared(
8605    statements: &mut ColdBuildInsertStatements<'_>,
8606    resolved: &ResolvedRef,
8607) -> Result<()> {
8608    let raw = &resolved.raw;
8609    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8610    statements.reference.execute(params![
8611        raw.ref_id,
8612        raw.caller_node,
8613        raw.caller_file,
8614        raw.kind,
8615        raw.short_name,
8616        raw.full_ref,
8617        raw.module_path,
8618        raw.import_kind,
8619        raw.local_name,
8620        raw.requested_name,
8621        raw.namespace_alias,
8622        bool_int(raw.wildcard),
8623        raw.line as i64,
8624        raw.byte_start as i64,
8625        raw.byte_end as i64,
8626        resolved.status,
8627        resolved.target_node,
8628        resolved.target_file,
8629        resolved.target_symbol,
8630        PROVENANCE_TREESITTER,
8631    ])?;
8632    if let Some(edge) = &resolved.edge {
8633        statements.edge.execute(params![
8634            edge.edge_id,
8635            raw.ref_id,
8636            edge.source_node,
8637            edge.target_node,
8638            edge.target_file,
8639            edge.target_symbol,
8640            edge.kind,
8641            edge.line as i64,
8642            PROVENANCE_TREESITTER,
8643        ])?;
8644    }
8645    Ok(())
8646}
8647
8648fn insert_file_extract(
8649    tx: &Transaction<'_>,
8650    project_root: &Path,
8651    extract: &FileExtract,
8652) -> Result<()> {
8653    tx.execute(
8654        "INSERT OR REPLACE INTO files(
8655            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
8656            is_public_api, surface_fingerprint, indexed_at
8657        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
8658        params![
8659            extract.rel_path,
8660            hash_to_hex(extract.freshness.content_hash),
8661            system_time_to_ns(extract.freshness.mtime),
8662            extract.freshness.size as i64,
8663            lang_label(extract.lang),
8664            extract.surface_fingerprint,
8665            unix_seconds_now(),
8666        ],
8667    )?;
8668    for node in &extract.nodes {
8669        tx.execute(
8670            "INSERT OR REPLACE INTO nodes(
8671                id, file_path, name, scoped_name, kind, start_line, start_col,
8672                end_line, end_col, range_ordinal, signature, exported,
8673                is_default_export, is_type_like, is_callgraph_entry_point, provenance
8674            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
8675            params![
8676                node.id,
8677                node.file_path,
8678                node.name,
8679                node.scoped_name,
8680                node.kind,
8681                node.range.start_line as i64,
8682                node.range.start_col as i64,
8683                node.range.end_line as i64,
8684                node.range.end_col as i64,
8685                node.range_ordinal as i64,
8686                node.signature,
8687                bool_int(node.exported),
8688                bool_int(node.is_default_export),
8689                bool_int(node.is_type_like),
8690                bool_int(node.is_callgraph_entry_point),
8691                PROVENANCE_TREESITTER,
8692            ],
8693        )?;
8694    }
8695    let mut dependencies = BTreeSet::new();
8696    for raw_ref in &extract.raw_refs {
8697        dependencies.extend(raw_ref.dependencies.iter().cloned());
8698    }
8699    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
8700
8701    for hint in &extract.dispatch_hints {
8702        tx.execute(
8703            "INSERT OR REPLACE INTO dispatch_hints(
8704                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
8705            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
8706            params![
8707                hint.id,
8708                hint.method_name,
8709                hint.caller_node,
8710                hint.file,
8711                hint.line as i64,
8712                hint.byte_start as i64,
8713                hint.byte_end as i64,
8714                PROVENANCE_TREESITTER,
8715            ],
8716        )?;
8717    }
8718    mark_backend_state(
8719        tx,
8720        project_root,
8721        &extract.rel_path,
8722        Some(&extract.freshness.content_hash),
8723        "fresh",
8724    )?;
8725    Ok(())
8726}
8727
8728fn insert_file_dependencies(
8729    tx: &Transaction<'_>,
8730    file_path: &str,
8731    dependencies: &BTreeSet<String>,
8732) -> Result<()> {
8733    for dep_file in dependencies {
8734        tx.execute(
8735            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8736            params![file_path, dep_file],
8737        )?;
8738    }
8739    Ok(())
8740}
8741
8742fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8743    let raw = &resolved.raw;
8744    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8745    tx.execute(
8746        "INSERT OR REPLACE INTO refs(
8747            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8748            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8749            byte_start, byte_end, status, target_node, target_file, target_symbol,
8750            provenance
8751        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8752        params![
8753            raw.ref_id,
8754            raw.caller_node,
8755            raw.caller_file,
8756            raw.kind,
8757            raw.short_name,
8758            raw.full_ref,
8759            raw.module_path,
8760            raw.import_kind,
8761            raw.local_name,
8762            raw.requested_name,
8763            raw.namespace_alias,
8764            bool_int(raw.wildcard),
8765            raw.line as i64,
8766            raw.byte_start as i64,
8767            raw.byte_end as i64,
8768            resolved.status,
8769            resolved.target_node,
8770            resolved.target_file,
8771            resolved.target_symbol,
8772            PROVENANCE_TREESITTER,
8773        ],
8774    )?;
8775    if let Some(edge) = &resolved.edge {
8776        tx.execute(
8777            "INSERT OR REPLACE INTO edges(
8778                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8779                kind, line, provenance
8780            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8781            params![
8782                edge.edge_id,
8783                raw.ref_id,
8784                edge.source_node,
8785                edge.target_node,
8786                edge.target_file,
8787                edge.target_symbol,
8788                edge.kind,
8789                edge.line as i64,
8790                PROVENANCE_TREESITTER,
8791            ],
8792        )?;
8793    }
8794    Ok(())
8795}
8796
8797fn insert_method_dispatch_edges(
8798    tx: &Transaction<'_>,
8799    project_root: &Path,
8800    caller_files: Option<&BTreeSet<String>>,
8801) -> Result<usize> {
8802    let references = load_name_match_refs(tx, caller_files)?;
8803    if references.is_empty() {
8804        return Ok(0);
8805    }
8806
8807    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8808    let mut source_cache: DispatchSourceCache = HashMap::new();
8809    let mut inserted = 0usize;
8810    for reference in references {
8811        let key = (reference.method_name.clone(), reference.lang.clone());
8812        let candidates = match candidates_by_name.entry(key) {
8813            Entry::Occupied(entry) => entry.into_mut(),
8814            Entry::Vacant(entry) => {
8815                let candidates =
8816                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8817                entry.insert(candidates)
8818            }
8819        };
8820
8821        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
8822            ReceiverTypeInference::Known(receiver_type) => {
8823                let Some(candidate) =
8824                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8825                else {
8826                    continue;
8827                };
8828                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8829                inserted += 1;
8830                continue;
8831            }
8832            ReceiverTypeInference::RustDirectSelfField {
8833                receiver_type,
8834                declaration_file,
8835                module_scope,
8836            } => {
8837                let Some(candidate) = select_rust_direct_self_field_candidate(
8838                    project_root,
8839                    &reference,
8840                    candidates.as_slice(),
8841                    &receiver_type,
8842                    &declaration_file,
8843                    &module_scope,
8844                    &mut source_cache,
8845                ) else {
8846                    continue;
8847                };
8848                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8849                inserted += 1;
8850                continue;
8851            }
8852            ReceiverTypeInference::KnownButUnresolved => continue,
8853            ReceiverTypeInference::Unknown => {}
8854        }
8855
8856        if method_name_match_denylisted(&reference.method_name) {
8857            continue;
8858        }
8859
8860        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8861            continue;
8862        };
8863        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8864        inserted += 1;
8865    }
8866    Ok(inserted)
8867}
8868
8869fn insert_method_dispatch_edges_chunked(
8870    tx: &Transaction<'_>,
8871    project_root: &Path,
8872    caller_files: &BTreeSet<String>,
8873    chunk_size: usize,
8874) -> Result<usize> {
8875    if caller_files.is_empty() {
8876        return Ok(0);
8877    }
8878    if chunk_size == 0 || caller_files.len() <= chunk_size {
8879        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8880    }
8881
8882    let mut inserted = 0usize;
8883    let mut batch = BTreeSet::new();
8884    for caller_file in caller_files {
8885        batch.insert(caller_file.clone());
8886        if batch.len() == chunk_size {
8887            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8888            batch.clear();
8889        }
8890    }
8891    if !batch.is_empty() {
8892        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8893    }
8894    Ok(inserted)
8895}
8896
8897fn insert_method_dispatch_edge(
8898    tx: &Transaction<'_>,
8899    reference: &NameMatchRef,
8900    candidate: &NameMatchCandidate,
8901    provenance: &str,
8902) -> Result<()> {
8903    tx.execute(
8904        "INSERT OR REPLACE INTO edges(
8905            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8906            kind, line, provenance
8907        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8908        params![
8909            ref_id(&[&reference.ref_id, provenance, "edge"]),
8910            &reference.ref_id,
8911            &reference.caller_node,
8912            &candidate.node_id,
8913            &candidate.file_path,
8914            &candidate.scoped_name,
8915            reference.line as i64,
8916            provenance,
8917        ],
8918    )?;
8919    Ok(())
8920}
8921
8922fn delete_method_dispatch_edges_for_callers(
8923    tx: &Transaction<'_>,
8924    caller_files: &BTreeSet<String>,
8925) -> Result<()> {
8926    if caller_files.is_empty() {
8927        return Ok(());
8928    }
8929
8930    let mut stmt = tx.prepare(
8931        "DELETE FROM edges
8932         WHERE provenance IN (?1, ?2)
8933           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8934    )?;
8935    for caller_file in caller_files {
8936        stmt.execute(params![
8937            PROVENANCE_NAME_MATCH,
8938            PROVENANCE_TYPE_MATCH,
8939            caller_file
8940        ])?;
8941    }
8942    Ok(())
8943}
8944
8945fn load_name_match_refs(
8946    tx: &Transaction<'_>,
8947    caller_files: Option<&BTreeSet<String>>,
8948) -> Result<Vec<NameMatchRef>> {
8949    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8950                           n.signature, r.short_name, r.full_ref, r.line, f.lang
8951                    FROM refs r
8952                    JOIN files f ON f.path = r.caller_file
8953                    JOIN nodes n ON n.id = r.caller_node
8954                    WHERE r.kind = 'call'
8955                      AND r.status = 'unresolved'
8956                      AND r.caller_node IS NOT NULL
8957                      AND r.full_ref IS NOT NULL
8958                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8959                      AND NOT EXISTS (
8960                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8961                      )";
8962    let mut references = Vec::new();
8963
8964    if let Some(caller_files) = caller_files {
8965        if caller_files.is_empty() {
8966            return Ok(references);
8967        }
8968        let sql = format!(
8969            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8970        );
8971        let mut stmt = tx.prepare(&sql)?;
8972        for caller_file in caller_files {
8973            let rows = stmt.query_map(params![caller_file], |row| {
8974                Ok((
8975                    row.get::<_, String>(0)?,
8976                    row.get::<_, Option<String>>(1)?,
8977                    row.get::<_, String>(2)?,
8978                    row.get::<_, String>(3)?,
8979                    row.get::<_, Option<String>>(4)?,
8980                    row.get::<_, Option<String>>(5)?,
8981                    row.get::<_, Option<String>>(6)?,
8982                    row.get::<_, i64>(7)?,
8983                    row.get::<_, String>(8)?,
8984                ))
8985            })?;
8986            for row in rows {
8987                let (
8988                    ref_id,
8989                    caller_node,
8990                    caller_file,
8991                    caller_symbol,
8992                    caller_signature,
8993                    short_name,
8994                    full_ref,
8995                    line,
8996                    lang,
8997                ) = row?;
8998                if let Some(reference) = name_match_ref_from_parts(
8999                    ref_id,
9000                    caller_node,
9001                    caller_file,
9002                    caller_symbol,
9003                    caller_signature,
9004                    short_name,
9005                    full_ref,
9006                    line,
9007                    lang,
9008                ) {
9009                    references.push(reference);
9010                }
9011            }
9012        }
9013        return Ok(references);
9014    }
9015
9016    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
9017    let mut stmt = tx.prepare(&sql)?;
9018    let rows = stmt.query_map([], |row| {
9019        Ok((
9020            row.get::<_, String>(0)?,
9021            row.get::<_, Option<String>>(1)?,
9022            row.get::<_, String>(2)?,
9023            row.get::<_, String>(3)?,
9024            row.get::<_, Option<String>>(4)?,
9025            row.get::<_, Option<String>>(5)?,
9026            row.get::<_, Option<String>>(6)?,
9027            row.get::<_, i64>(7)?,
9028            row.get::<_, String>(8)?,
9029        ))
9030    })?;
9031    for row in rows {
9032        let (
9033            ref_id,
9034            caller_node,
9035            caller_file,
9036            caller_symbol,
9037            caller_signature,
9038            short_name,
9039            full_ref,
9040            line,
9041            lang,
9042        ) = row?;
9043        if let Some(reference) = name_match_ref_from_parts(
9044            ref_id,
9045            caller_node,
9046            caller_file,
9047            caller_symbol,
9048            caller_signature,
9049            short_name,
9050            full_ref,
9051            line,
9052            lang,
9053        ) {
9054            references.push(reference);
9055        }
9056    }
9057    Ok(references)
9058}
9059
9060#[allow(clippy::too_many_arguments)]
9061fn name_match_ref_from_parts(
9062    ref_id: String,
9063    caller_node: Option<String>,
9064    caller_file: String,
9065    caller_symbol: String,
9066    caller_signature: Option<String>,
9067    short_name: Option<String>,
9068    full_ref: Option<String>,
9069    line: i64,
9070    lang: String,
9071) -> Option<NameMatchRef> {
9072    let caller_node = caller_node?;
9073    let full_ref = full_ref?;
9074    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
9075    let method_name = if member.is_empty() {
9076        short_name.as_deref()?.to_string()
9077    } else {
9078        member
9079    };
9080    Some(NameMatchRef {
9081        ref_id,
9082        caller_node,
9083        caller_file,
9084        caller_symbol,
9085        caller_signature,
9086        receiver_expression,
9087        receiver,
9088        method_name,
9089        colon_dispatch,
9090        line: line.max(0) as u32,
9091        lang,
9092    })
9093}
9094
9095fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
9096    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
9097    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
9098    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
9099    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
9100        .into_iter()
9101        .flatten()
9102        .max_by_key(|(index, _, _)| *index)?;
9103    if delimiter == 0 {
9104        return None;
9105    }
9106    let member_start = delimiter + delimiter_len;
9107    if member_start >= full_ref.len() {
9108        return None;
9109    }
9110    let receiver_expression = full_ref[..delimiter].trim();
9111    let receiver = last_name_segment(receiver_expression).trim();
9112    let member = &full_ref[member_start..];
9113    if receiver.is_empty() || member.is_empty() {
9114        return None;
9115    }
9116    Some((
9117        receiver_expression.to_string(),
9118        receiver.to_string(),
9119        member.to_string(),
9120        colon_dispatch,
9121    ))
9122}
9123
9124fn last_name_segment(value: &str) -> &str {
9125    value
9126        .rsplit(['.', ':', '/', '\\', '-', '>'])
9127        .find(|segment| !segment.is_empty())
9128        .unwrap_or(value)
9129}
9130
9131fn load_name_match_candidates(
9132    tx: &Transaction<'_>,
9133    method_name: &str,
9134    lang: &str,
9135) -> Result<Vec<NameMatchCandidate>> {
9136    let mut stmt = tx.prepare(
9137        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
9138         FROM nodes n JOIN files f ON f.path = n.file_path
9139         WHERE n.name = ?1
9140           AND f.lang = ?2
9141           AND n.kind IN ('method', 'function')
9142         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
9143    )?;
9144    let rows = stmt.query_map(params![method_name, lang], |row| {
9145        Ok(NameMatchCandidate {
9146            node_id: row.get(0)?,
9147            file_path: row.get(1)?,
9148            scoped_name: row.get(2)?,
9149            kind: row.get(3)?,
9150            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
9151        })
9152    })?;
9153    rows.collect::<std::result::Result<Vec<_>, _>>()
9154        .map_err(Into::into)
9155}
9156
9157struct ParsedDispatchSource {
9158    source: String,
9159    tree: tree_sitter::Tree,
9160}
9161
9162type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
9163
9164#[derive(Debug, Clone, PartialEq, Eq)]
9165enum ReceiverTypeInference {
9166    Unknown,
9167    Known(String),
9168    RustDirectSelfField {
9169        receiver_type: String,
9170        declaration_file: String,
9171        module_scope: Vec<(usize, usize)>,
9172    },
9173    KnownButUnresolved,
9174}
9175
9176#[cfg(test)]
9177fn infer_receiver_type(
9178    project_root: &Path,
9179    reference: &NameMatchRef,
9180    source_cache: &mut DispatchSourceCache,
9181) -> Option<String> {
9182    match infer_receiver_type_state(project_root, reference, source_cache) {
9183        ReceiverTypeInference::Known(receiver_type)
9184        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
9185        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
9186    }
9187}
9188
9189fn infer_receiver_type_state(
9190    project_root: &Path,
9191    reference: &NameMatchRef,
9192    source_cache: &mut DispatchSourceCache,
9193) -> ReceiverTypeInference {
9194    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
9195    match reference.lang.as_str() {
9196        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
9197        "java" => {
9198            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
9199                .map(known)
9200                .unwrap_or(ReceiverTypeInference::Unknown)
9201        }
9202        "kotlin" => {
9203            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
9204                .map(known)
9205                .unwrap_or(ReceiverTypeInference::Unknown)
9206        }
9207        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
9208            .map(known)
9209            .unwrap_or(ReceiverTypeInference::Unknown),
9210        _ => ReceiverTypeInference::Unknown,
9211    }
9212}
9213
9214fn parse_dispatch_source(
9215    project_root: &Path,
9216    caller_file: &str,
9217    lang: LangId,
9218) -> Option<ParsedDispatchSource> {
9219    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
9220    let grammar = crate::parser::grammar_for(lang);
9221    let mut parser = tree_sitter::Parser::new();
9222    parser.set_language(&grammar).ok()?;
9223    let tree = parser.parse(&source, None)?;
9224    Some(ParsedDispatchSource { source, tree })
9225}
9226
9227fn parsed_dispatch_source<'a>(
9228    project_root: &Path,
9229    reference: &NameMatchRef,
9230    lang: LangId,
9231    source_cache: &'a mut DispatchSourceCache,
9232) -> Option<&'a ParsedDispatchSource> {
9233    parsed_dispatch_source_for_file(
9234        project_root,
9235        &reference.caller_file,
9236        &reference.lang,
9237        lang,
9238        source_cache,
9239    )
9240}
9241
9242fn parsed_dispatch_source_for_file<'a>(
9243    project_root: &Path,
9244    file_path: &str,
9245    lang_label: &str,
9246    lang: LangId,
9247    source_cache: &'a mut DispatchSourceCache,
9248) -> Option<&'a ParsedDispatchSource> {
9249    let key = (file_path.to_string(), lang_label.to_string());
9250    source_cache
9251        .entry(key)
9252        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
9253        .as_ref()
9254}
9255
9256fn infer_java_like_receiver_type(
9257    project_root: &Path,
9258    reference: &NameMatchRef,
9259    lang: LangId,
9260    source_cache: &mut DispatchSourceCache,
9261) -> Option<String> {
9262    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9263        return None;
9264    }
9265
9266    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
9267    let root = parsed.tree.root_node();
9268    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
9269
9270    let callable_scope = type_node
9271        .and_then(|node| {
9272            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
9273        })
9274        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
9275
9276    if let Some(callable_scope) = callable_scope {
9277        if let Some(receiver_type) = infer_java_like_local_receiver_type(
9278            callable_scope,
9279            &parsed.source,
9280            &reference.receiver,
9281            reference.line.max(1),
9282            lang,
9283        ) {
9284            return Some(receiver_type);
9285        }
9286    }
9287
9288    type_node.and_then(|node| {
9289        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
9290    })
9291}
9292
9293fn infer_cpp_receiver_type(
9294    project_root: &Path,
9295    reference: &NameMatchRef,
9296    source_cache: &mut DispatchSourceCache,
9297) -> Option<String> {
9298    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
9299        return None;
9300    }
9301
9302    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
9303    let root = parsed.tree.root_node();
9304    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
9305    infer_cpp_receiver_type_from_scope(
9306        scope,
9307        &parsed.source,
9308        &reference.receiver,
9309        reference.line.max(1),
9310    )
9311}
9312
9313fn find_enclosing_java_like_type_node<'tree>(
9314    root: tree_sitter::Node<'tree>,
9315    source: &str,
9316    reference: &NameMatchRef,
9317    lang: LangId,
9318) -> Option<tree_sitter::Node<'tree>> {
9319    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
9320        .and_then(|name| simple_type_name(&name));
9321    let line = reference.line.max(1);
9322    let mut best = None;
9323    let mut stack = vec![root];
9324    while let Some(node) = stack.pop() {
9325        if !node_contains_line(node, line) {
9326            continue;
9327        }
9328        if is_java_like_type_kind(node.kind(), lang) {
9329            let name = declaration_name(node, source);
9330            if expected_type
9331                .as_deref()
9332                .is_none_or(|expected| name == Some(expected))
9333            {
9334                best = tighter_node(best, node);
9335            }
9336        }
9337        push_named_children(node, &mut stack);
9338    }
9339    best
9340}
9341
9342fn find_enclosing_java_like_callable_node<'tree>(
9343    root: tree_sitter::Node<'tree>,
9344    source: &str,
9345    reference: &NameMatchRef,
9346    lang: LangId,
9347) -> Option<tree_sitter::Node<'tree>> {
9348    let expected_name = reference.caller_symbol.rsplit("::").next();
9349    let line = reference.line.max(1);
9350    let mut best = None;
9351    let mut stack = vec![root];
9352    while let Some(node) = stack.pop() {
9353        if !node_contains_line(node, line) {
9354            continue;
9355        }
9356        if is_java_like_callable_kind(node.kind(), lang) {
9357            let name = declaration_name(node, source);
9358            if expected_name.is_none_or(|expected| name == Some(expected)) {
9359                best = tighter_node(best, node);
9360            }
9361        }
9362        push_named_children(node, &mut stack);
9363    }
9364    best
9365}
9366
9367fn find_enclosing_cpp_callable_node<'tree>(
9368    root: tree_sitter::Node<'tree>,
9369    _source: &str,
9370    reference: &NameMatchRef,
9371) -> Option<tree_sitter::Node<'tree>> {
9372    let line = reference.line.max(1);
9373    let mut best = None;
9374    let mut stack = vec![root];
9375    while let Some(node) = stack.pop() {
9376        if !node_contains_line(node, line) {
9377            continue;
9378        }
9379        if node.kind() == "function_definition" {
9380            best = tighter_node(best, node);
9381        }
9382        push_named_children(node, &mut stack);
9383    }
9384    best
9385}
9386
9387fn tighter_node<'tree>(
9388    current: Option<tree_sitter::Node<'tree>>,
9389    candidate: tree_sitter::Node<'tree>,
9390) -> Option<tree_sitter::Node<'tree>> {
9391    match current {
9392        Some(current)
9393            if current.start_byte() > candidate.start_byte()
9394                || (current.start_byte() == candidate.start_byte()
9395                    && current.end_byte() <= candidate.end_byte()) =>
9396        {
9397            Some(current)
9398        }
9399        _ => Some(candidate),
9400    }
9401}
9402
9403fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
9404    let start = node.start_position().row as u32 + 1;
9405    let end = node.end_position().row as u32 + 1;
9406    start <= line && line <= end
9407}
9408
9409fn push_named_children<'tree>(
9410    node: tree_sitter::Node<'tree>,
9411    stack: &mut Vec<tree_sitter::Node<'tree>>,
9412) {
9413    for index in 0..node.named_child_count() {
9414        if let Some(child) = node.named_child(index as u32) {
9415            stack.push(child);
9416        }
9417    }
9418}
9419
9420fn declaration_name<'source>(
9421    node: tree_sitter::Node<'_>,
9422    source: &'source str,
9423) -> Option<&'source str> {
9424    node.child_by_field_name("name")
9425        .map(|name| node_text(name, source))
9426        .or_else(|| {
9427            first_named_child_text(
9428                node,
9429                source,
9430                &["identifier", "type_identifier", "simple_identifier"],
9431            )
9432        })
9433}
9434
9435fn first_named_child_text<'source>(
9436    node: tree_sitter::Node<'_>,
9437    source: &'source str,
9438    kinds: &[&str],
9439) -> Option<&'source str> {
9440    for index in 0..node.named_child_count() {
9441        let child = node.named_child(index as u32)?;
9442        if kinds.contains(&child.kind()) {
9443            return Some(node_text(child, source));
9444        }
9445    }
9446    None
9447}
9448
9449fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
9450    &source[node.byte_range()]
9451}
9452
9453fn infer_java_like_field_receiver_type(
9454    type_node: tree_sitter::Node<'_>,
9455    source: &str,
9456    receiver: &str,
9457    lang: LangId,
9458) -> Option<String> {
9459    let mut stack = Vec::new();
9460    push_named_children(type_node, &mut stack);
9461    while let Some(node) = stack.pop() {
9462        if is_java_like_field_kind(node.kind(), lang) {
9463            if let Some(receiver_type) =
9464                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9465            {
9466                return Some(receiver_type);
9467            }
9468        }
9469        if is_java_like_type_kind(node.kind(), lang)
9470            || is_java_like_callable_kind(node.kind(), lang)
9471        {
9472            continue;
9473        }
9474        push_named_children(node, &mut stack);
9475    }
9476    None
9477}
9478
9479fn infer_java_like_local_receiver_type(
9480    callable_node: tree_sitter::Node<'_>,
9481    source: &str,
9482    receiver: &str,
9483    call_line: u32,
9484    lang: LangId,
9485) -> Option<String> {
9486    let mut best: Option<(u32, String)> = None;
9487    let mut stack = Vec::new();
9488    push_named_children(callable_node, &mut stack);
9489    while let Some(node) = stack.pop() {
9490        let start_line = node.start_position().row as u32 + 1;
9491        if start_line > call_line {
9492            continue;
9493        }
9494        if is_java_like_local_kind(node.kind(), lang) {
9495            if let Some(receiver_type) =
9496                extract_java_like_declared_type(node_text(node, source), receiver, lang)
9497            {
9498                if best
9499                    .as_ref()
9500                    .is_none_or(|(best_line, _)| start_line >= *best_line)
9501                {
9502                    best = Some((start_line, receiver_type));
9503                }
9504            }
9505        }
9506        if is_java_like_type_kind(node.kind(), lang)
9507            || is_java_like_callable_kind(node.kind(), lang)
9508        {
9509            continue;
9510        }
9511        push_named_children(node, &mut stack);
9512    }
9513    best.map(|(_, receiver_type)| receiver_type)
9514}
9515
9516fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
9517    match lang {
9518        LangId::Java => matches!(
9519            kind,
9520            "class_declaration"
9521                | "interface_declaration"
9522                | "enum_declaration"
9523                | "record_declaration"
9524                | "annotation_type_declaration"
9525        ),
9526        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
9527        _ => false,
9528    }
9529}
9530
9531fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
9532    match lang {
9533        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
9534        LangId::Kotlin => kind == "function_declaration",
9535        _ => false,
9536    }
9537}
9538
9539fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
9540    match lang {
9541        LangId::Java => kind == "field_declaration",
9542        LangId::Kotlin => kind == "property_declaration",
9543        _ => false,
9544    }
9545}
9546
9547fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
9548    match lang {
9549        LangId::Java => kind == "local_variable_declaration",
9550        LangId::Kotlin => kind == "property_declaration",
9551        _ => false,
9552    }
9553}
9554
9555fn extract_java_like_declared_type(
9556    declaration: &str,
9557    receiver: &str,
9558    lang: LangId,
9559) -> Option<String> {
9560    match lang {
9561        LangId::Java => extract_java_declared_type(declaration, receiver),
9562        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
9563        _ => None,
9564    }
9565}
9566
9567fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9568    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9569    let after = declaration[receiver_start + receiver.len()..].trim_start();
9570    if after
9571        .chars()
9572        .next()
9573        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
9574    {
9575        return None;
9576    }
9577
9578    let before = declaration[..receiver_start].trim_end();
9579    if before.contains(',') {
9580        return None;
9581    }
9582    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
9583}
9584
9585fn strip_java_declaration_prefixes(mut value: &str) -> &str {
9586    loop {
9587        value = value.trim_start();
9588        if let Some(stripped) = strip_leading_java_annotation(value) {
9589            value = stripped;
9590            continue;
9591        }
9592        if let Some(stripped) = strip_leading_java_modifier(value) {
9593            value = stripped;
9594            continue;
9595        }
9596        return value.trim();
9597    }
9598}
9599
9600fn strip_leading_java_annotation(value: &str) -> Option<&str> {
9601    let value = value.trim_start();
9602    let mut chars = value.char_indices();
9603    let (_, first) = chars.next()?;
9604    if first != '@' {
9605        return None;
9606    }
9607    let mut end = first.len_utf8();
9608    for (index, ch) in chars {
9609        if !(is_code_ident_char(ch) || ch == '.') {
9610            end = index;
9611            break;
9612        }
9613        end = index + ch.len_utf8();
9614    }
9615    let rest = value[end..].trim_start();
9616    if let Some(stripped) = rest.strip_prefix('(') {
9617        let mut depth = 1usize;
9618        for (index, ch) in stripped.char_indices() {
9619            match ch {
9620                '(' => depth += 1,
9621                ')' => {
9622                    depth = depth.saturating_sub(1);
9623                    if depth == 0 {
9624                        return Some(stripped[index + ch.len_utf8()..].trim_start());
9625                    }
9626                }
9627                _ => {}
9628            }
9629        }
9630        return Some("");
9631    }
9632    Some(rest)
9633}
9634
9635fn strip_leading_java_modifier(value: &str) -> Option<&str> {
9636    const MODIFIERS: &[&str] = &[
9637        "public",
9638        "protected",
9639        "private",
9640        "abstract",
9641        "static",
9642        "final",
9643        "transient",
9644        "volatile",
9645        "synchronized",
9646        "native",
9647        "strictfp",
9648    ];
9649    MODIFIERS
9650        .iter()
9651        .find_map(|modifier| strip_leading_word(value, modifier))
9652}
9653
9654fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
9655    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
9656    let before = &declaration[..receiver_start];
9657    if find_identifier_occurrence(before, "val").is_none()
9658        && find_identifier_occurrence(before, "var").is_none()
9659    {
9660        return None;
9661    }
9662
9663    let after = declaration[receiver_start + receiver.len()..].trim_start();
9664    if let Some(type_text) = after.strip_prefix(':') {
9665        return normalize_receiver_type_name(read_type_prefix(type_text));
9666    }
9667    after
9668        .strip_prefix('=')
9669        .and_then(infer_kotlin_constructor_type)
9670}
9671
9672fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
9673    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
9674    if rest.trim_start().starts_with('(') {
9675        normalize_receiver_type_name(head)
9676    } else {
9677        None
9678    }
9679}
9680
9681fn read_type_prefix(value: &str) -> &str {
9682    let mut angle_depth = 0usize;
9683    for (index, ch) in value.char_indices() {
9684        match ch {
9685            '<' => angle_depth += 1,
9686            '>' => angle_depth = angle_depth.saturating_sub(1),
9687            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
9688                return value[..index].trim();
9689            }
9690            _ => {}
9691        }
9692    }
9693    value.trim()
9694}
9695
9696fn infer_cpp_receiver_type_from_scope(
9697    scope: tree_sitter::Node<'_>,
9698    source: &str,
9699    receiver: &str,
9700    call_line: u32,
9701) -> Option<String> {
9702    let lines = source.lines().collect::<Vec<_>>();
9703    if lines.is_empty() {
9704        return None;
9705    }
9706    let scope_start = scope.start_position().row as usize;
9707    let call_index = (call_line as usize)
9708        .saturating_sub(1)
9709        .min(lines.len().saturating_sub(1));
9710    for index in (scope_start..=call_index).rev() {
9711        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
9712            return Some(receiver_type);
9713        }
9714    }
9715    None
9716}
9717
9718fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
9719    for receiver_start in identifier_occurrences(line, receiver) {
9720        let after = line[receiver_start + receiver.len()..].trim_start();
9721        if after
9722            .chars()
9723            .next()
9724            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
9725        {
9726            continue;
9727        }
9728        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
9729        let normalized = normalize_cpp_type_name(type_text)?;
9730        if normalized == "auto" {
9731            if let Some(rhs) = after.strip_prefix('=') {
9732                return infer_cpp_auto_receiver_type(rhs);
9733            }
9734            continue;
9735        }
9736        return Some(normalized);
9737    }
9738    None
9739}
9740
9741fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
9742    let candidate = prefix
9743        .rsplit([';', '{', '}', '('])
9744        .next()
9745        .unwrap_or(prefix)
9746        .trim();
9747    if candidate.is_empty() || candidate.ends_with(',') {
9748        None
9749    } else {
9750        Some(candidate)
9751    }
9752}
9753
9754fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
9755    let without_templates = strip_angle_groups(type_text);
9756    let mut cleaned = String::with_capacity(without_templates.len());
9757    for token in without_templates.split_whitespace() {
9758        if matches!(
9759            token,
9760            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
9761        ) {
9762            continue;
9763        }
9764        if !cleaned.is_empty() {
9765            cleaned.push(' ');
9766        }
9767        cleaned.push_str(token);
9768    }
9769    let token = cleaned
9770        .split_whitespace()
9771        .last()
9772        .unwrap_or(cleaned.trim())
9773        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
9774        .trim_matches(['*', '&']);
9775    let simple = token.rsplit("::").next().unwrap_or(token).trim();
9776    if simple.is_empty() || cpp_non_type_token(simple) {
9777        None
9778    } else {
9779        Some(simple.to_string())
9780    }
9781}
9782
9783fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
9784    let rhs = rhs.trim_start();
9785    if let Some(after_new) = rhs.strip_prefix("new ") {
9786        return infer_cpp_constructor_type(after_new);
9787    }
9788    infer_cpp_make_template_type(rhs)
9789        .or_else(|| infer_cpp_constructor_type(rhs))
9790        .or_else(|| infer_cpp_factory_type(rhs))
9791}
9792
9793fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9794    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9795    let normalized = normalize_cpp_type_name(head)?;
9796    if !normalized
9797        .chars()
9798        .next()
9799        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9800    {
9801        return None;
9802    }
9803    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9804        Some(normalized)
9805    } else {
9806        None
9807    }
9808}
9809
9810fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9811    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9812    if !rest.trim_start().starts_with('(') {
9813        return None;
9814    }
9815    let base = head.split('<').next().unwrap_or(head);
9816    let base_simple = base.rsplit("::").next().unwrap_or(base);
9817    if !matches!(base_simple, "make_unique" | "make_shared") {
9818        return None;
9819    }
9820    first_angle_arg(head).and_then(normalize_cpp_type_name)
9821}
9822
9823fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9824    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9825    if !rest.trim_start().starts_with('(') {
9826        return None;
9827    }
9828    let simple = head
9829        .split('<')
9830        .next()
9831        .unwrap_or(head)
9832        .rsplit("::")
9833        .next()
9834        .unwrap_or(head);
9835    for prefix in ["make", "create", "build"] {
9836        if let Some(suffix) = simple.strip_prefix(prefix) {
9837            if suffix
9838                .chars()
9839                .next()
9840                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9841            {
9842                return normalize_cpp_type_name(suffix);
9843            }
9844        }
9845    }
9846    None
9847}
9848
9849#[derive(Debug, Clone, Copy)]
9850enum JavaLikeInvocation {
9851    Kotlin,
9852    Cpp,
9853}
9854
9855fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9856    let value = value.trim_start();
9857    let mut end = 0usize;
9858    for (index, ch) in value.char_indices() {
9859        let allowed_separator = match flavor {
9860            JavaLikeInvocation::Kotlin => ch == '.',
9861            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9862        };
9863        if is_code_ident_char(ch) || allowed_separator {
9864            end = index + ch.len_utf8();
9865            continue;
9866        }
9867        break;
9868    }
9869    if end == 0 {
9870        return None;
9871    }
9872    let mut rest = &value[end..];
9873    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9874        let skipped = skip_balanced_angle(stripped)?;
9875        let rest_start = rest.len() - rest.trim_start().len();
9876        let angle_len = 1 + skipped;
9877        end += rest_start + angle_len;
9878        rest = &value[end..];
9879    }
9880    Some((value[..end].trim(), rest))
9881}
9882
9883fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9884    let mut depth = 1usize;
9885    for (index, ch) in value_after_open.char_indices() {
9886        match ch {
9887            '<' => depth += 1,
9888            '>' => {
9889                depth = depth.saturating_sub(1);
9890                if depth == 0 {
9891                    return Some(index + ch.len_utf8());
9892                }
9893            }
9894            _ => {}
9895        }
9896    }
9897    None
9898}
9899
9900fn first_angle_arg(value: &str) -> Option<&str> {
9901    let open = value.find('<')?;
9902    let inner_len = skip_balanced_angle(&value[open + 1..])?;
9903    let inner = &value[open + 1..open + inner_len];
9904    split_top_level_commas(inner).into_iter().next()
9905}
9906
9907fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9908    let without_generics = strip_angle_groups(type_text);
9909    let cleaned = without_generics
9910        .replace("[]", " ")
9911        .replace("...", " ")
9912        .replace(['?', '&', '*'], " ");
9913    let token = cleaned
9914        .split_whitespace()
9915        .last()
9916        .unwrap_or(cleaned.trim())
9917        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9918    let token = token.rsplit("::").next().unwrap_or(token);
9919    let simple = token.rsplit('.').next().unwrap_or(token).trim();
9920    if simple.is_empty()
9921        || java_like_primitive_type(simple)
9922        || !simple
9923            .chars()
9924            .next()
9925            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9926    {
9927        None
9928    } else {
9929        Some(simple.to_string())
9930    }
9931}
9932
9933fn simple_type_name(scoped_name: &str) -> Option<String> {
9934    scoped_name
9935        .rsplit("::")
9936        .find(|segment| !segment.is_empty())
9937        .and_then(normalize_receiver_type_name)
9938}
9939
9940fn strip_angle_groups(value: &str) -> String {
9941    let mut output = String::with_capacity(value.len());
9942    let mut depth = 0usize;
9943    for ch in value.chars() {
9944        match ch {
9945            '<' => {
9946                if depth == 0 {
9947                    output.push(' ');
9948                }
9949                depth += 1;
9950            }
9951            '>' => depth = depth.saturating_sub(1),
9952            _ if depth == 0 => output.push(ch),
9953            _ => {}
9954        }
9955    }
9956    output
9957}
9958
9959fn java_like_primitive_type(value: &str) -> bool {
9960    matches!(
9961        value,
9962        "boolean"
9963            | "byte"
9964            | "char"
9965            | "double"
9966            | "float"
9967            | "int"
9968            | "long"
9969            | "short"
9970            | "void"
9971            | "Boolean"
9972            | "Byte"
9973            | "Char"
9974            | "Double"
9975            | "Float"
9976            | "Int"
9977            | "Long"
9978            | "Short"
9979            | "Unit"
9980    )
9981}
9982
9983fn cpp_non_type_token(value: &str) -> bool {
9984    matches!(
9985        value,
9986        "return"
9987            | "if"
9988            | "else"
9989            | "for"
9990            | "while"
9991            | "do"
9992            | "switch"
9993            | "case"
9994            | "default"
9995            | "break"
9996            | "continue"
9997            | "goto"
9998            | "throw"
9999            | "new"
10000            | "delete"
10001            | "co_await"
10002            | "co_yield"
10003            | "co_return"
10004            | "static_cast"
10005            | "const_cast"
10006            | "dynamic_cast"
10007            | "reinterpret_cast"
10008            | "sizeof"
10009            | "alignof"
10010            | "typeid"
10011            | "and"
10012            | "or"
10013            | "not"
10014            | "xor"
10015    )
10016}
10017
10018fn receiver_is_bare_identifier(value: &str) -> bool {
10019    let mut chars = value.chars();
10020    let Some(first) = chars.next() else {
10021        return false;
10022    };
10023    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
10024}
10025
10026fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
10027    identifier_occurrences(value, needle).into_iter().next()
10028}
10029
10030fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
10031    value
10032        .match_indices(needle)
10033        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
10034        .collect()
10035}
10036
10037fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
10038    let before = value[..start].chars().next_back();
10039    let after = value[start + len..].chars().next();
10040    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
10041}
10042
10043fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
10044    let stripped = value.strip_prefix(word)?;
10045    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
10046        Some(stripped.trim_start())
10047    } else {
10048        None
10049    }
10050}
10051
10052fn is_code_ident_char(ch: char) -> bool {
10053    ch == '_' || ch.is_ascii_alphanumeric()
10054}
10055
10056fn infer_rust_receiver_type(
10057    project_root: &Path,
10058    reference: &NameMatchRef,
10059    source_cache: &mut DispatchSourceCache,
10060) -> ReceiverTypeInference {
10061    if matches!(reference.receiver.as_str(), "self" | "Self") {
10062        return enclosing_type_from_scoped_name(&reference.caller_symbol)
10063            .map(ReceiverTypeInference::Known)
10064            .unwrap_or(ReceiverTypeInference::Unknown);
10065    }
10066
10067    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
10068        return ReceiverTypeInference::Known(reference.receiver.clone());
10069    }
10070
10071    if let Some(receiver_type) = reference
10072        .caller_signature
10073        .as_deref()
10074        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
10075    {
10076        return ReceiverTypeInference::Known(receiver_type);
10077    }
10078
10079    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
10080}
10081
10082fn infer_rust_direct_self_field_receiver_type(
10083    project_root: &Path,
10084    reference: &NameMatchRef,
10085    source_cache: &mut DispatchSourceCache,
10086) -> ReceiverTypeInference {
10087    if reference.colon_dispatch {
10088        return ReceiverTypeInference::Unknown;
10089    }
10090    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
10091        return ReceiverTypeInference::Unknown;
10092    };
10093    if field_name != reference.receiver {
10094        return ReceiverTypeInference::Unknown;
10095    }
10096
10097    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
10098        return ReceiverTypeInference::Unknown;
10099    };
10100    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
10101        return ReceiverTypeInference::KnownButUnresolved;
10102    };
10103    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
10104    else {
10105        return ReceiverTypeInference::Unknown;
10106    };
10107    let Some(impl_node) =
10108        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
10109    else {
10110        return ReceiverTypeInference::Unknown;
10111    };
10112    if impl_node.child_by_field_name("trait").is_some()
10113        || impl_node.child_by_field_name("type_parameters").is_some()
10114    {
10115        return ReceiverTypeInference::KnownButUnresolved;
10116    }
10117    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10118        return ReceiverTypeInference::KnownButUnresolved;
10119    };
10120    if impl_target.kind() != "type_identifier"
10121        || node_text(impl_target, &parsed.source) != impl_type
10122    {
10123        return ReceiverTypeInference::KnownButUnresolved;
10124    }
10125
10126    let module_scope = rust_module_scope(impl_node);
10127    let Some(struct_node) = find_unique_rust_struct(
10128        parsed.tree.root_node(),
10129        &parsed.source,
10130        struct_name,
10131        &module_scope,
10132    ) else {
10133        return ReceiverTypeInference::KnownButUnresolved;
10134    };
10135    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
10136    else {
10137        return ReceiverTypeInference::KnownButUnresolved;
10138    };
10139    if field_type.kind() != "type_identifier" {
10140        return ReceiverTypeInference::KnownButUnresolved;
10141    }
10142    let field_type_name = node_text(field_type, &parsed.source);
10143    if find_unique_rust_struct(
10144        parsed.tree.root_node(),
10145        &parsed.source,
10146        field_type_name,
10147        &module_scope,
10148    )
10149    .is_none()
10150    {
10151        return ReceiverTypeInference::KnownButUnresolved;
10152    }
10153
10154    ReceiverTypeInference::RustDirectSelfField {
10155        receiver_type: field_type_name.to_string(),
10156        declaration_file: reference.caller_file.clone(),
10157        module_scope,
10158    }
10159}
10160
10161fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
10162    let (base, field) = receiver_expression.split_once('.')?;
10163    let base = base.trim();
10164    let field = field.trim();
10165    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
10166}
10167
10168fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
10169    let name = value.rsplit("::").next()?.trim();
10170    (!name.is_empty()
10171        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
10172        && name.chars().all(is_rust_ident_char))
10173    .then_some(name)
10174}
10175
10176fn find_enclosing_rust_impl_node<'tree>(
10177    root: tree_sitter::Node<'tree>,
10178    line: u32,
10179) -> Option<tree_sitter::Node<'tree>> {
10180    let mut best = None;
10181    let mut stack = vec![root];
10182    while let Some(node) = stack.pop() {
10183        if !node_contains_line(node, line) {
10184            continue;
10185        }
10186        if node.kind() == "impl_item" {
10187            best = tighter_node(best, node);
10188        }
10189        push_named_children(node, &mut stack);
10190    }
10191    best
10192}
10193
10194fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
10195    let mut scope = Vec::new();
10196    let mut current = node.parent();
10197    while let Some(parent) = current {
10198        if parent.kind() == "mod_item" {
10199            scope.push((parent.start_byte(), parent.end_byte()));
10200        }
10201        current = parent.parent();
10202    }
10203    scope.reverse();
10204    scope
10205}
10206
10207fn find_unique_rust_struct<'tree>(
10208    root: tree_sitter::Node<'tree>,
10209    source: &str,
10210    expected_name: &str,
10211    module_scope: &[(usize, usize)],
10212) -> Option<tree_sitter::Node<'tree>> {
10213    let mut found = None;
10214    let mut stack = vec![root];
10215    while let Some(node) = stack.pop() {
10216        if node.kind() == "struct_item"
10217            && rust_module_scope(node) == module_scope
10218            && node.child_by_field_name("type_parameters").is_none()
10219            && declaration_name(node, source) == Some(expected_name)
10220        {
10221            if found.is_some() {
10222                return None;
10223            }
10224            found = Some(node);
10225        }
10226        push_named_children(node, &mut stack);
10227    }
10228    found
10229}
10230
10231fn rust_struct_field_type_node<'tree>(
10232    struct_node: tree_sitter::Node<'tree>,
10233    source: &str,
10234    field_name: &str,
10235) -> Option<tree_sitter::Node<'tree>> {
10236    let fields = struct_node.child_by_field_name("body")?;
10237    if fields.kind() != "field_declaration_list" {
10238        return None;
10239    }
10240    for index in 0..fields.named_child_count() {
10241        let field = fields.named_child(index as u32)?;
10242        if field.kind() != "field_declaration"
10243            || declaration_name(field, source) != Some(field_name)
10244        {
10245            continue;
10246        }
10247        return field.child_by_field_name("type");
10248    }
10249    None
10250}
10251
10252fn rust_receiver_looks_type_like(receiver: &str) -> bool {
10253    receiver
10254        .chars()
10255        .next()
10256        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
10257}
10258
10259fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
10260    scoped_name
10261        .rsplit_once("::")
10262        .map(|(enclosing, _)| enclosing)
10263        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
10264        .map(ToString::to_string)
10265}
10266
10267fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
10268    let params = signature_parameter_text(signature)?;
10269    for param in split_top_level_commas(params) {
10270        let Some((pattern, type_text)) = param.split_once(':') else {
10271            continue;
10272        };
10273        let Some(name) = rust_parameter_name(pattern) else {
10274            continue;
10275        };
10276        if name == receiver {
10277            return normalize_rust_receiver_type(type_text);
10278        }
10279    }
10280    None
10281}
10282
10283fn signature_parameter_text(signature: &str) -> Option<&str> {
10284    let open = signature.find('(')?;
10285    let mut depth = 0usize;
10286    for (offset, ch) in signature[open..].char_indices() {
10287        match ch {
10288            '(' => depth += 1,
10289            ')' => {
10290                depth = depth.saturating_sub(1);
10291                if depth == 0 {
10292                    return Some(&signature[open + 1..open + offset]);
10293                }
10294            }
10295            _ => {}
10296        }
10297    }
10298    None
10299}
10300
10301fn split_top_level_commas(value: &str) -> Vec<&str> {
10302    let mut parts = Vec::new();
10303    let mut start = 0usize;
10304    let mut angle_depth = 0usize;
10305    let mut paren_depth = 0usize;
10306    let mut bracket_depth = 0usize;
10307    for (index, ch) in value.char_indices() {
10308        match ch {
10309            '<' => angle_depth += 1,
10310            '>' => angle_depth = angle_depth.saturating_sub(1),
10311            '(' => paren_depth += 1,
10312            ')' => paren_depth = paren_depth.saturating_sub(1),
10313            '[' => bracket_depth += 1,
10314            ']' => bracket_depth = bracket_depth.saturating_sub(1),
10315            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
10316                let part = value[start..index].trim();
10317                if !part.is_empty() {
10318                    parts.push(part);
10319                }
10320                start = index + ch.len_utf8();
10321            }
10322            _ => {}
10323        }
10324    }
10325    let part = value[start..].trim();
10326    if !part.is_empty() {
10327        parts.push(part);
10328    }
10329    parts
10330}
10331
10332fn rust_parameter_name(pattern: &str) -> Option<&str> {
10333    let mut pattern = pattern.trim();
10334    if let Some(stripped) = pattern.strip_prefix("mut ") {
10335        pattern = stripped.trim_start();
10336    }
10337    pattern
10338        .rsplit(|ch: char| !is_rust_ident_char(ch))
10339        .find(|part| !part.is_empty())
10340}
10341
10342fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
10343    let mut ty = strip_leading_rust_type_modifiers(type_text);
10344    let owned_inner;
10345    if let Some(inner) = single_outer_generic_arg(ty) {
10346        owned_inner = inner.trim().to_string();
10347        ty = strip_leading_rust_type_modifiers(&owned_inner);
10348    }
10349    rust_base_type_ident(ty)
10350}
10351
10352fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
10353    loop {
10354        ty = ty.trim_start();
10355        if let Some(stripped) = ty.strip_prefix('&') {
10356            ty = stripped.trim_start();
10357            if let Some(stripped) = strip_leading_lifetime(ty) {
10358                ty = stripped.trim_start();
10359            }
10360            if let Some(stripped) = ty.strip_prefix("mut ") {
10361                ty = stripped.trim_start();
10362            }
10363            continue;
10364        }
10365        if let Some(stripped) = ty.strip_prefix("mut ") {
10366            ty = stripped.trim_start();
10367            continue;
10368        }
10369        if let Some(stripped) = ty.strip_prefix("dyn ") {
10370            ty = stripped.trim_start();
10371            continue;
10372        }
10373        if let Some(stripped) = ty.strip_prefix("impl ") {
10374            ty = stripped.trim_start();
10375            continue;
10376        }
10377        break ty.trim();
10378    }
10379}
10380
10381fn strip_leading_lifetime(value: &str) -> Option<&str> {
10382    let mut chars = value.char_indices();
10383    let (_, first) = chars.next()?;
10384    if first != '\'' {
10385        return None;
10386    }
10387    for (index, ch) in chars {
10388        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
10389            return Some(&value[index..]);
10390        }
10391    }
10392    Some("")
10393}
10394
10395fn single_outer_generic_arg(ty: &str) -> Option<&str> {
10396    let ty = ty.trim();
10397    let open = ty.find('<')?;
10398    let mut depth = 0usize;
10399    let mut close = None;
10400    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
10401        match ch {
10402            '<' => depth += 1,
10403            '>' => {
10404                depth = depth.saturating_sub(1);
10405                if depth == 0 {
10406                    close = Some(index);
10407                    break;
10408                }
10409            }
10410            _ => {}
10411        }
10412    }
10413    let close = close?;
10414    if !ty[close + 1..].trim().is_empty() {
10415        return None;
10416    }
10417    let inner = &ty[open + 1..close];
10418    let args = split_top_level_commas(inner);
10419    match args.as_slice() {
10420        [arg] => Some(*arg),
10421        _ => None,
10422    }
10423}
10424
10425fn rust_base_type_ident(ty: &str) -> Option<String> {
10426    let ty = ty.trim();
10427    let head = ty
10428        .split([' ', '+', '='])
10429        .find(|part| !part.is_empty())
10430        .unwrap_or(ty);
10431    let head = head.split('<').next().unwrap_or(head).trim();
10432    let ident = head
10433        .rsplit("::")
10434        .next()
10435        .unwrap_or(head)
10436        .trim_matches(|ch: char| !is_rust_ident_char(ch));
10437    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
10438        None
10439    } else {
10440        Some(ident.to_string())
10441    }
10442}
10443
10444fn is_rust_ident_char(ch: char) -> bool {
10445    ch == '_' || ch.is_ascii_alphanumeric()
10446}
10447
10448fn select_rust_direct_self_field_candidate(
10449    project_root: &Path,
10450    reference: &NameMatchRef,
10451    candidates: &[NameMatchCandidate],
10452    receiver_type: &str,
10453    declaration_file: &str,
10454    declaration_scope: &[(usize, usize)],
10455    source_cache: &mut DispatchSourceCache,
10456) -> Option<NameMatchCandidate> {
10457    let eligible = candidates
10458        .iter()
10459        .filter(|candidate| candidate.node_id != reference.caller_node)
10460        .filter(|candidate| {
10461            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10462        })
10463        .filter(|candidate| {
10464            rust_direct_self_field_candidate_matches_scope(
10465                project_root,
10466                candidate,
10467                receiver_type,
10468                declaration_file,
10469                declaration_scope,
10470                source_cache,
10471            )
10472        })
10473        .collect::<Vec<_>>();
10474    match eligible.as_slice() {
10475        [candidate] => Some((**candidate).clone()),
10476        _ => None,
10477    }
10478}
10479
10480fn rust_direct_self_field_candidate_matches_scope(
10481    project_root: &Path,
10482    candidate: &NameMatchCandidate,
10483    receiver_type: &str,
10484    declaration_file: &str,
10485    declaration_scope: &[(usize, usize)],
10486    source_cache: &mut DispatchSourceCache,
10487) -> bool {
10488    if candidate.file_path != declaration_file {
10489        return false;
10490    }
10491    let Some(parsed) = parsed_dispatch_source_for_file(
10492        project_root,
10493        &candidate.file_path,
10494        "rust",
10495        LangId::Rust,
10496        source_cache,
10497    ) else {
10498        return false;
10499    };
10500    let Some(impl_node) =
10501        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
10502    else {
10503        return false;
10504    };
10505    if impl_node.child_by_field_name("trait").is_some()
10506        || impl_node.child_by_field_name("type_parameters").is_some()
10507    {
10508        return false;
10509    }
10510    let Some(impl_target) = impl_node.child_by_field_name("type") else {
10511        return false;
10512    };
10513    impl_target.kind() == "type_identifier"
10514        && node_text(impl_target, &parsed.source) == receiver_type
10515        && rust_module_scope(impl_node) == declaration_scope
10516}
10517
10518fn select_type_match_candidate(
10519    reference: &NameMatchRef,
10520    candidates: &[NameMatchCandidate],
10521    receiver_type: &str,
10522) -> Option<NameMatchCandidate> {
10523    let candidates = candidates
10524        .iter()
10525        .filter(|candidate| candidate.node_id != reference.caller_node)
10526        .filter(|candidate| {
10527            type_candidate_matches(candidate, receiver_type, &reference.method_name)
10528        })
10529        .collect::<Vec<_>>();
10530    match candidates.as_slice() {
10531        [candidate] => Some((**candidate).clone()),
10532        _ => None,
10533    }
10534}
10535
10536fn type_candidate_matches(
10537    candidate: &NameMatchCandidate,
10538    receiver_type: &str,
10539    method_name: &str,
10540) -> bool {
10541    let normalized_type = receiver_type.replace('.', "::");
10542    let suffix = format!("{normalized_type}::{method_name}");
10543    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
10544}
10545
10546fn select_name_match_candidate(
10547    reference: &NameMatchRef,
10548    candidates: &[NameMatchCandidate],
10549) -> Option<NameMatchCandidate> {
10550    let candidates = candidates
10551        .iter()
10552        .filter(|candidate| candidate.node_id != reference.caller_node)
10553        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
10554        .collect::<Vec<_>>();
10555    match candidates.as_slice() {
10556        [] => None,
10557        [candidate] => Some((**candidate).clone()),
10558        _ => select_scored_name_match_candidate(reference, &candidates),
10559    }
10560}
10561
10562fn candidate_allowed_for_reference(
10563    reference: &NameMatchRef,
10564    candidate: &NameMatchCandidate,
10565) -> bool {
10566    if !reference.colon_dispatch {
10567        return true;
10568    }
10569
10570    candidate.kind == "method"
10571        && candidate
10572            .scoped_name
10573            .split("::")
10574            .any(|segment| segment == reference.receiver)
10575}
10576
10577fn select_scored_name_match_candidate(
10578    reference: &NameMatchRef,
10579    candidates: &[&NameMatchCandidate],
10580) -> Option<NameMatchCandidate> {
10581    let receiver_words = split_camel_case(&reference.receiver);
10582    if receiver_words.is_empty() {
10583        return None;
10584    }
10585
10586    let mut best: Option<(&NameMatchCandidate, f64)> = None;
10587    let mut tied_best = false;
10588    for candidate in candidates {
10589        let candidate_words = split_camel_case(&candidate.scoped_name);
10590        let overlap = receiver_words
10591            .iter()
10592            .filter(|receiver_word| {
10593                candidate_words
10594                    .iter()
10595                    .any(|candidate_word| candidate_word == *receiver_word)
10596            })
10597            .count() as f64;
10598        let score =
10599            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
10600        match best {
10601            None => {
10602                best = Some((*candidate, score));
10603                tied_best = false;
10604            }
10605            Some((_, best_score)) if score > best_score => {
10606                best = Some((*candidate, score));
10607                tied_best = false;
10608            }
10609            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
10610                tied_best = true;
10611            }
10612            _ => {}
10613        }
10614    }
10615
10616    let (candidate, score) = best?;
10617    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
10618        Some(candidate.clone())
10619    } else {
10620        None
10621    }
10622}
10623
10624fn method_name_match_denylisted(method_name: &str) -> bool {
10625    matches!(
10626        method_name,
10627        "and_then"
10628            | "as_bytes"
10629            | "as_deref"
10630            | "as_mut"
10631            | "as_ref"
10632            | "as_str"
10633            | "borrow"
10634            | "borrow_mut"
10635            | "clear"
10636            | "clone"
10637            | "collect"
10638            | "contains"
10639            | "contains_key"
10640            | "count"
10641            | "dedup"
10642            | "default"
10643            | "drain"
10644            | "ends_with"
10645            | "entry"
10646            | "err"
10647            | "expect"
10648            | "extend"
10649            | "filter"
10650            | "filter_map"
10651            | "find"
10652            | "from"
10653            | "get"
10654            | "get_mut"
10655            | "insert"
10656            | "into"
10657            | "into_iter"
10658            | "is_empty"
10659            | "is_err"
10660            | "is_none"
10661            | "is_ok"
10662            | "is_some"
10663            | "iter"
10664            | "iter_mut"
10665            | "join"
10666            | "len"
10667            | "lock"
10668            | "map"
10669            | "map_err"
10670            | "max"
10671            | "min"
10672            | "new"
10673            | "next"
10674            | "ok"
10675            | "or_default"
10676            | "or_else"
10677            | "or_insert"
10678            | "or_insert_with"
10679            | "parse"
10680            | "pop"
10681            | "position"
10682            | "push"
10683            | "read"
10684            | "recv"
10685            | "remove"
10686            | "replace"
10687            | "retain"
10688            | "send"
10689            | "sort"
10690            | "sort_by"
10691            | "split"
10692            | "starts_with"
10693            | "sum"
10694            | "take"
10695            | "to_owned"
10696            | "to_string"
10697            | "trim"
10698            | "try_from"
10699            | "try_into"
10700            | "unwrap"
10701            | "unwrap_or"
10702            | "unwrap_or_default"
10703            | "unwrap_or_else"
10704            | "with_capacity"
10705            | "write"
10706    )
10707}
10708
10709fn split_camel_case(value: &str) -> Vec<String> {
10710    let chars = value.chars().collect::<Vec<_>>();
10711    let mut normalized = String::with_capacity(value.len() + 8);
10712    for (index, ch) in chars.iter().enumerate() {
10713        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
10714        let next = chars.get(index + 1);
10715        let is_separator = ch.is_whitespace()
10716            || matches!(
10717                ch,
10718                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
10719            );
10720        if is_separator {
10721            normalized.push(' ');
10722            continue;
10723        }
10724        let camel_boundary = previous.is_some_and(|prev| {
10725            (prev.is_lowercase() && ch.is_uppercase())
10726                || (prev.is_ascii_digit() && ch.is_alphabetic())
10727                || (prev.is_uppercase()
10728                    && ch.is_uppercase()
10729                    && next.is_some_and(|next| next.is_lowercase()))
10730        });
10731        if camel_boundary {
10732            normalized.push(' ');
10733        }
10734        normalized.push(*ch);
10735    }
10736
10737    normalized
10738        .split_whitespace()
10739        .filter(|word| word.len() > 1)
10740        .map(|word| word.to_ascii_lowercase())
10741        .collect()
10742}
10743
10744fn compute_path_proximity(left: &str, right: &str) -> f64 {
10745    let left_dirs = left
10746        .rsplit_once('/')
10747        .map(|(dir, _)| dir)
10748        .unwrap_or_default()
10749        .split('/')
10750        .filter(|part| !part.is_empty());
10751    let right_dirs = right
10752        .rsplit_once('/')
10753        .map(|(dir, _)| dir)
10754        .unwrap_or_default()
10755        .split('/')
10756        .filter(|part| !part.is_empty());
10757
10758    let shared = left_dirs
10759        .zip(right_dirs)
10760        .take_while(|(left, right)| left == right)
10761        .count();
10762    ((shared as f64) * 0.05).min(0.5)
10763}
10764
10765fn mark_backend_state(
10766    tx: &Transaction<'_>,
10767    project_root: &Path,
10768    rel_path: &str,
10769    content_hash: Option<&blake3::Hash>,
10770    status: &str,
10771) -> Result<()> {
10772    clear_backend_state_for_file(tx, project_root, rel_path)?;
10773    let hash = content_hash
10774        .map(|hash| hash_to_hex(*hash))
10775        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10776    tx.execute(
10777        "INSERT OR REPLACE INTO backend_file_state(
10778            backend, workspace_root, file_path, content_hash, status, updated_at
10779        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10780        params![
10781            BACKEND_TREESITTER,
10782            project_root.display().to_string(),
10783            rel_path,
10784            hash,
10785            status,
10786            unix_seconds_now(),
10787        ],
10788    )?;
10789    Ok(())
10790}
10791
10792fn clear_backend_state_for_file(
10793    tx: &Transaction<'_>,
10794    project_root: &Path,
10795    rel_path: &str,
10796) -> Result<()> {
10797    tx.execute(
10798        "DELETE FROM backend_file_state
10799         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
10800        params![
10801            BACKEND_TREESITTER,
10802            project_root.display().to_string(),
10803            rel_path
10804        ],
10805    )?;
10806    Ok(())
10807}
10808
10809fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
10810    tx.query_row(
10811        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
10812        params![rel_path],
10813        |row| {
10814            let hash_text: String = row.get(1)?;
10815            Ok(FileRow {
10816                surface_fingerprint: row.get(0)?,
10817                freshness: FileFreshness {
10818                    content_hash: hash_from_hex(&hash_text)
10819                        .unwrap_or_else(cache_freshness::zero_hash),
10820                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
10821                    size: row.get::<_, i64>(3)? as u64,
10822                },
10823            })
10824        },
10825    )
10826    .optional()
10827    .map_err(CallGraphStoreError::from)
10828}
10829
10830fn stored_node_ids_match_extract(
10831    tx: &Transaction<'_>,
10832    rel_path: &str,
10833    extract: &FileExtract,
10834) -> Result<bool> {
10835    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
10836    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
10837    let mut stored = BTreeSet::new();
10838    for row in rows {
10839        stored.insert(row?);
10840    }
10841    let extracted = extract
10842        .nodes
10843        .iter()
10844        .map(|node| node.id.clone())
10845        .collect::<BTreeSet<_>>();
10846    Ok(stored == extracted)
10847}
10848
10849/// Compare every persisted graph row that comes from this file before rewriting it.
10850/// Ranges and reference byte offsets are part of the key because queries expose
10851/// source locations; equal names and edges are not enough after a body shift.
10852fn stored_extract_matches(
10853    tx: &Transaction<'_>,
10854    rel_path: &str,
10855    extract: &FileExtract,
10856    index: &ProjectIndex<'_>,
10857) -> Result<bool> {
10858    let stored_file = tx
10859        .query_row(
10860            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
10861            params![rel_path],
10862            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
10863        )
10864        .optional()?;
10865    if stored_file
10866        != Some((
10867            lang_label(extract.lang).to_string(),
10868            extract.surface_fingerprint.clone(),
10869        ))
10870    {
10871        return Ok(false);
10872    }
10873
10874    let mut stored_nodes_stmt = tx.prepare(
10875        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
10876                end_line, end_col, range_ordinal, signature, exported,
10877                is_default_export, is_type_like, is_callgraph_entry_point, provenance
10878         FROM nodes WHERE file_path = ?1",
10879    )?;
10880    let stored_nodes = stored_nodes_stmt
10881        .query_map(params![rel_path], |row| {
10882            Ok(serde_json::json!([
10883                row.get::<_, String>(0)?,
10884                row.get::<_, String>(1)?,
10885                row.get::<_, String>(2)?,
10886                row.get::<_, String>(3)?,
10887                row.get::<_, String>(4)?,
10888                row.get::<_, i64>(5)?,
10889                row.get::<_, i64>(6)?,
10890                row.get::<_, i64>(7)?,
10891                row.get::<_, i64>(8)?,
10892                row.get::<_, i64>(9)?,
10893                row.get::<_, Option<String>>(10)?,
10894                row.get::<_, i64>(11)?,
10895                row.get::<_, i64>(12)?,
10896                row.get::<_, i64>(13)?,
10897                row.get::<_, i64>(14)?,
10898                row.get::<_, String>(15)?,
10899            ])
10900            .to_string())
10901        })?
10902        .collect::<rusqlite::Result<Vec<_>>>()?;
10903    let expected_nodes = extract
10904        .nodes
10905        .iter()
10906        .map(|node| {
10907            serde_json::json!([
10908                node.id,
10909                node.file_path,
10910                node.name,
10911                node.scoped_name,
10912                node.kind,
10913                node.range.start_line,
10914                node.range.start_col,
10915                node.range.end_line,
10916                node.range.end_col,
10917                node.range_ordinal,
10918                node.signature,
10919                bool_int(node.exported),
10920                bool_int(node.is_default_export),
10921                bool_int(node.is_type_like),
10922                bool_int(node.is_callgraph_entry_point),
10923                PROVENANCE_TREESITTER,
10924            ])
10925            .to_string()
10926        })
10927        .collect::<Vec<_>>();
10928    let mut stored_nodes = stored_nodes;
10929    let mut expected_nodes = expected_nodes;
10930    stored_nodes.sort();
10931    expected_nodes.sort();
10932    if stored_nodes != expected_nodes {
10933        return Ok(false);
10934    }
10935
10936    let resolved_refs = extract
10937        .raw_refs
10938        .iter()
10939        .cloned()
10940        .map(|raw| resolve_ref(raw, index))
10941        .collect::<Result<Vec<_>>>()?;
10942    let mut stored_refs_stmt = tx.prepare(
10943        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
10944                module_path, import_kind, local_name, requested_name, namespace_alias,
10945                wildcard, line, byte_start, byte_end, status, target_node,
10946                target_file, target_symbol, provenance
10947         FROM refs WHERE caller_file = ?1",
10948    )?;
10949    let stored_refs = stored_refs_stmt
10950        .query_map(params![rel_path], |row| {
10951            Ok(serde_json::json!([
10952                row.get::<_, String>(0)?,
10953                row.get::<_, Option<String>>(1)?,
10954                row.get::<_, String>(2)?,
10955                row.get::<_, String>(3)?,
10956                row.get::<_, Option<String>>(4)?,
10957                row.get::<_, Option<String>>(5)?,
10958                row.get::<_, Option<String>>(6)?,
10959                row.get::<_, Option<String>>(7)?,
10960                row.get::<_, Option<String>>(8)?,
10961                row.get::<_, Option<String>>(9)?,
10962                row.get::<_, Option<String>>(10)?,
10963                row.get::<_, i64>(11)?,
10964                row.get::<_, i64>(12)?,
10965                row.get::<_, i64>(13)?,
10966                row.get::<_, i64>(14)?,
10967                row.get::<_, String>(15)?,
10968                row.get::<_, Option<String>>(16)?,
10969                row.get::<_, Option<String>>(17)?,
10970                row.get::<_, Option<String>>(18)?,
10971                row.get::<_, String>(19)?,
10972            ])
10973            .to_string())
10974        })?
10975        .collect::<rusqlite::Result<Vec<_>>>()?;
10976    let expected_refs = resolved_refs
10977        .iter()
10978        .map(|resolved| {
10979            let raw = &resolved.raw;
10980            serde_json::json!([
10981                raw.ref_id,
10982                raw.caller_node,
10983                raw.caller_file,
10984                raw.kind,
10985                raw.short_name,
10986                raw.full_ref,
10987                raw.module_path,
10988                raw.import_kind,
10989                raw.local_name,
10990                raw.requested_name,
10991                raw.namespace_alias,
10992                bool_int(raw.wildcard),
10993                raw.line,
10994                raw.byte_start,
10995                raw.byte_end,
10996                resolved.status,
10997                resolved.target_node,
10998                resolved.target_file,
10999                resolved.target_symbol,
11000                PROVENANCE_TREESITTER,
11001            ])
11002            .to_string()
11003        })
11004        .collect::<Vec<_>>();
11005    let mut stored_refs = stored_refs;
11006    let mut expected_refs = expected_refs;
11007    stored_refs.sort();
11008    expected_refs.sort();
11009    if stored_refs != expected_refs {
11010        return Ok(false);
11011    }
11012
11013    let mut stored_edges_stmt = tx.prepare(
11014        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
11015                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
11016         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
11017         WHERE r.caller_file = ?1 AND e.provenance = ?2",
11018    )?;
11019    let stored_edges = stored_edges_stmt
11020        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
11021            Ok(serde_json::json!([
11022                row.get::<_, String>(0)?,
11023                row.get::<_, String>(1)?,
11024                row.get::<_, String>(2)?,
11025                row.get::<_, Option<String>>(3)?,
11026                row.get::<_, String>(4)?,
11027                row.get::<_, String>(5)?,
11028                row.get::<_, String>(6)?,
11029                row.get::<_, i64>(7)?,
11030                row.get::<_, String>(8)?,
11031            ])
11032            .to_string())
11033        })?
11034        .collect::<rusqlite::Result<Vec<_>>>()?;
11035    let expected_edges = resolved_refs
11036        .iter()
11037        .filter_map(|resolved| {
11038            resolved.edge.as_ref().map(|edge| {
11039                serde_json::json!([
11040                    edge.edge_id,
11041                    resolved.raw.ref_id,
11042                    edge.source_node,
11043                    edge.target_node,
11044                    edge.target_file,
11045                    edge.target_symbol,
11046                    edge.kind,
11047                    edge.line,
11048                    PROVENANCE_TREESITTER,
11049                ])
11050                .to_string()
11051            })
11052        })
11053        .collect::<Vec<_>>();
11054    let mut stored_edges = stored_edges;
11055    let mut expected_edges = expected_edges;
11056    stored_edges.sort();
11057    expected_edges.sort();
11058    if stored_edges != expected_edges {
11059        return Ok(false);
11060    }
11061
11062    let mut stored_dependencies_stmt =
11063        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
11064    let stored_dependencies = stored_dependencies_stmt
11065        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
11066        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
11067    let expected_dependencies = extract
11068        .raw_refs
11069        .iter()
11070        .flat_map(|raw| raw.dependencies.iter().cloned())
11071        .collect::<BTreeSet<_>>();
11072    if stored_dependencies != expected_dependencies {
11073        return Ok(false);
11074    }
11075
11076    let mut stored_hints_stmt = tx.prepare(
11077        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11078         FROM dispatch_hints WHERE file = ?1",
11079    )?;
11080    let stored_hints = stored_hints_stmt
11081        .query_map(params![rel_path], |row| {
11082            Ok(serde_json::json!([
11083                row.get::<_, String>(0)?,
11084                row.get::<_, String>(1)?,
11085                row.get::<_, String>(2)?,
11086                row.get::<_, String>(3)?,
11087                row.get::<_, i64>(4)?,
11088                row.get::<_, i64>(5)?,
11089                row.get::<_, i64>(6)?,
11090                row.get::<_, String>(7)?,
11091            ])
11092            .to_string())
11093        })?
11094        .collect::<rusqlite::Result<Vec<_>>>()?;
11095    let expected_hints = extract
11096        .dispatch_hints
11097        .iter()
11098        .map(|hint| {
11099            serde_json::json!([
11100                hint.id,
11101                hint.method_name,
11102                hint.caller_node,
11103                hint.file,
11104                hint.line,
11105                hint.byte_start,
11106                hint.byte_end,
11107                PROVENANCE_TREESITTER,
11108            ])
11109            .to_string()
11110        })
11111        .collect::<Vec<_>>();
11112    let mut stored_hints = stored_hints;
11113    let mut expected_hints = expected_hints;
11114    stored_hints.sort();
11115    expected_hints.sort();
11116    Ok(stored_hints == expected_hints)
11117}
11118
11119fn update_file_fresh_metadata(
11120    tx: &Transaction<'_>,
11121    project_root: &Path,
11122    rel_path: &str,
11123    hash: &blake3::Hash,
11124    mtime: SystemTime,
11125    size: u64,
11126) -> Result<()> {
11127    tx.execute(
11128        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
11129         WHERE path = ?1",
11130        params![
11131            rel_path,
11132            hash_to_hex(*hash),
11133            system_time_to_ns(mtime),
11134            size as i64,
11135            unix_seconds_now()
11136        ],
11137    )?;
11138    tx.execute(
11139        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
11140         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
11141        params![
11142            BACKEND_TREESITTER,
11143            rel_path,
11144            hash_to_hex(*hash),
11145            project_root.display().to_string(),
11146            unix_seconds_now(),
11147        ],
11148    )?;
11149    Ok(())
11150}
11151
11152#[derive(Debug, Clone, PartialEq, Eq)]
11153struct DependentRefSelection {
11154    ref_id: String,
11155    caller_file: String,
11156}
11157
11158fn ref_ids_depending_on(
11159    tx: &Transaction<'_>,
11160    project_root: &Path,
11161    rel_path: &str,
11162) -> Result<Vec<DependentRefSelection>> {
11163    let mut stmt = tx.prepare(
11164        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
11165         FROM refs r
11166         WHERE r.caller_file IN (
11167             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
11168         )
11169            OR r.target_file = ?1
11170         ORDER BY r.ref_id",
11171    )?;
11172    let rows = stmt.query_map(params![rel_path], |row| {
11173        Ok(RefDependencyRow {
11174            ref_id: row.get(0)?,
11175            kind: row.get(1)?,
11176            caller_file: row.get(2)?,
11177            module_path: row.get(3)?,
11178            target_file: row.get(4)?,
11179        })
11180    })?;
11181    let mut ids = Vec::new();
11182    for row in rows {
11183        let row = row?;
11184        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
11185            ids.push(DependentRefSelection {
11186                ref_id: row.ref_id,
11187                caller_file: row.caller_file,
11188            });
11189        }
11190    }
11191    Ok(ids)
11192}
11193
11194fn record_dependent_refs(
11195    selected_ref_ids: &mut BTreeSet<String>,
11196    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
11197    dependent_refs: Vec<DependentRefSelection>,
11198) {
11199    for dependent_ref in dependent_refs {
11200        let DependentRefSelection {
11201            ref_id,
11202            caller_file,
11203        } = dependent_ref;
11204        selected_ref_ids.insert(ref_id.clone());
11205        selected_refs_by_caller
11206            .entry(caller_file)
11207            .or_default()
11208            .insert(ref_id);
11209    }
11210}
11211
11212#[cfg(test)]
11213fn refs_by_caller_for_ref_ids(
11214    tx: &Transaction<'_>,
11215    ref_ids: &BTreeSet<String>,
11216) -> Result<BTreeMap<String, BTreeSet<String>>> {
11217    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11218    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
11219    for ref_id in ref_ids {
11220        if let Some(caller) = stmt
11221            .query_row(params![ref_id], |row| row.get::<_, String>(0))
11222            .optional()?
11223        {
11224            by_caller.entry(caller).or_default().insert(ref_id.clone());
11225        }
11226    }
11227    Ok(by_caller)
11228}
11229
11230fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11231    tx.execute(
11232        "DELETE FROM file_dependencies WHERE file_path = ?1",
11233        params![rel_path],
11234    )?;
11235    delete_refs_for_caller(tx, rel_path)?;
11236    tx.execute(
11237        "DELETE FROM dispatch_hints WHERE file = ?1",
11238        params![rel_path],
11239    )?;
11240    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
11241    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
11242    Ok(())
11243}
11244
11245fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
11246    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
11247    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
11248    let mut ids = BTreeSet::new();
11249    for row in rows {
11250        ids.insert(row?);
11251    }
11252    delete_ref_ids(tx, &ids)
11253}
11254
11255fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
11256    for ref_id in ref_ids {
11257        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
11258        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
11259    }
11260    Ok(())
11261}
11262
11263fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
11264    let mut stmt = conn.prepare(
11265        "SELECT source.file_path, source.scoped_name, edges.target_file,
11266                edges.target_symbol, edges.kind, edges.line
11267         FROM edges
11268         JOIN nodes AS source ON source.id = edges.source_node
11269         ORDER BY source.file_path, source.scoped_name, edges.target_file,
11270                  edges.target_symbol, edges.kind, edges.line",
11271    )?;
11272    let rows = stmt.query_map([], |row| {
11273        Ok(StoredEdge {
11274            source_file: row.get(0)?,
11275            source_symbol: row.get(1)?,
11276            target_file: row.get(2)?,
11277            target_symbol: row.get(3)?,
11278            kind: row.get(4)?,
11279            line: row.get::<_, i64>(5)? as u32,
11280        })
11281    })?;
11282    let mut edges = BTreeSet::new();
11283    for row in rows {
11284        edges.insert(row?);
11285    }
11286    Ok(edges)
11287}
11288
11289fn module_target_from_dependencies(
11290    project_root: &Path,
11291    dependencies: &BTreeSet<String>,
11292) -> Option<String> {
11293    dependencies.iter().find_map(|dep| {
11294        let path = project_root.join(dep);
11295        if path.is_file() {
11296            Some(relative_path(project_root, &canonicalize_path(&path)))
11297        } else {
11298            None
11299        }
11300    })
11301}
11302
11303fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
11304    let mut named = HashMap::new();
11305    if let Some(full_ref) = &raw_ref.full_ref {
11306        named = parse_reexport_names(full_ref);
11307    }
11308    ReexportIndex {
11309        target_file,
11310        named,
11311        wildcard: raw_ref.wildcard,
11312    }
11313}
11314
11315fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
11316    let mut names = HashMap::new();
11317    let Some(open) = statement.find('{') else {
11318        return names;
11319    };
11320    let Some(close) = statement[open + 1..]
11321        .find('}')
11322        .map(|offset| open + 1 + offset)
11323    else {
11324        return names;
11325    };
11326    for spec in statement[open + 1..close].split(',') {
11327        let spec = spec.trim();
11328        if spec.is_empty() {
11329            continue;
11330        }
11331        if let Some((source, local)) = spec.split_once(" as ") {
11332            names.insert(local.trim().to_string(), source.trim().to_string());
11333        } else {
11334            names.insert(spec.to_string(), spec.to_string());
11335        }
11336    }
11337    names
11338}
11339
11340#[derive(Debug)]
11341struct RefDependencyRow {
11342    ref_id: String,
11343    kind: String,
11344    caller_file: String,
11345    module_path: Option<String>,
11346    target_file: Option<String>,
11347}
11348
11349fn ref_dependency_row_depends_on(
11350    project_root: &Path,
11351    row: &RefDependencyRow,
11352    rel_path: &str,
11353) -> bool {
11354    if row.target_file.as_deref() == Some(rel_path) {
11355        return true;
11356    }
11357
11358    match row.kind.as_str() {
11359        "call" => true,
11360        "import" | "reexport" => row
11361            .module_path
11362            .as_deref()
11363            .map(|module_path| {
11364                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
11365                    .contains(rel_path)
11366            })
11367            .unwrap_or(false),
11368        "export_alias" => false,
11369        _ => false,
11370    }
11371}
11372
11373fn module_dependencies_for_ref(
11374    project_root: &Path,
11375    caller_file: &str,
11376    module_path: &str,
11377) -> BTreeSet<String> {
11378    module_dependencies(project_root, &project_root.join(caller_file), module_path)
11379}
11380
11381fn import_dependencies(
11382    project_root: &Path,
11383    abs_path: &Path,
11384    imports: &[ImportStatement],
11385) -> BTreeSet<String> {
11386    let mut deps = BTreeSet::new();
11387    for import in imports {
11388        deps.extend(module_dependencies(
11389            project_root,
11390            abs_path,
11391            &import.module_path,
11392        ));
11393    }
11394    deps
11395}
11396
11397fn module_dependencies(
11398    project_root: &Path,
11399    abs_path: &Path,
11400    module_path: &str,
11401) -> BTreeSet<String> {
11402    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
11403    let caller_dir = abs_path.parent().unwrap_or(project_root);
11404    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
11405        deps.insert(relative_path(project_root, &resolved));
11406    }
11407    if module_path.starts_with('.') {
11408        let base = caller_dir.join(module_path);
11409        for candidate in relative_module_candidates(&base) {
11410            deps.insert(relative_path(project_root, &candidate));
11411        }
11412    }
11413    deps
11414}
11415
11416fn rust_module_dependencies(
11417    project_root: &Path,
11418    abs_path: &Path,
11419    module_path: &str,
11420) -> BTreeSet<String> {
11421    let mut deps = BTreeSet::new();
11422    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
11423    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
11424        return deps;
11425    };
11426    let src_prefix = rust_src_prefix(&rel_path);
11427    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
11428    if !path_segments.is_empty() {
11429        rust_push_module_dependency_candidate(
11430            project_root,
11431            &mut deps,
11432            &src_prefix,
11433            &path_segments[..path_segments.len() - 1],
11434        );
11435    }
11436    deps
11437}
11438
11439fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
11440    let path = rust_module_path_without_alias_or_use_list(module_path);
11441    let segments = path
11442        .split("::")
11443        .map(str::trim)
11444        .filter(|segment| !segment.is_empty())
11445        .collect::<Vec<_>>();
11446    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
11447        return None;
11448    }
11449    rust_resolve_segments(rel_path, &segments)
11450}
11451
11452fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
11453    let path = module_path
11454        .trim()
11455        .trim_end_matches(';')
11456        .split_once(" as ")
11457        .map(|(left, _)| left.trim())
11458        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
11459    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
11460}
11461
11462fn rust_push_module_dependency_candidate(
11463    project_root: &Path,
11464    deps: &mut BTreeSet<String>,
11465    src_prefix: &str,
11466    segments: &[String],
11467) {
11468    let candidates = if segments.is_empty() {
11469        vec![
11470            format!("{src_prefix}/lib.rs"),
11471            format!("{src_prefix}/main.rs"),
11472        ]
11473    } else {
11474        vec![
11475            format!("{}/{}.rs", src_prefix, segments.join("/")),
11476            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
11477        ]
11478    };
11479    for candidate in candidates {
11480        if project_root.join(&candidate).is_file() {
11481            deps.insert(candidate);
11482        }
11483    }
11484}
11485
11486fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
11487    let mut candidates = Vec::new();
11488    if base.extension().is_some() {
11489        candidates.push(base.to_path_buf());
11490        return candidates;
11491    }
11492    for ext in JS_TS_EXTENSIONS {
11493        candidates.push(base.with_extension(ext));
11494    }
11495    for ext in JS_TS_EXTENSIONS {
11496        candidates.push(base.join(format!("index.{ext}")));
11497    }
11498    candidates
11499}
11500
11501fn import_local_names(import: &ImportStatement) -> Vec<String> {
11502    let mut names = Vec::new();
11503    if let Some(default) = &import.default_import {
11504        names.push(default.clone());
11505    }
11506    if let Some(namespace) = &import.namespace_import {
11507        names.push(namespace.clone());
11508    }
11509    for name in &import.names {
11510        names.push(crate::imports::specifier_local_name(name).to_string());
11511    }
11512    names
11513}
11514
11515fn import_requested_names(import: &ImportStatement) -> Vec<String> {
11516    import
11517        .names
11518        .iter()
11519        .map(|name| crate::imports::specifier_imported_name(name).to_string())
11520        .collect()
11521}
11522
11523fn import_is_wildcard(import: &ImportStatement) -> bool {
11524    import.namespace_import.is_some() || import.raw_text.contains('*')
11525}
11526
11527fn namespace_alias(full_ref: &str) -> Option<String> {
11528    full_ref
11529        .split_once('.')
11530        .map(|(namespace, _)| namespace.to_string())
11531}
11532
11533fn import_kind_label(kind: ImportKind) -> &'static str {
11534    match kind {
11535        ImportKind::Value => "value",
11536        ImportKind::Type => "type",
11537        ImportKind::SideEffect => "side_effect",
11538    }
11539}
11540
11541fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
11542    match kind {
11543        SymbolKind::Function => "function",
11544        SymbolKind::Class => "class",
11545        SymbolKind::Method => "method",
11546        SymbolKind::Struct => "struct",
11547        SymbolKind::Interface => "interface",
11548        SymbolKind::Enum => "enum",
11549        SymbolKind::TypeAlias => "type_alias",
11550        SymbolKind::Variable => "variable",
11551        SymbolKind::Heading => "heading",
11552        SymbolKind::FileSummary => "file_summary",
11553    }
11554}
11555
11556fn is_type_like(kind: &SymbolKind) -> bool {
11557    matches!(
11558        kind,
11559        SymbolKind::Class
11560            | SymbolKind::Struct
11561            | SymbolKind::Interface
11562            | SymbolKind::Enum
11563            | SymbolKind::TypeAlias
11564    )
11565}
11566
11567fn lang_label(lang: LangId) -> &'static str {
11568    match lang {
11569        LangId::TypeScript => "typescript",
11570        LangId::Tsx => "tsx",
11571        LangId::JavaScript => "javascript",
11572        LangId::Python => "python",
11573        LangId::Rust => "rust",
11574        LangId::Go => "go",
11575        LangId::C => "c",
11576        LangId::Cpp => "cpp",
11577        LangId::Zig => "zig",
11578        LangId::CSharp => "csharp",
11579        LangId::Bash => "bash",
11580        LangId::Html => "html",
11581        LangId::Markdown => "markdown",
11582        LangId::Solidity => "solidity",
11583        LangId::Scss => "scss",
11584        LangId::Vue => "vue",
11585        LangId::Json => "json",
11586        LangId::Scala => "scala",
11587        LangId::Java => "java",
11588        LangId::Ruby => "ruby",
11589        LangId::Kotlin => "kotlin",
11590        LangId::Swift => "swift",
11591        LangId::Php => "php",
11592        LangId::Lua => "lua",
11593        LangId::Perl => "perl",
11594        LangId::Yaml => "yaml",
11595        LangId::Pascal => "pascal",
11596        LangId::R => "r",
11597        LangId::Groovy => "groovy",
11598        LangId::ObjC => "objc",
11599    }
11600}
11601
11602fn lang_from_label(label: &str) -> Option<LangId> {
11603    match label {
11604        "typescript" => Some(LangId::TypeScript),
11605        "tsx" => Some(LangId::Tsx),
11606        "javascript" => Some(LangId::JavaScript),
11607        "python" => Some(LangId::Python),
11608        "rust" => Some(LangId::Rust),
11609        "go" => Some(LangId::Go),
11610        "c" => Some(LangId::C),
11611        "cpp" => Some(LangId::Cpp),
11612        "zig" => Some(LangId::Zig),
11613        "csharp" => Some(LangId::CSharp),
11614        "bash" => Some(LangId::Bash),
11615        "html" => Some(LangId::Html),
11616        "markdown" => Some(LangId::Markdown),
11617        "solidity" => Some(LangId::Solidity),
11618        "scss" => Some(LangId::Scss),
11619        "vue" => Some(LangId::Vue),
11620        "json" => Some(LangId::Json),
11621        "scala" => Some(LangId::Scala),
11622        "java" => Some(LangId::Java),
11623        "ruby" => Some(LangId::Ruby),
11624        "kotlin" => Some(LangId::Kotlin),
11625        "swift" => Some(LangId::Swift),
11626        "php" => Some(LangId::Php),
11627        "lua" => Some(LangId::Lua),
11628        "perl" => Some(LangId::Perl),
11629        "yaml" => Some(LangId::Yaml),
11630        "pascal" => Some(LangId::Pascal),
11631        "r" => Some(LangId::R),
11632        "groovy" => Some(LangId::Groovy),
11633        "objc" => Some(LangId::ObjC),
11634        _ => None,
11635    }
11636}
11637
11638fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
11639    let mut normalized = if files.is_empty() {
11640        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
11641    } else {
11642        files
11643            .iter()
11644            .map(|path| normalize_file_path(project_root, path))
11645            .collect::<Result<Vec<_>>>()?
11646    };
11647    normalized.sort();
11648    normalized.dedup();
11649    Ok(normalized)
11650}
11651
11652fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
11653    let full_path = if path.is_relative() {
11654        project_root.join(path)
11655    } else {
11656        path.to_path_buf()
11657    };
11658    Ok(canonicalize_path(&full_path))
11659}
11660
11661fn canonicalize_path(path: &Path) -> PathBuf {
11662    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
11663}
11664
11665fn relative_path(project_root: &Path, path: &Path) -> String {
11666    if let Ok(stripped) = path.strip_prefix(project_root) {
11667        return stripped.to_string_lossy().replace('\\', "/");
11668    }
11669    let canon_root = canonicalize_path(project_root);
11670    let canon_path = canonicalize_path(path);
11671    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
11672        return stripped.to_string_lossy().replace('\\', "/");
11673    }
11674    canon_path.to_string_lossy().replace('\\', "/")
11675}
11676
11677fn unqualified_name(scoped: &str) -> &str {
11678    if scoped == TOP_LEVEL_SYMBOL {
11679        return scoped;
11680    }
11681    scoped
11682        .rsplit("::")
11683        .next()
11684        .unwrap_or(scoped)
11685        .rsplit('.')
11686        .next()
11687        .unwrap_or(scoped)
11688        .rsplit('#')
11689        .next()
11690        .unwrap_or(scoped)
11691}
11692
11693fn ref_id(parts: &[&str]) -> String {
11694    let joined = parts.join("\0");
11695    hash_to_hex(blake3::hash(joined.as_bytes()))
11696}
11697
11698fn hash_to_hex(hash: blake3::Hash) -> String {
11699    hash.to_hex().to_string()
11700}
11701
11702fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
11703    let bytes = hex_to_bytes(value)?;
11704    Some(blake3::Hash::from_bytes(bytes))
11705}
11706
11707fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
11708    if value.len() != 64 {
11709        return None;
11710    }
11711    let mut bytes = [0u8; 32];
11712    for (index, slot) in bytes.iter_mut().enumerate() {
11713        let start = index * 2;
11714        let end = start + 2;
11715        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
11716    }
11717    Some(bytes)
11718}
11719
11720#[derive(Debug, Clone)]
11721struct LineIndex {
11722    newline_offsets: Vec<usize>,
11723    source_len: usize,
11724}
11725
11726impl LineIndex {
11727    fn new(source: &str) -> Self {
11728        Self {
11729            newline_offsets: source
11730                .bytes()
11731                .enumerate()
11732                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
11733                .collect(),
11734            source_len: source.len(),
11735        }
11736    }
11737
11738    fn byte_to_line(&self, byte_offset: usize) -> u32 {
11739        let byte_offset = byte_offset.min(self.source_len);
11740        self.newline_offsets
11741            .partition_point(|offset| *offset < byte_offset) as u32
11742            + 1
11743    }
11744}
11745
11746fn empty_to_none(value: String) -> Option<String> {
11747    if value.is_empty() {
11748        None
11749    } else {
11750        Some(value)
11751    }
11752}
11753
11754fn bool_int(value: bool) -> i64 {
11755    if value {
11756        1
11757    } else {
11758        0
11759    }
11760}
11761
11762fn system_time_to_ns(time: SystemTime) -> i64 {
11763    time.duration_since(UNIX_EPOCH)
11764        .unwrap_or_default()
11765        .as_nanos()
11766        .min(i64::MAX as u128) as i64
11767}
11768
11769fn ns_to_system_time(value: i64) -> SystemTime {
11770    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
11771}
11772
11773fn unix_millis_now() -> u64 {
11774    SystemTime::now()
11775        .duration_since(UNIX_EPOCH)
11776        .unwrap_or_default()
11777        .as_millis()
11778        .min(u128::from(u64::MAX)) as u64
11779}
11780
11781fn unix_seconds_now() -> i64 {
11782    SystemTime::now()
11783        .duration_since(UNIX_EPOCH)
11784        .unwrap_or_default()
11785        .as_secs() as i64
11786}
11787
11788/// Serializes every test that drives the process-wide refresh worker
11789/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
11790/// worker down between another test's enqueue and its flush, deferring the
11791/// batch and zeroing that test's seam counts).
11792#[cfg(test)]
11793pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11794
11795#[cfg(test)]
11796mod refresh_worker_tests {
11797    use super::*;
11798    use std::fs;
11799    use tempfile::tempdir;
11800
11801    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11802        let temp = tempdir().unwrap();
11803        let root = temp.path().join("root");
11804        fs::create_dir_all(&root).unwrap();
11805        let artifact_key = crate::search_index::artifact_cache_key(&root);
11806        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11807        let callgraph_dir = temp
11808            .path()
11809            .join("storage")
11810            .join("callgraph")
11811            .join(artifact_key);
11812        let source = root.join("main.rs");
11813        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
11814        let (store, _) = CallGraphStore::cold_build_with_lease(
11815            callgraph_dir.clone(),
11816            root.clone(),
11817            std::slice::from_ref(&source),
11818        )
11819        .unwrap();
11820        drop(store);
11821        (temp, root, callgraph_dir, source)
11822    }
11823
11824    fn pending_paths() -> PendingCallGraphStorePaths {
11825        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
11826    }
11827
11828    fn wait_for_refresh_calls(root: &Path, expected: usize) {
11829        let deadline = Instant::now() + Duration::from_secs(12);
11830        while callgraph_refresh_worker_test_counts(root).0 < expected {
11831            assert!(
11832                Instant::now() < deadline,
11833                "timed out waiting for {expected} callgraph refresh worker call(s)"
11834            );
11835            std::thread::sleep(Duration::from_millis(5));
11836        }
11837    }
11838
11839    fn wait_for_refresh_worker_idle() {
11840        let deadline = Instant::now() + Duration::from_secs(12);
11841        loop {
11842            let worker = CALLGRAPH_REFRESH_WORKER
11843                .get_or_init(|| Mutex::new(None))
11844                .lock()
11845                .expect("callgraph refresh worker mutex poisoned")
11846                .clone();
11847            let idle = worker.is_none_or(|worker| {
11848                let queue = worker
11849                    .shared
11850                    .queue
11851                    .lock()
11852                    .expect("callgraph refresh queue mutex poisoned");
11853                queue.active.is_none() && queue.order.is_empty()
11854            });
11855            if idle {
11856                return;
11857            }
11858            assert!(
11859                Instant::now() < deadline,
11860                "timed out waiting for callgraph refresh worker to become idle"
11861            );
11862            std::thread::sleep(Duration::from_millis(5));
11863        }
11864    }
11865
11866    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
11867        let temp = tempdir().unwrap();
11868        let root = temp.path().join("workspace");
11869        fs::create_dir_all(root.join("app/src")).unwrap();
11870        let artifact_key = crate::search_index::artifact_cache_key(&root);
11871        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
11872        let callgraph_dir = temp
11873            .path()
11874            .join("storage")
11875            .join("callgraph")
11876            .join(artifact_key);
11877        fs::write(
11878            root.join("Cargo.toml"),
11879            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
11880        )
11881        .unwrap();
11882        fs::write(
11883            root.join("app/Cargo.toml"),
11884            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11885        )
11886        .unwrap();
11887        let caller = root.join("app/src/lib.rs");
11888        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
11889        let (store, _) = CallGraphStore::cold_build_with_lease(
11890            callgraph_dir.clone(),
11891            root.clone(),
11892            std::slice::from_ref(&caller),
11893        )
11894        .unwrap();
11895        drop(store);
11896        (temp, root, callgraph_dir, caller)
11897    }
11898
11899    #[test]
11900    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
11901        let _guard = REFRESH_WORKER_TEST_LOCK
11902            .lock()
11903            .unwrap_or_else(std::sync::PoisonError::into_inner);
11904        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11905        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11906        reset_workspace_crate_prefix_build_count(&root);
11907        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11908
11909        for revision in ["first", "second"] {
11910            fs::write(
11911                &caller,
11912                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
11913            )
11914            .unwrap();
11915            enqueue_callgraph_store_refresh(
11916                callgraph_dir.clone(),
11917                root.clone(),
11918                vec![caller.clone()],
11919                pending_paths(),
11920            );
11921            wait_for_refresh_worker_idle();
11922        }
11923
11924        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11925        assert!(flush_callgraph_store_refreshes_with_budget(
11926            Duration::from_secs(5)
11927        ));
11928        clear_callgraph_refresh_worker_test_seam(&root);
11929    }
11930
11931    #[test]
11932    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
11933        let _guard = REFRESH_WORKER_TEST_LOCK
11934            .lock()
11935            .unwrap_or_else(std::sync::PoisonError::into_inner);
11936        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11937        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
11938        reset_workspace_crate_prefix_build_count(&root);
11939        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
11940
11941        fs::write(
11942            &caller,
11943            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
11944        )
11945        .unwrap();
11946        enqueue_callgraph_store_refresh(
11947            callgraph_dir.clone(),
11948            root.clone(),
11949            vec![caller.clone()],
11950            pending_paths(),
11951        );
11952        wait_for_refresh_worker_idle();
11953        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
11954
11955        let added_manifest = root.join("added/Cargo.toml");
11956        let added_source = root.join("added/src/lib.rs");
11957        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
11958        fs::write(
11959            root.join("Cargo.toml"),
11960            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
11961        )
11962        .unwrap();
11963        fs::write(
11964            &added_manifest,
11965            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
11966        )
11967        .unwrap();
11968        fs::write(&added_source, "pub fn target() {}\n").unwrap();
11969        fs::write(
11970            &caller,
11971            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
11972        )
11973        .unwrap();
11974
11975        enqueue_callgraph_store_refresh(
11976            callgraph_dir.clone(),
11977            root.clone(),
11978            vec![
11979                root.join("Cargo.toml"),
11980                added_manifest,
11981                added_source,
11982                caller,
11983            ],
11984            pending_paths(),
11985        );
11986        assert!(flush_callgraph_store_refreshes_with_budget(
11987            Duration::from_secs(12)
11988        ));
11989
11990        // This is the negative control for a permanently-static cache: without
11991        // manifest invalidation the build count stays at one and the call remains
11992        // unresolved because `added_crate` was absent when the map was primed.
11993        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
11994        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
11995            .unwrap()
11996            .expect("refreshed workspace store");
11997        let tree = store
11998            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
11999            .unwrap();
12000        assert_eq!(tree.children.len(), 1);
12001        assert_eq!(tree.children[0].file, "added/src/lib.rs");
12002        assert_eq!(tree.children[0].name, "target");
12003        assert!(tree.children[0].resolved);
12004        clear_callgraph_refresh_worker_test_seam(&root);
12005    }
12006
12007    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
12008        let temp = tempdir().unwrap();
12009        let main = temp.path().join("main");
12010        let worktree = temp.path().join("worktree");
12011        fs::create_dir_all(&main).unwrap();
12012        let mut git = std::process::Command::new("git");
12013        assert!(
12014            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
12015                .status()
12016                .unwrap()
12017                .success()
12018        );
12019        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
12020        for args in [
12021            vec![
12022                "-C",
12023                main.to_str().unwrap(),
12024                "config",
12025                "user.email",
12026                "test@example.com",
12027            ],
12028            vec![
12029                "-C",
12030                main.to_str().unwrap(),
12031                "config",
12032                "user.name",
12033                "AFT Test",
12034            ],
12035            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
12036            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
12037        ] {
12038            let mut command = std::process::Command::new("git");
12039            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
12040                .status()
12041                .unwrap()
12042                .success());
12043        }
12044        let mut add_worktree = std::process::Command::new("git");
12045        assert!(crate::test_env::apply_hermetic_git_env(
12046            add_worktree
12047                .arg("-C")
12048                .arg(&main)
12049                .args(["worktree", "add", "--detach"])
12050                .arg(&worktree),
12051        )
12052        .status()
12053        .unwrap()
12054        .success());
12055        let main = fs::canonicalize(main).unwrap();
12056        let worktree = fs::canonicalize(worktree).unwrap();
12057        let project_key = crate::search_index::artifact_cache_key(&main);
12058        assert_eq!(
12059            crate::search_index::artifact_cache_key(&worktree),
12060            project_key
12061        );
12062        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12063        (temp, main, worktree, project_key, callgraph_dir)
12064    }
12065
12066    #[test]
12067    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
12068        let _git_env = crate::test_env::hermetic_git_env_guard();
12069        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
12070        crate::root_cache::configure_artifact_access(&root, &project_key, true);
12071        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12072        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12073        let publications_for_observer = Arc::clone(&publications);
12074        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12075            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12076        })));
12077        let source = root.join("lib.rs");
12078
12079        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
12080            .expect_err("borrow-only writable open must remain unavailable");
12081        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
12082        assert!(
12083            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
12084                .unwrap()
12085                .is_none()
12086        );
12087        assert!(
12088            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
12089                .unwrap()
12090                .is_none()
12091        );
12092        assert!(matches!(
12093            CallGraphStore::cold_build_with_lease(
12094                callgraph_dir.clone(),
12095                root.clone(),
12096                std::slice::from_ref(&source),
12097            ),
12098            Err(CallGraphStoreError::Unavailable(_))
12099        ));
12100        assert!(matches!(
12101            CallGraphStore::ensure_built_with_lease(
12102                callgraph_dir.clone(),
12103                root.clone(),
12104                std::slice::from_ref(&source),
12105            ),
12106            Err(CallGraphStoreError::Unavailable(_))
12107        ));
12108        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
12109            callgraph_dir.clone(),
12110            root.clone(),
12111            &[source],
12112            1,
12113        )
12114        .expect_err("borrow-only forced rebuild must remain unsatisfied");
12115        set_cold_build_swap_observer(None);
12116
12117        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
12118        assert_eq!(
12119            crate::root_cache::writer_lease_acquisition_count_for_test(
12120                crate::root_cache::RootCacheDomain::Callgraph,
12121                &project_key,
12122                &root,
12123            ),
12124            0
12125        );
12126        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
12127        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
12128    }
12129
12130    #[test]
12131    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
12132        let _git_env = crate::test_env::hermetic_git_env_guard();
12133        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
12134        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
12135        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
12136        let source = owner.join("lib.rs");
12137        let (store, _) = CallGraphStore::cold_build_with_lease(
12138            callgraph_dir.clone(),
12139            owner.clone(),
12140            std::slice::from_ref(&source),
12141        )
12142        .unwrap();
12143        let sqlite_path = store.sqlite_path().to_path_buf();
12144        drop(store);
12145
12146        let conn = Connection::open(&sqlite_path).unwrap();
12147        conn.execute(
12148            "UPDATE backend_file_state SET workspace_root = ?1",
12149            [worktree.display().to_string()],
12150        )
12151        .unwrap();
12152        drop(conn);
12153
12154        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12155        let publications_for_observer = Arc::clone(&publications);
12156        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
12157            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
12158        })));
12159        crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
12160
12161        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12162            .unwrap()
12163            .expect("owner should purge the storm-era worktree root");
12164        drop(repaired);
12165        for _ in 0..3 {
12166            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
12167                .unwrap()
12168                .expect("linked worktree should borrow the owner generation");
12169            drop(borrower);
12170            assert!(
12171                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
12172                    .unwrap()
12173                    .is_none()
12174            );
12175            let owner_store =
12176                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
12177                    .unwrap()
12178                    .expect("owner generation should remain ready");
12179            drop(owner_store);
12180        }
12181        set_cold_build_swap_observer(None);
12182
12183        assert_eq!(
12184            publications.load(AtomicOrdering::SeqCst),
12185            1,
12186            "the owner performs one expected post-storm purge and alternation stays read-only"
12187        );
12188        assert_eq!(
12189            crate::root_cache::writer_lease_acquisition_count_for_test(
12190                crate::root_cache::RootCacheDomain::Callgraph,
12191                &project_key,
12192                &worktree,
12193            ),
12194            0
12195        );
12196    }
12197
12198    #[test]
12199    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
12200        let temp = tempdir().unwrap();
12201        let root = temp.path().join("owner");
12202        let other_root = temp.path().join("other");
12203        fs::create_dir_all(&root).unwrap();
12204        fs::create_dir_all(&other_root).unwrap();
12205        let source = root.join("lib.rs");
12206        fs::write(&source, "pub fn marker() {}\n").unwrap();
12207        let project_key = crate::search_index::artifact_cache_key(&root);
12208        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
12209        crate::root_cache::configure_artifact_access(&root, &project_key, false);
12210        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
12211        rebuild_cooldown_records()
12212            .lock()
12213            .unwrap_or_else(std::sync::PoisonError::into_inner)
12214            .remove(&cooldown_key);
12215        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
12216        let stale_epoch = epoch.current();
12217        epoch.next();
12218
12219        let failed = with_publish_epoch(epoch, stale_epoch, || {
12220            CallGraphStore::cold_build_with_lease(
12221                callgraph_dir.clone(),
12222                root.clone(),
12223                std::slice::from_ref(&source),
12224            )
12225        });
12226        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
12227        assert!(
12228            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12229                .is_none()
12230        );
12231
12232        let (store, _) = CallGraphStore::cold_build_with_lease(
12233            callgraph_dir.clone(),
12234            root.clone(),
12235            std::slice::from_ref(&source),
12236        )
12237        .unwrap();
12238        drop(store);
12239        assert!(
12240            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
12241                .is_none()
12242        );
12243
12244        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
12245        assert!(
12246            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
12247        );
12248    }
12249
12250    #[test]
12251    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
12252        let _guard = REFRESH_WORKER_TEST_LOCK
12253            .lock()
12254            .unwrap_or_else(std::sync::PoisonError::into_inner);
12255        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12256        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12257        let pending = pending_paths();
12258        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12259
12260        let lifecycle = SubcLifecycleAdmission::default();
12261        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12262        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12263        let ticket = CallgraphRefreshTicket::new(
12264            lifecycle,
12265            Arc::clone(&generation),
12266            7,
12267            publish_epoch.clone(),
12268            publish_epoch.current(),
12269        );
12270        // Supersede before the worker runs: the batch must defer, not commit.
12271        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12272        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12273            .unwrap()
12274            .expect("ready store snapshot");
12275        let refresh_state = CallgraphRefreshState::new(
12276            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
12277            Arc::new(AtomicBool::new(true)),
12278        );
12279
12280        enqueue_callgraph_store_refresh_fenced_with_state(
12281            callgraph_dir,
12282            root.clone(),
12283            vec![source.clone()],
12284            Arc::clone(&pending),
12285            refresh_state,
12286            ticket,
12287        );
12288        assert!(flush_callgraph_store_refreshes_with_budget(
12289            Duration::from_secs(5)
12290        ));
12291        assert_eq!(
12292            callgraph_refresh_worker_test_counts(&root).0,
12293            0,
12294            "superseded batch must not reach refresh_files or self-replay"
12295        );
12296        assert!(
12297            pending.lock().contains(&source),
12298            "superseded batch must defer its paths to the pending sink"
12299        );
12300        clear_callgraph_refresh_worker_test_seam(&root);
12301    }
12302
12303    #[test]
12304    fn superseded_open_failure_defers_without_self_replay() {
12305        let _guard = REFRESH_WORKER_TEST_LOCK
12306            .lock()
12307            .unwrap_or_else(std::sync::PoisonError::into_inner);
12308        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12309        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12310        let pending = pending_paths();
12311        let installed = Arc::new(
12312            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
12313                .unwrap()
12314                .expect("ready store snapshot"),
12315        );
12316        let refresh_state = CallgraphRefreshState::new(
12317            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
12318            Arc::new(AtomicBool::new(true)),
12319        );
12320        assert!(!installed.is_legacy_fallback());
12321        assert!(installed.is_current());
12322        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12323        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12324        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
12325        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
12326
12327        let lifecycle = SubcLifecycleAdmission::default();
12328        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
12329        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12330        let ticket = CallgraphRefreshTicket::new(
12331            lifecycle,
12332            Arc::clone(&generation),
12333            7,
12334            publish_epoch.clone(),
12335            publish_epoch.current(),
12336        );
12337        enqueue_callgraph_store_refresh_fenced_with_state(
12338            callgraph_dir,
12339            root.clone(),
12340            vec![source.clone()],
12341            Arc::clone(&pending),
12342            refresh_state,
12343            ticket,
12344        );
12345        held_rx
12346            .recv_timeout(Duration::from_secs(12))
12347            .expect("refresh worker must hold after injected open failure");
12348
12349        // Mark the refresh request obsolete after the injected open failure,
12350        // then unblock the worker before its deferred retry can run.
12351        generation.store(8, std::sync::atomic::Ordering::SeqCst);
12352        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
12353        release_tx
12354            .send(())
12355            .expect("release superseded refresh worker");
12356        wait_for_refresh_worker_idle();
12357
12358        assert_eq!(
12359            callgraph_refresh_worker_test_counts(&root).0,
12360            1,
12361            "superseded open-failure batch must not self-replay"
12362        );
12363        assert_eq!(
12364            callgraph_refresh_worker_test_worker_calls(&root),
12365            1,
12366            "superseded open-failure batch must not create another worker call"
12367        );
12368        assert!(
12369            pending.lock().contains(&source),
12370            "superseded open-failure paths must remain in the pending sink"
12371        );
12372        let tree = installed
12373            .call_tree(Path::new("main.rs"), "entry", 1)
12374            .unwrap();
12375        assert_eq!(
12376            tree.children[0].name, "old_leaf",
12377            "superseded open-failure batch must not converge the store"
12378        );
12379        clear_callgraph_refresh_worker_test_seam(&root);
12380    }
12381
12382    #[test]
12383    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
12384        let _guard = REFRESH_WORKER_TEST_LOCK
12385            .lock()
12386            .unwrap_or_else(std::sync::PoisonError::into_inner);
12387        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12388        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12389        let pending = pending_paths();
12390        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12391
12392        let lifecycle = SubcLifecycleAdmission::default();
12393        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
12394        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12395        let expected_epoch = publish_epoch.current();
12396        let ticket = CallgraphRefreshTicket::new(
12397            lifecycle,
12398            generation,
12399            3,
12400            publish_epoch.clone(),
12401            expected_epoch,
12402        );
12403        // A cold build published a replacement generation after enqueue.
12404        publish_epoch.next();
12405
12406        enqueue_callgraph_store_refresh_fenced(
12407            callgraph_dir,
12408            root.clone(),
12409            vec![source.clone()],
12410            Arc::clone(&pending),
12411            ticket,
12412        );
12413        assert!(flush_callgraph_store_refreshes_with_budget(
12414            Duration::from_secs(5)
12415        ));
12416        assert_eq!(
12417            callgraph_refresh_worker_test_counts(&root).0,
12418            0,
12419            "epoch-superseded batch must not reach refresh_files"
12420        );
12421        assert!(
12422            pending.lock().contains(&source),
12423            "epoch-superseded batch must defer its paths to the pending sink"
12424        );
12425        clear_callgraph_refresh_worker_test_seam(&root);
12426    }
12427
12428    #[test]
12429    fn fenced_refresh_with_current_ticket_commits_normally() {
12430        let _guard = REFRESH_WORKER_TEST_LOCK
12431            .lock()
12432            .unwrap_or_else(std::sync::PoisonError::into_inner);
12433        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12434        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12435        let pending = pending_paths();
12436        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12437
12438        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
12439
12440        let lifecycle = SubcLifecycleAdmission::default();
12441        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
12442        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
12443        let ticket = CallgraphRefreshTicket::new(
12444            lifecycle,
12445            generation,
12446            5,
12447            publish_epoch.clone(),
12448            publish_epoch.current(),
12449        );
12450
12451        enqueue_callgraph_store_refresh_fenced(
12452            callgraph_dir.clone(),
12453            root.clone(),
12454            vec![source.clone()],
12455            Arc::clone(&pending),
12456            ticket,
12457        );
12458        assert!(flush_callgraph_store_refreshes_with_budget(
12459            Duration::from_secs(5)
12460        ));
12461        assert_eq!(
12462            callgraph_refresh_worker_test_counts(&root).0,
12463            1,
12464            "current ticket must run the refresh"
12465        );
12466        assert!(
12467            pending.lock().is_empty(),
12468            "committed batch must not defer paths"
12469        );
12470
12471        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
12472            .unwrap()
12473            .expect("published generation must remain readable");
12474        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12475        assert_eq!(
12476            tree.children[0].name, "new_leaf",
12477            "fenced commit must actually persist the refreshed content"
12478        );
12479        clear_callgraph_refresh_worker_test_seam(&root);
12480    }
12481
12482    #[test]
12483    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
12484        let _guard = REFRESH_WORKER_TEST_LOCK
12485            .lock()
12486            .unwrap_or_else(std::sync::PoisonError::into_inner);
12487        // Generous pre-drain: the refresh worker is process-wide, so a prior
12488        // test's still-running batch (slow Windows CI) must fully settle
12489        // before this test enqueues, or its wait deadline absorbs the
12490        // leftover work. Idle workers return immediately.
12491        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12492        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12493        let pending = pending_paths();
12494        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
12495
12496        enqueue_callgraph_store_refresh(
12497            callgraph_dir.clone(),
12498            root.clone(),
12499            vec![source.clone()],
12500            Arc::clone(&pending),
12501        );
12502        wait_for_refresh_calls(&root, 1);
12503        for _ in 0..3 {
12504            enqueue_callgraph_store_refresh(
12505                callgraph_dir.clone(),
12506                root.clone(),
12507                vec![source.clone()],
12508                Arc::clone(&pending),
12509            );
12510        }
12511
12512        assert!(flush_callgraph_store_refreshes_with_budget(
12513            Duration::from_secs(2)
12514        ));
12515        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
12516        assert!(pending.lock().is_empty());
12517        clear_callgraph_refresh_worker_test_seam(&root);
12518    }
12519
12520    #[test]
12521    fn queued_refresh_opens_generation_published_after_enqueue() {
12522        let _guard = REFRESH_WORKER_TEST_LOCK
12523            .lock()
12524            .unwrap_or_else(std::sync::PoisonError::into_inner);
12525        // Generous pre-drain: the refresh worker is process-wide, so a prior
12526        // test's still-running batch (slow Windows CI) must fully settle
12527        // before this test enqueues, or its wait deadline absorbs the
12528        // leftover work. Idle workers return immediately.
12529        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12530        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12531        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
12532        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
12533        let (active_held_rx, active_release_tx) =
12534            install_callgraph_refresh_worker_test_gate(active_root.clone());
12535        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
12536        enqueue_callgraph_store_refresh(
12537            active_dir,
12538            active_root.clone(),
12539            vec![active_source],
12540            pending_paths(),
12541        );
12542        active_held_rx
12543            .recv_timeout(Duration::from_secs(12))
12544            .expect("active refresh worker holds the queue");
12545
12546        fs::write(
12547            &target_source,
12548            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12549        )
12550        .unwrap();
12551        enqueue_callgraph_store_refresh(
12552            target_dir.clone(),
12553            target_root.clone(),
12554            vec![target_source.clone()],
12555            pending_paths(),
12556        );
12557        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
12558            target_dir.clone(),
12559            target_root.clone(),
12560            std::slice::from_ref(&target_source),
12561        )
12562        .unwrap();
12563        fs::write(
12564            &target_source,
12565            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
12566        )
12567        .unwrap();
12568        drop(new_generation);
12569
12570        active_release_tx
12571            .send(())
12572            .expect("release active refresh worker");
12573        wait_for_refresh_calls(&target_root, 1);
12574        assert!(flush_callgraph_store_refreshes_with_budget(
12575            Duration::from_secs(12)
12576        ));
12577        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
12578            .unwrap()
12579            .expect("current callgraph generation");
12580        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
12581        assert_eq!(tree.children[0].name, "worker_leaf");
12582        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
12583        clear_callgraph_refresh_worker_test_seam(&active_root);
12584        clear_callgraph_refresh_worker_test_seam(&target_root);
12585    }
12586
12587    #[test]
12588    fn refresh_failure_marks_files_stale() {
12589        let _guard = REFRESH_WORKER_TEST_LOCK
12590            .lock()
12591            .unwrap_or_else(std::sync::PoisonError::into_inner);
12592        // Generous pre-drain: the refresh worker is process-wide, so a prior
12593        // test's still-running batch (slow Windows CI) must fully settle
12594        // before this test enqueues, or its wait deadline absorbs the
12595        // leftover work. Idle workers return immediately.
12596        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12597        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12598        let pending = pending_paths();
12599        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
12600
12601        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
12602        assert!(flush_callgraph_store_refreshes_with_budget(
12603            Duration::from_secs(2)
12604        ));
12605
12606        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
12607        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
12608            .unwrap()
12609            .expect("ready callgraph store");
12610        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
12611        clear_callgraph_refresh_worker_test_seam(&root);
12612    }
12613
12614    #[test]
12615    fn idle_refresh_truncates_wal() {
12616        let _guard = REFRESH_WORKER_TEST_LOCK
12617            .lock()
12618            .unwrap_or_else(std::sync::PoisonError::into_inner);
12619        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12620        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
12621        let generation = read_pointer(
12622            &callgraph_dir,
12623            &crate::search_index::artifact_cache_key(&root),
12624        )
12625        .expect("fixture publishes a generation");
12626        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
12627        let pending = pending_paths();
12628        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
12629
12630        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
12631        enqueue_callgraph_store_refresh(
12632            callgraph_dir.clone(),
12633            root.clone(),
12634            vec![source.clone()],
12635            Arc::clone(&pending),
12636        );
12637        wait_for_refresh_calls(&root, 1);
12638        wait_for_refresh_worker_idle();
12639        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
12640        while fs::metadata(&wal_path)
12641            .map(|metadata| metadata.len())
12642            .unwrap_or(0)
12643            != 0
12644        {
12645            assert!(
12646                Instant::now() < checkpoint_deadline,
12647                "idle checkpoint did not truncate WAL"
12648            );
12649            std::thread::sleep(Duration::from_millis(5));
12650        }
12651        assert_eq!(
12652            fs::metadata(&wal_path)
12653                .map(|metadata| metadata.len())
12654                .unwrap_or(0),
12655            0,
12656            "idle transition truncates the refresh WAL"
12657        );
12658
12659        clear_callgraph_refresh_worker_test_seam(&root);
12660    }
12661
12662    #[test]
12663    fn bounded_shutdown_defers_unprocessed_batches() {
12664        let _guard = REFRESH_WORKER_TEST_LOCK
12665            .lock()
12666            .unwrap_or_else(std::sync::PoisonError::into_inner);
12667        // Generous pre-drain: the refresh worker is process-wide, so a prior
12668        // test's still-running batch (slow Windows CI) must fully settle
12669        // before this test enqueues, or its wait deadline absorbs the
12670        // leftover work. Idle workers return immediately.
12671        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
12672        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
12673        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
12674        let active_pending = pending_paths();
12675        let queued_pending = pending_paths();
12676        set_callgraph_refresh_worker_test_seam(
12677            active_root.clone(),
12678            Duration::from_millis(300),
12679            false,
12680        );
12681
12682        enqueue_callgraph_store_refresh(
12683            active_dir,
12684            active_root.clone(),
12685            vec![active_source.clone()],
12686            Arc::clone(&active_pending),
12687        );
12688        wait_for_refresh_calls(&active_root, 1);
12689        enqueue_callgraph_store_refresh(
12690            queued_dir,
12691            queued_root.clone(),
12692            vec![queued_source.clone()],
12693            Arc::clone(&queued_pending),
12694        );
12695
12696        assert!(!flush_callgraph_store_refreshes_with_budget(
12697            Duration::from_millis(20)
12698        ));
12699        assert!(active_pending.lock().contains(&active_source));
12700        assert!(queued_pending.lock().contains(&queued_source));
12701        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
12702        clear_callgraph_refresh_worker_test_seam(&active_root);
12703    }
12704}
12705
12706#[cfg(test)]
12707mod cold_build_insert_tests {
12708    use super::*;
12709    use crate::imports::ImportBlock;
12710    use std::cell::Cell;
12711    use std::fs;
12712    use std::path::{Path, PathBuf};
12713    use tempfile::tempdir;
12714
12715    thread_local! {
12716        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
12717        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
12718        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
12719    }
12720
12721    fn count_caller_traversal_selects(sql: &str) {
12722        let sql = sql.trim_start();
12723        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
12724            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
12725        }
12726        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
12727            && sql.contains("e.target_file =")
12728        {
12729            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
12730        }
12731        if sql.starts_with("WITH requested") {
12732            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
12733        }
12734    }
12735
12736    #[test]
12737    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
12738        let dir = tempdir().unwrap();
12739        let previous_root = dir.path().join("previous-root");
12740        let current_root = dir.path().join("current-root");
12741        fs::create_dir_all(&previous_root).unwrap();
12742        fs::create_dir_all(&current_root).unwrap();
12743        fs::remove_dir(&previous_root).unwrap();
12744        let mut conn = Connection::open_in_memory().unwrap();
12745        initialize_schema(&conn).unwrap();
12746        conn.execute(
12747            "INSERT INTO backend_file_state(
12748                backend, workspace_root, file_path, content_hash, status, updated_at
12749             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
12750            params![previous_root.display().to_string()],
12751        )
12752        .unwrap();
12753
12754        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
12755
12756        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
12757        assert_eq!(
12758            stored_workspace_roots(&conn).unwrap(),
12759            vec![previous_root.display().to_string()]
12760        );
12761    }
12762
12763    #[test]
12764    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
12765        assert_eq!(
12766            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
12767            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
12768        );
12769    }
12770
12771    #[test]
12772    fn legacy_migration_completion_log_has_operator_fields() {
12773        assert_eq!(
12774            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
12775            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
12776        );
12777    }
12778
12779    fn write_generation_with_age(
12780        dir: &Path,
12781        project_key: &str,
12782        ordinal: u64,
12783        age: Duration,
12784    ) -> String {
12785        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
12786        let path = dir.join(&generation);
12787        fs::write(&path, b"sqlite placeholder").unwrap();
12788        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12789        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12790        generation
12791    }
12792
12793    #[test]
12794    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
12795        let dir = tempfile::tempdir().unwrap();
12796        let project_key = "project";
12797        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12798        let previous =
12799            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12800        let pinned =
12801            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12802        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12803
12804        gc_old_generations(dir.path(), project_key, &current);
12805
12806        assert!(dir.path().join(&previous).is_file());
12807        assert!(dir.path().join(&pinned).is_file());
12808
12809        drop(marker);
12810        gc_old_generations(dir.path(), project_key, &current);
12811
12812        assert!(dir.path().join(&previous).is_file());
12813        assert!(!dir.path().join(&pinned).exists());
12814    }
12815
12816    #[test]
12817    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
12818        let dir = tempfile::tempdir().unwrap();
12819        let project_key = "project";
12820        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12821        let _previous =
12822            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
12823        let pinned =
12824            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
12825        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
12826        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
12827
12828        gc_old_generations(dir.path(), project_key, &current);
12829
12830        assert!(dir.path().join(&pinned).is_file());
12831    }
12832
12833    #[test]
12834    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
12835        let dir = tempfile::tempdir().unwrap();
12836        let project_key = "project";
12837        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
12838        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
12839        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
12840        let old = write_generation_with_age(
12841            dir.path(),
12842            project_key,
12843            200,
12844            expired + Duration::from_secs(60),
12845        );
12846        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
12847
12848        gc_old_generations(dir.path(), project_key, &current);
12849
12850        assert!(dir.path().join(&current).is_file());
12851        assert!(dir.path().join(&previous).is_file());
12852        assert!(!dir.path().join(&old).exists());
12853    }
12854
12855    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
12856        let path = dir.join(name);
12857        fs::write(&path, b"temp placeholder").unwrap();
12858        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
12859        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
12860        path
12861    }
12862
12863    #[test]
12864    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
12865        let dir = tempdir().unwrap();
12866        // One directory holds both an aged orphan (with its journal sidecar) and a
12867        // fresh temporary, so this proves the sweep SELECTS by age rather than
12868        // deleting everything in the directory.
12869        let aged = "project.g100.1.sqlite.tmp.1.200";
12870        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
12871        let fresh = "project.g300.1.sqlite.tmp.1.400";
12872        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
12873        write_build_temp_with_age(dir.path(), aged, aged_age);
12874        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
12875        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12876
12877        sweep_orphaned_build_temps(dir.path());
12878
12879        assert!(
12880            !dir.path().join(aged).exists(),
12881            "aged orphan must be removed"
12882        );
12883        assert!(
12884            !dir.path().join(aged_journal).exists(),
12885            "aged journal sidecar must be removed"
12886        );
12887        assert!(
12888            dir.path().join(fresh).is_file(),
12889            "fresh temporary must survive"
12890        );
12891    }
12892
12893    #[test]
12894    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
12895        let storage = tempdir().unwrap();
12896        let storage_root = storage.path();
12897        // The production shape: a legacy per-harness store whose root no longer
12898        // builds there — no `.current` pointer, no running build — so the per-root
12899        // cleanup never fires for it. A sibling root still building in the
12900        // root-keyed store triggers the store-wide sweep, which must reach into the
12901        // legacy directory and reclaim the orphan.
12902        let legacy_dir = storage_root.join("opencode").join("callgraph");
12903        fs::create_dir_all(&legacy_dir).unwrap();
12904        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
12905        write_build_temp_with_age(
12906            &legacy_dir,
12907            orphan,
12908            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12909        );
12910        assert!(
12911            !legacy_dir.join("deadbeef.current").exists(),
12912            "the dead root has no current pointer"
12913        );
12914
12915        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
12916        fs::create_dir_all(&root_keyed_dir).unwrap();
12917
12918        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
12919
12920        assert!(
12921            !legacy_dir.join(orphan).exists(),
12922            "legacy orphan must be reclaimed by the store-wide sweep"
12923        );
12924    }
12925
12926    #[test]
12927    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
12928        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
12929        // everything (min_age = 0) removes the fresh temporary that the real 24h
12930        // threshold spares in the test above. If a mutation to the age check leaves
12931        // the fresh file in place here, the predicate is no longer doing the
12932        // selection work the fresh-survives assertion relies on.
12933        let dir = tempdir().unwrap();
12934        let fresh = "project.g300.1.sqlite.tmp.1.400";
12935        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
12936
12937        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
12938
12939        assert!(
12940            !dir.path().join(fresh).exists(),
12941            "with the age predicate forced open, the fresh temporary is removed"
12942        );
12943    }
12944
12945    #[test]
12946    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
12947        let dir = tempdir().unwrap();
12948        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
12949        // to be swept, plus a live read marker, is generation GC's jurisdiction.
12950        // The orphan sweep must not intersect it.
12951        let generation = write_generation_with_age(
12952            dir.path(),
12953            "project",
12954            400,
12955            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
12956        );
12957        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
12958
12959        sweep_orphaned_build_temps(dir.path());
12960
12961        assert!(
12962            dir.path().join(&generation).is_file(),
12963            "completed generation must survive the orphan sweep"
12964        );
12965        assert!(
12966            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
12967            "read marker must survive the orphan sweep"
12968        );
12969    }
12970
12971    #[test]
12972    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
12973        let dir = tempfile::tempdir().unwrap();
12974        let project_key = "project".to_string();
12975        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
12976        let sqlite_path = dir.path().join(&generation);
12977        fs::remove_file(&sqlite_path).unwrap();
12978        let conn = Connection::open(&sqlite_path).unwrap();
12979        let store = CallGraphStore::from_connection(
12980            dir.path().to_path_buf(),
12981            project_key,
12982            sqlite_path,
12983            dir.path().to_path_buf(),
12984            false,
12985            Some(generation.clone()),
12986            None,
12987            None,
12988            conn,
12989        );
12990
12991        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
12992        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
12993
12994        drop(marker);
12995        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
12996    }
12997
12998    #[test]
12999    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
13000        let dir = tempdir().expect("temp dir");
13001        let file = dir.path().join("main.ts");
13002        fs::write(&file, "export function main() {}\n").expect("write fixture");
13003        let store = CallGraphStore::open(
13004            dir.path().join(".store-readiness-cache"),
13005            dir.path().to_path_buf(),
13006        )
13007        .expect("open store");
13008        {
13009            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13010            conn.trace(Some(count_caller_traversal_selects));
13011        }
13012
13013        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13014        assert!(store.indexed_file_count().is_err());
13015        assert!(store.indexed_file_count().is_err());
13016        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
13017
13018        store
13019            .cold_build(std::slice::from_ref(&file))
13020            .expect("cold build");
13021        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13022        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
13023        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
13024        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
13025
13026        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13027        conn.trace(None);
13028    }
13029
13030    #[test]
13031    fn callers_depth_boundary_batches_sqlite_counts() {
13032        const CALLER_COUNT: usize = 1_000;
13033
13034        let dir = tempdir().expect("temp dir");
13035        let file = dir.path().join("main.ts");
13036        let mut source = String::from("export function sharedHotHelper() {}\n");
13037        for index in 0..CALLER_COUNT {
13038            source.push_str(&format!(
13039                "export function caller{index}() {{ sharedHotHelper(); }}\n"
13040            ));
13041        }
13042        fs::write(&file, source).expect("write fixture");
13043
13044        let store = CallGraphStore::open(
13045            dir.path().join(".store-callers-query-fanout"),
13046            dir.path().to_path_buf(),
13047        )
13048        .expect("open store");
13049        store
13050            .cold_build(std::slice::from_ref(&file))
13051            .expect("cold build");
13052
13053        CALLER_QUERY_SELECTS.with(|count| count.set(0));
13054        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
13055        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
13056        {
13057            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13058            conn.trace(Some(count_caller_traversal_selects));
13059        }
13060
13061        let started = Instant::now();
13062        let result = crate::commands::callgraph_store_adapter::callers_result(
13063            &store,
13064            Path::new("main.ts"),
13065            "sharedHotHelper",
13066            1,
13067            true,
13068        )
13069        .expect("callers result");
13070        let elapsed = started.elapsed();
13071
13072        {
13073            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13074            conn.trace(None);
13075        }
13076        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
13077        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
13078        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
13079        eprintln!(
13080            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
13081            result.total_callers,
13082            caller_queries,
13083            boundary_queries,
13084            total_selects,
13085            elapsed.as_secs_f64() * 1_000.0
13086        );
13087
13088        assert_eq!(result.total_callers, CALLER_COUNT);
13089        assert_eq!(caller_queries, 1);
13090        assert_eq!(boundary_queries, 3);
13091        assert_eq!(total_selects, 9);
13092    }
13093
13094    #[test]
13095    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
13096        let dir = tempdir().expect("temp dir");
13097        let file = dir.path().join("main.ts");
13098        fs::write(
13099            &file,
13100            r#"export function topA() {
13101  root();
13102}
13103
13104export function topB() {
13105  root();
13106}
13107
13108export function root() {
13109  leaf();
13110  missing();
13111}
13112
13113export function leaf() {}
13114"#,
13115        )
13116        .expect("write fixture");
13117
13118        let store = CallGraphStore::open(
13119            dir.path().join(".store-depth-boundary-counts"),
13120            dir.path().to_path_buf(),
13121        )
13122        .expect("open store");
13123        store
13124            .cold_build(std::slice::from_ref(&file))
13125            .expect("cold build");
13126
13127        let root = store
13128            .node_for(Path::new("main.ts"), "root")
13129            .expect("root node");
13130        let leaf = store
13131            .node_for(Path::new("main.ts"), "leaf")
13132            .expect("leaf node");
13133
13134        let (full_forward_len, full_direct_len) = {
13135            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13136            conn.execute(
13137                "INSERT INTO edges (
13138                    edge_id, ref_id, source_node, target_node, target_file,
13139                    target_symbol, kind, line, provenance
13140                 ) VALUES (
13141                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
13142                    ?2, ?3, 'call', 98, ?4
13143                 )",
13144                rusqlite::params![
13145                    &root.node_id,
13146                    &leaf.file,
13147                    &leaf.symbol,
13148                    PROVENANCE_TREESITTER
13149                ],
13150            )
13151            .expect("insert dangling forward edge");
13152            conn.execute(
13153                "INSERT INTO edges (
13154                    edge_id, ref_id, source_node, target_node, target_file,
13155                    target_symbol, kind, line, provenance
13156                 ) VALUES (
13157                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
13158                    ?1, ?2, ?3, 'call', 99, ?4
13159                 )",
13160                rusqlite::params![
13161                    &root.node_id,
13162                    &root.file,
13163                    &root.symbol,
13164                    PROVENANCE_TREESITTER
13165                ],
13166            )
13167            .expect("insert dangling direct-caller edge");
13168
13169            let full_forward_len = forward_calls_for_node(&conn, &root)
13170                .expect("full forward calls")
13171                .len();
13172            let counted_forward_len =
13173                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
13174            assert_eq!(
13175                counted_forward_len, full_forward_len,
13176                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
13177            );
13178
13179            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
13180                .expect("full direct callers");
13181            let full_direct_len = full_direct.len();
13182            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
13183                .expect("counted direct callers");
13184            assert_eq!(
13185                counted_direct_len, full_direct_len,
13186                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
13187            );
13188
13189            let distinct_direct_len = full_direct
13190                .iter()
13191                .map(|site| {
13192                    (
13193                        site.caller.file.clone(),
13194                        site.line,
13195                        site.target_file.clone(),
13196                        site.target_symbol.clone(),
13197                    )
13198                })
13199                .collect::<BTreeSet<_>>()
13200                .len();
13201            let batch_counts = direct_caller_counts_for_tuples(
13202                &conn,
13203                &[
13204                    (root.file.clone(), root.symbol.clone()),
13205                    (root.file.clone(), root.symbol.clone()),
13206                    (leaf.file.clone(), leaf.symbol.clone()),
13207                ],
13208            )
13209            .expect("batched direct-caller counts");
13210            assert_eq!(batch_counts.len(), 2);
13211            assert_eq!(
13212                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
13213                Some(&distinct_direct_len)
13214            );
13215
13216            (full_forward_len, full_direct_len)
13217        };
13218
13219        assert_eq!(
13220            full_forward_len, 2,
13221            "fixture root should have one resolved and one unresolved outgoing call"
13222        );
13223        assert_eq!(
13224            full_direct_len, 2,
13225            "fixture root should have two real direct callers"
13226        );
13227
13228        let tree = store
13229            .call_tree(Path::new("main.ts"), "root", 0)
13230            .expect("call tree");
13231        assert!(tree.depth_limited);
13232        assert_eq!(tree.children.len(), 0);
13233        assert_eq!(
13234            tree.truncated, full_forward_len,
13235            "call_tree depth boundary must report the full forward-call list length"
13236        );
13237
13238        let callers = store
13239            .callers_of(Path::new("main.ts"), "leaf", 0)
13240            .expect("callers");
13241        assert!(callers.depth_limited);
13242        assert_eq!(callers.callers.len(), 1);
13243        assert_eq!(callers.callers[0].caller.symbol, "root");
13244        assert_eq!(
13245            callers.truncated, full_direct_len,
13246            "callers depth boundary must report the full direct-caller list length"
13247        );
13248    }
13249
13250    #[test]
13251    fn source_freshness_matches_cache_collect_for_same_bytes() {
13252        let dir = tempdir().expect("temp dir");
13253        let path = dir.path().join("fixture.ts");
13254        let source = "export function main() { return helper(); }\n";
13255        fs::write(&path, source).expect("write fixture");
13256
13257        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
13258        let actual =
13259            collect_source_freshness(&path, source).expect("collect freshness from source");
13260
13261        assert_eq!(actual, expected);
13262    }
13263
13264    #[test]
13265    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
13266        let root = tempfile::tempdir().unwrap();
13267        let callgraph_dir = tempfile::tempdir().unwrap();
13268        let source_dir = root.path().join("src");
13269        std::fs::create_dir_all(&source_dir).unwrap();
13270        let source = source_dir.join("lib.rs");
13271        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
13272        let files = vec![source.clone()];
13273        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
13274        let old_epoch = epoch.next();
13275        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
13276        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
13277        let old_epoch_flag = epoch.clone();
13278        let old_dir = callgraph_dir.path().to_path_buf();
13279        let old_root = root.path().to_path_buf();
13280        let old_files = files.clone();
13281        let old = std::thread::spawn(move || {
13282            set_cold_build_before_publish_observer(Some(Arc::new(move || {
13283                reached_tx.send(()).unwrap();
13284                release_rx.recv().unwrap();
13285            })));
13286            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
13287                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
13288            });
13289            set_cold_build_before_publish_observer(None);
13290            result
13291        });
13292        // Positive wait: the older build runs a real cold build (git probe +
13293        // SQLite schema init) before the barrier, which can exceed 5s on a
13294        // contended Windows CI runner. Only negative waits stay short.
13295        reached_rx
13296            .recv_timeout(Duration::from_secs(30))
13297            .expect("older build did not reach its publication barrier");
13298
13299        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
13300        let new_epoch = epoch.next();
13301        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
13302            CallGraphStore::cold_build_with_lease(
13303                callgraph_dir.path().to_path_buf(),
13304                root.path().to_path_buf(),
13305                &files,
13306            )
13307        })
13308        .expect("newer build should publish");
13309        drop(new_store);
13310
13311        release_tx.send(()).unwrap();
13312        assert!(matches!(
13313            old.join().unwrap(),
13314            Err(CallGraphStoreError::Superseded)
13315        ));
13316
13317        let current = CallGraphStore::open_readonly(
13318            callgraph_dir.path().to_path_buf(),
13319            root.path().to_path_buf(),
13320        )
13321        .unwrap()
13322        .expect("current callgraph generation");
13323        assert_eq!(
13324            current
13325                .nodes_matching("new_generation_marker")
13326                .unwrap()
13327                .len(),
13328            1
13329        );
13330        assert!(current
13331            .nodes_matching("old_generation_marker")
13332            .unwrap()
13333            .is_empty());
13334    }
13335
13336    #[test]
13337    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
13338        let dir = tempdir().expect("temp dir");
13339        let project_root = dir.path();
13340        let extract = fixture_extract(project_root);
13341        let resolved = fixture_resolved(&extract);
13342
13343        let reference = build_reference_connection(project_root, &extract, &resolved);
13344        let optimized = build_optimized_connection(project_root, &extract, &resolved);
13345
13346        for table in [
13347            "files",
13348            "nodes",
13349            "file_dependencies",
13350            "dispatch_hints",
13351            "refs",
13352            "edges",
13353        ] {
13354            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
13355            // the reference and optimized builds run sequentially and can straddle a
13356            // one-second tick under load, so it is legitimately allowed to differ.
13357            // This mirrors the existing exclusions of `backend_file_state.updated_at`
13358            // and the chunked-vs-unchunked sibling test. The check is for structural
13359            // row equivalence of the optimized bulk insert, not wall-clock equality.
13360            let excluded: &[&str] = if table == "files" {
13361                &["indexed_at"]
13362            } else {
13363                &[]
13364            };
13365            assert_eq!(
13366                table_rows_without(&reference, table, excluded),
13367                table_rows_without(&optimized, table, excluded),
13368                "table `{table}` rows must match apart from wall-clock columns"
13369            );
13370        }
13371        assert_eq!(
13372            backend_state_rows(&reference),
13373            backend_state_rows(&optimized),
13374            "backend freshness rows must match apart from updated_at"
13375        );
13376        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
13377    }
13378
13379    #[test]
13380    fn cold_build_chunked_matches_unchunked_logical_rows() {
13381        let dir = tempdir().expect("temp dir");
13382        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
13383        write_chunked_equivalence_fixture(&project_root);
13384        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13385        assert!(
13386            files.len() > 6,
13387            "fixture should be large enough to split into multiple chunks"
13388        );
13389
13390        let unchunked = CallGraphStore::open(
13391            project_root.join(".store-unchunked"),
13392            project_root.to_path_buf(),
13393        )
13394        .expect("open unchunked store");
13395        let unchunked_stats = unchunked
13396            .cold_build_chunked(&files, 0)
13397            .expect("unchunked cold build");
13398
13399        let chunked = CallGraphStore::open(
13400            project_root.join(".store-chunked"),
13401            project_root.to_path_buf(),
13402        )
13403        .expect("open chunked store");
13404        let chunked_stats = chunked
13405            .cold_build_chunked(&files, 3)
13406            .expect("chunked cold build");
13407
13408        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
13409        assert_eq!(
13410            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
13411            chunked.edge_snapshot().expect("chunked edge snapshot"),
13412            "public edge snapshots must match"
13413        );
13414
13415        let dispatch_edges = {
13416            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
13417            conn.query_row(
13418                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
13419                [],
13420                |row| row.get::<_, i64>(0),
13421            )
13422            .expect("count dispatch edges")
13423        };
13424        assert!(
13425            dispatch_edges > 0,
13426            "fixture must exercise method-dispatch edge insertion"
13427        );
13428
13429        for table in [
13430            "edges",
13431            "refs",
13432            "nodes",
13433            "file_dependencies",
13434            "dispatch_hints",
13435        ] {
13436            assert_eq!(
13437                graph_table_rows(&unchunked, table),
13438                graph_table_rows(&chunked, table),
13439                "chunked cold build must match unchunked rows for {table}"
13440            );
13441        }
13442        assert_eq!(
13443            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
13444            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
13445            "files rows must match apart from indexed_at"
13446        );
13447        assert_eq!(
13448            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
13449            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
13450            "backend freshness rows must match apart from updated_at"
13451        );
13452
13453        let published_dir = project_root.join(".store-published");
13454        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
13455            published_dir.clone(),
13456            project_root.to_path_buf(),
13457            &files,
13458            0,
13459        )
13460        .expect("published unchunked cold build");
13461        assert!(
13462            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
13463                .expect("needs_cold_build after publish"),
13464            "published store should be ready"
13465        );
13466        drop(_published);
13467        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
13468            published_dir,
13469            project_root.to_path_buf(),
13470            &files,
13471            3,
13472        )
13473        .expect("ensure with a different chunk size");
13474        assert!(
13475            rebuild_stats.is_none(),
13476            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
13477        );
13478    }
13479
13480    // Perf A/B bench (not a gate): measures cold_build wall time at a given
13481    // chunk size against a real repo. Driven by env so the same binary can A/B
13482    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
13483    // memory work. Run:
13484    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
13485    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
13486    #[test]
13487    #[ignore]
13488    fn bench_cold_build_chunk() {
13489        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
13490        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
13491            .expect("AFT_PERF_CHUNK")
13492            .parse()
13493            .expect("AFT_PERF_CHUNK must be a non-negative integer");
13494        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
13495        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
13496        let dir = tempdir().expect("temp dir");
13497        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
13498            .expect("open store");
13499        let started = Instant::now();
13500        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
13501        let ms = started.elapsed().as_millis();
13502        println!(
13503            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
13504            stats.files, stats.nodes, stats.refs, stats.edges
13505        );
13506    }
13507
13508    #[test]
13509    fn persisted_workspace_reexport_selects_its_package_dependency() {
13510        let root = tempdir().expect("temp dir");
13511        let dependencies = BTreeSet::from([
13512            "packages/aft-bridge/src/index.ts".to_string(),
13513            "packages/opencode-plugin/src/types.ts".to_string(),
13514        ]);
13515        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
13516
13517        assert_eq!(
13518            stored_dependencies_for_module(
13519                root.path(),
13520                "packages/opencode-plugin/src/shared/bash-hints.ts",
13521                "@cortexkit/aft-bridge",
13522                &dependencies,
13523                &indexed_files,
13524            ),
13525            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
13526        );
13527    }
13528
13529    #[test]
13530    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
13531        let dir = tempdir().expect("temp dir");
13532        let project_root = dir.path();
13533        let files =
13534            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
13535        let index_path = project_root.join("src/index.ts");
13536
13537        let store = CallGraphStore::open(
13538            project_root.join(".store-incremental-barrel"),
13539            project_root.to_path_buf(),
13540        )
13541        .expect("open incremental store");
13542        store.cold_build(&files).expect("initial cold build");
13543
13544        {
13545            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
13546            let tx = conn.transaction().expect("dependency transaction");
13547            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
13548                .expect("dependent refs for barrel");
13549            let selected_ref_ids = dependent_refs
13550                .iter()
13551                .map(|dependent_ref| dependent_ref.ref_id.clone())
13552                .collect::<BTreeSet<_>>();
13553            let mut threaded_ref_ids = BTreeSet::new();
13554            let mut threaded_by_caller = BTreeMap::new();
13555            record_dependent_refs(
13556                &mut threaded_ref_ids,
13557                &mut threaded_by_caller,
13558                dependent_refs,
13559            );
13560            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
13561                .expect("old per-ref caller lookup");
13562
13563            assert_eq!(threaded_ref_ids, selected_ref_ids);
13564            assert_eq!(threaded_by_caller, old_by_caller);
13565            for consumer in [
13566                "src/consumer_a.ts",
13567                "src/consumer_b.ts",
13568                "src/consumer_c.ts",
13569            ] {
13570                assert!(
13571                    threaded_by_caller.contains_key(consumer),
13572                    "barrel edit should select dependent refs from {consumer}"
13573                );
13574            }
13575        }
13576
13577        fs::write(
13578            &index_path,
13579            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
13580        )
13581        .expect("edit barrel");
13582        let stats = store
13583            .refresh_files(std::slice::from_ref(&index_path))
13584            .expect("incremental refresh");
13585        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
13586        assert!(
13587            stats.dependency_selected_refs > 0,
13588            "barrel surface edit should select dependent refs"
13589        );
13590
13591        let cold_store = CallGraphStore::open(
13592            project_root.join(".store-cold-barrel"),
13593            project_root.to_path_buf(),
13594        )
13595        .expect("open cold rebuild store");
13596        cold_store
13597            .cold_build(&files)
13598            .expect("comparison cold build");
13599
13600        for table in [
13601            "nodes",
13602            "refs",
13603            "file_dependencies",
13604            "edges",
13605            "dispatch_hints",
13606        ] {
13607            assert_eq!(
13608                graph_table_rows(&store, table),
13609                graph_table_rows(&cold_store, table),
13610                "incremental refresh {table} rows must match cold rebuild"
13611            );
13612        }
13613
13614        let consumer_path = project_root.join("src/consumer_a.ts");
13615        fs::write(
13616            &consumer_path,
13617            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
13618        )
13619        .expect("edit barrel consumer");
13620        store
13621            .refresh_files(std::slice::from_ref(&consumer_path))
13622            .expect("refresh consumer through unchanged barrel");
13623        cold_store
13624            .cold_build(&files)
13625            .expect("comparison cold rebuild after consumer refresh");
13626        for table in [
13627            "nodes",
13628            "refs",
13629            "file_dependencies",
13630            "edges",
13631            "dispatch_hints",
13632        ] {
13633            assert_eq!(
13634                graph_table_rows(&store, table),
13635                graph_table_rows(&cold_store, table),
13636                "refresh through a persisted barrel must preserve cold-build {table} rows"
13637            );
13638        }
13639    }
13640
13641    fn build_reference_connection(
13642        project_root: &Path,
13643        extract: &FileExtract,
13644        resolved: &ResolvedRef,
13645    ) -> Connection {
13646        let mut conn = Connection::open_in_memory().expect("open reference db");
13647        configure_build_connection(&conn).expect("configure reference db");
13648        initialize_schema(&conn).expect("initialize reference schema");
13649        {
13650            let tx = conn.transaction().expect("reference transaction");
13651            clear_tables(&tx).expect("reference clear");
13652            insert_meta(&tx).expect("reference meta");
13653            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
13654            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
13655            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13656                .expect("reference dispatch edges");
13657            assert_eq!(supplemental, 0);
13658            tx.commit().expect("reference commit");
13659        }
13660        conn
13661    }
13662
13663    fn build_optimized_connection(
13664        project_root: &Path,
13665        extract: &FileExtract,
13666        resolved: &ResolvedRef,
13667    ) -> Connection {
13668        let mut conn = Connection::open_in_memory().expect("open optimized db");
13669        configure_build_connection(&conn).expect("configure optimized db");
13670        initialize_schema(&conn).expect("initialize optimized schema");
13671        {
13672            let tx = conn.transaction().expect("optimized transaction");
13673            clear_tables(&tx).expect("optimized clear");
13674            insert_meta(&tx).expect("optimized meta");
13675            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
13676            {
13677                let workspace_root = project_root.display().to_string();
13678                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
13679                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
13680                    .expect("optimized file extract");
13681                insert_resolved_ref_prepared(&mut inserts, resolved)
13682                    .expect("optimized resolved ref");
13683            }
13684            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
13685            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
13686                .expect("optimized dispatch edges");
13687            assert_eq!(supplemental, 0);
13688            tx.commit().expect("optimized commit");
13689        }
13690        conn
13691    }
13692
13693    fn fixture_extract(_project_root: &Path) -> FileExtract {
13694        let rel_path = "src/main.ts".to_string();
13695        let target_path = "src/helper.ts".to_string();
13696        let node = NodeRecord {
13697            id: "node-main".to_string(),
13698            file_path: rel_path.clone(),
13699            name: "main".to_string(),
13700            scoped_name: "main".to_string(),
13701            kind: "function".to_string(),
13702            range: Range {
13703                start_line: 0,
13704                start_col: 0,
13705                end_line: 0,
13706                end_col: 32,
13707            },
13708            range_ordinal: 0,
13709            signature: Some("export function main()".to_string()),
13710            exported: true,
13711            is_default_export: false,
13712            is_type_like: false,
13713            is_callgraph_entry_point: true,
13714        };
13715        let mut dependencies = BTreeSet::new();
13716        dependencies.insert(target_path.clone());
13717        let raw_ref = RawRef {
13718            ref_id: "ref-main-helper".to_string(),
13719            caller_node: Some(node.id.clone()),
13720            caller_symbol: Some(node.scoped_name.clone()),
13721            caller_file: rel_path.clone(),
13722            kind: "call".to_string(),
13723            short_name: Some("helper".to_string()),
13724            full_ref: Some("helper".to_string()),
13725            module_path: None,
13726            import_kind: None,
13727            local_name: Some("helper".to_string()),
13728            requested_name: Some("helper".to_string()),
13729            namespace_alias: None,
13730            wildcard: false,
13731            line: 1,
13732            byte_start: 24,
13733            byte_end: 32,
13734            dependencies,
13735        };
13736        FileExtract {
13737            rel_path,
13738            freshness: FileFreshness {
13739                mtime: UNIX_EPOCH + Duration::from_secs(123),
13740                size: 40,
13741                content_hash: cache_freshness::hash_bytes(b"fixture source"),
13742            },
13743            lang: LangId::TypeScript,
13744            data: FileCallData {
13745                calls_by_symbol: HashMap::new(),
13746                exported_symbols: Vec::new(),
13747                symbol_metadata: HashMap::new(),
13748                default_export_symbol: None,
13749                import_block: ImportBlock::empty(),
13750                lang: LangId::TypeScript,
13751            },
13752            nodes: vec![node.clone()],
13753            raw_refs: vec![raw_ref],
13754            dispatch_hints: vec![DispatchHint {
13755                id: "dispatch-main-helper".to_string(),
13756                method_name: "helper".to_string(),
13757                caller_node: node.id,
13758                file: "src/main.ts".to_string(),
13759                line: 1,
13760                byte_start: 24,
13761                byte_end: 32,
13762            }],
13763            surface_fingerprint: "surface".to_string(),
13764        }
13765    }
13766
13767    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
13768        let raw = extract.raw_refs[0].clone();
13769        let mut dependencies = raw.dependencies.clone();
13770        dependencies.insert("src/helper.ts".to_string());
13771        ResolvedRef {
13772            edge: Some(EdgeRecord {
13773                edge_id: "edge-main-helper".to_string(),
13774                source_node: raw.caller_node.clone().expect("caller node"),
13775                target_node: Some("node-helper".to_string()),
13776                target_file: "src/helper.ts".to_string(),
13777                target_symbol: "helper".to_string(),
13778                kind: "call".to_string(),
13779                line: raw.line,
13780            }),
13781            raw,
13782            status: "resolved".to_string(),
13783            target_node: Some("node-helper".to_string()),
13784            target_file: Some("src/helper.ts".to_string()),
13785            target_symbol: Some("helper".to_string()),
13786            dependencies,
13787        }
13788    }
13789
13790    fn write_chunked_equivalence_fixture(project_root: &Path) {
13791        let ts_dir = project_root.join("ts");
13792        fs::create_dir_all(&ts_dir).expect("create ts dir");
13793        fs::write(
13794            ts_dir.join("leaf.ts"),
13795            "export function leaf(value: number) {\n  return value + 1;\n}\n",
13796        )
13797        .expect("write ts leaf");
13798        fs::write(
13799            ts_dir.join("mid.ts"),
13800            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
13801        )
13802        .expect("write ts mid");
13803        fs::write(
13804            ts_dir.join("entry.ts"),
13805            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
13806        )
13807        .expect("write ts entry");
13808        fs::write(
13809            ts_dir.join("worker.ts"),
13810            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
13811        )
13812        .expect("write ts worker");
13813        for idx in 0..4 {
13814            fs::write(
13815                ts_dir.join(format!("extra_{idx}.ts")),
13816                format!(
13817                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
13818                ),
13819            )
13820            .expect("write ts extra");
13821        }
13822
13823        let rust_dir = project_root.join("src");
13824        let commands_dir = rust_dir.join("commands");
13825        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
13826        fs::write(
13827            rust_dir.join("context.rs"),
13828            r#"pub struct AppContext;
13829
13830impl AppContext {
13831    pub fn callgraph_store_for_ops(&self) -> usize {
13832        1
13833    }
13834}
13835"#,
13836        )
13837        .expect("write rust context");
13838        fs::write(
13839            rust_dir.join("lib.rs"),
13840            "pub mod context;\npub mod commands;\n",
13841        )
13842        .expect("write rust lib");
13843        fs::write(
13844            commands_dir.join("mod.rs"),
13845            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
13846        )
13847        .expect("write rust commands mod");
13848        for name in ["callers", "impact", "trace_to"] {
13849            fs::write(
13850                commands_dir.join(format!("{name}.rs")),
13851                format!(
13852                    r#"use crate::context::AppContext;
13853
13854pub fn handle_{name}(ctx: &AppContext) -> usize {{
13855    ctx.callgraph_store_for_ops()
13856}}
13857"#
13858                ),
13859            )
13860            .expect("write rust command");
13861        }
13862    }
13863
13864    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
13865        let src_dir = project_root.join("src");
13866        fs::create_dir_all(&src_dir).expect("create src dir");
13867
13868        let target_path = src_dir.join("target.ts");
13869        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
13870            .expect("write target");
13871
13872        let index_path = src_dir.join("index.ts");
13873        fs::write(&index_path, barrel_source).expect("write barrel");
13874
13875        let mut files = vec![target_path, index_path];
13876        for (file_name, function_name) in [
13877            ("consumer_a.ts", "consumerA"),
13878            ("consumer_b.ts", "consumerB"),
13879            ("consumer_c.ts", "consumerC"),
13880        ] {
13881            let path = src_dir.join(file_name);
13882            fs::write(
13883                &path,
13884                format!(
13885                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
13886                ),
13887            )
13888            .expect("write consumer");
13889            files.push(path);
13890        }
13891        files
13892    }
13893
13894    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
13895        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13896        table_rows(&conn, table)
13897    }
13898
13899    fn graph_table_rows_without(
13900        store: &CallGraphStore,
13901        table: &str,
13902        excluded_columns: &[&str],
13903    ) -> Vec<String> {
13904        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
13905        table_rows_without(&conn, table, excluded_columns)
13906    }
13907
13908    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
13909        table_rows_without(conn, table, &[])
13910    }
13911
13912    fn table_rows_without(
13913        conn: &Connection,
13914        table: &str,
13915        excluded_columns: &[&str],
13916    ) -> Vec<String> {
13917        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
13918        let columns: Vec<String> = conn
13919            .prepare(&format!("PRAGMA table_info({table})"))
13920            .expect("prepare table_info")
13921            .query_map([], |row| row.get::<_, String>(1))
13922            .expect("query table_info")
13923            .collect::<std::result::Result<Vec<String>, _>>()
13924            .expect("collect columns")
13925            .into_iter()
13926            .filter(|column| !excluded_columns.contains(column.as_str()))
13927            .collect();
13928        let sql = format!(
13929            "SELECT {} FROM {table} ORDER BY {}",
13930            columns.join(", "),
13931            columns.join(", ")
13932        );
13933        conn.prepare(&sql)
13934            .expect("prepare table rows")
13935            .query_map([], |row| row_to_strings(row, columns.len()))
13936            .expect("query table rows")
13937            .collect::<std::result::Result<_, _>>()
13938            .expect("collect table rows")
13939    }
13940
13941    fn assert_cold_build_stats_match_except_elapsed(
13942        expected: &ColdBuildStats,
13943        actual: &ColdBuildStats,
13944    ) {
13945        assert_eq!(actual.files, expected.files, "file counts must match");
13946        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
13947        assert_eq!(actual.refs, expected.refs, "ref counts must match");
13948        assert_eq!(actual.edges, expected.edges, "edge counts must match");
13949        assert_eq!(
13950            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
13951            expected
13952                .failed_files
13953                .iter()
13954                .cloned()
13955                .collect::<BTreeSet<_>>(),
13956            "failed file sets must match"
13957        );
13958    }
13959
13960    fn backend_state_rows(conn: &Connection) -> Vec<String> {
13961        conn.prepare(
13962            "SELECT backend, workspace_root, file_path, content_hash, status
13963             FROM backend_file_state
13964             ORDER BY backend, workspace_root, file_path, content_hash, status",
13965        )
13966        .expect("prepare backend rows")
13967        .query_map([], |row| row_to_strings(row, 5))
13968        .expect("query backend rows")
13969        .collect::<std::result::Result<_, _>>()
13970        .expect("collect backend rows")
13971    }
13972
13973    fn secondary_indexes(conn: &Connection) -> Vec<String> {
13974        let mut indexes = Vec::new();
13975        for table in [
13976            "files",
13977            "nodes",
13978            "refs",
13979            "file_dependencies",
13980            "edges",
13981            "dispatch_hints",
13982            "type_ref_names",
13983            "backend_file_state",
13984            "meta",
13985        ] {
13986            let sql = format!("PRAGMA index_list({table})");
13987            let mut stmt = conn.prepare(&sql).expect("prepare index list");
13988            let rows = stmt
13989                .query_map([], |row| row.get::<_, String>(1))
13990                .expect("query index list");
13991            for name in rows {
13992                let name = name.expect("index name");
13993                if name.starts_with("idx_") {
13994                    indexes.push(format!("{table}:{name}"));
13995                }
13996            }
13997        }
13998        indexes.sort();
13999        indexes
14000    }
14001
14002    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
14003        let mut values = Vec::with_capacity(len);
14004        for index in 0..len {
14005            let value = row.get_ref(index)?;
14006            values.push(match value {
14007                rusqlite::types::ValueRef::Null => "NULL".to_string(),
14008                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
14009                rusqlite::types::ValueRef::Real(value) => value.to_string(),
14010                rusqlite::types::ValueRef::Text(value) => {
14011                    String::from_utf8_lossy(value).into_owned()
14012                }
14013                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
14014            });
14015        }
14016        Ok(values.join("\u{1f}"))
14017    }
14018}
14019
14020#[cfg(test)]
14021mod rust_resolution_tests {
14022    use super::*;
14023    use crate::inspect::job::CallgraphSnapshot;
14024    use std::fs;
14025    use tempfile::tempdir;
14026
14027    #[test]
14028    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
14029        let dir = tempdir().expect("tempdir");
14030        let root = dir.path();
14031        write_rust_manifest(root, "scoped-alias-fixture");
14032        write_file(
14033            root,
14034            "src/lib.rs",
14035            r#"pub mod finalization_contract;
14036
14037pub fn run_alias() {
14038    use crate::finalization_contract as fc;
14039    fc::check_mason_contract();
14040}
14041"#,
14042        );
14043        write_file(
14044            root,
14045            "src/finalization_contract.rs",
14046            r#"pub fn check_mason_contract() {}
14047fn planted_dead() {}
14048"#,
14049        );
14050
14051        let (store, snapshot) = cold_build_twice(root);
14052        assert_direct_caller(
14053            &store,
14054            "src/finalization_contract.rs",
14055            "check_mason_contract",
14056            "src/lib.rs",
14057            "run_alias",
14058        );
14059        assert_projected_call(
14060            root,
14061            &snapshot,
14062            "src/finalization_contract.rs",
14063            "check_mason_contract",
14064        );
14065        assert_no_projected_call(
14066            root,
14067            &snapshot,
14068            "src/finalization_contract.rs",
14069            "planted_dead",
14070        );
14071        assert!(
14072            store
14073                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
14074                .expect("planted dead callers")
14075                .is_empty(),
14076            "planted-dead guard should stay without callers"
14077        );
14078    }
14079
14080    #[test]
14081    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
14082        let dir = tempdir().expect("tempdir");
14083        let root = dir.path();
14084        write_rust_manifest(root, "inline-module-fixture");
14085        write_file(
14086            root,
14087            "src/lib.rs",
14088            r#"mod work_graph { fn operations() {} }
14089mod manifest { fn operations() {} }
14090mod audit { fn operations() {} }
14091mod dispatch { fn operations() {} }
14092mod finalization { fn operations() {} }
14093
14094pub fn run_inline_operations() {
14095    work_graph::operations();
14096    manifest::operations();
14097    audit::operations();
14098    dispatch::operations();
14099    finalization::operations();
14100}
14101
14102fn planted_dead() {}
14103"#,
14104        );
14105
14106        let (store, snapshot) = cold_build_twice(root);
14107        for module in [
14108            "work_graph",
14109            "manifest",
14110            "audit",
14111            "dispatch",
14112            "finalization",
14113        ] {
14114            assert_direct_caller(
14115                &store,
14116                "src/lib.rs",
14117                &format!("{module}::operations"),
14118                "src/lib.rs",
14119                "run_inline_operations",
14120            );
14121        }
14122        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
14123        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14124    }
14125
14126    #[test]
14127    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
14128        let dir = tempdir().expect("tempdir");
14129        let root = dir.path();
14130        fs::write(
14131            root.join("Cargo.toml"),
14132            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
14133        )
14134        .expect("write workspace manifest");
14135        write_file(
14136            root,
14137            "crates/but-action/Cargo.toml",
14138            r#"[package]
14139name = "but-action"
14140version = "0.1.0"
14141edition = "2021"
14142"#,
14143        );
14144        write_file(
14145            root,
14146            "crates/but-action/src/lib.rs",
14147            "mod action;\npub use action::{list_actions};\n",
14148        );
14149        write_file(
14150            root,
14151            "crates/but-action/src/action.rs",
14152            "pub fn list_actions() {}\nfn planted_dead() {}\n",
14153        );
14154        write_file(
14155            root,
14156            "crates/app/Cargo.toml",
14157            r#"[package]
14158name = "app"
14159version = "0.1.0"
14160edition = "2021"
14161"#,
14162        );
14163        write_file(
14164            root,
14165            "crates/app/src/lib.rs",
14166            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
14167        );
14168
14169        let (store, snapshot) = cold_build_twice(root);
14170        assert_direct_caller(
14171            &store,
14172            "crates/but-action/src/action.rs",
14173            "list_actions",
14174            "crates/app/src/lib.rs",
14175            "run_actions",
14176        );
14177        assert!(
14178            store
14179                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
14180                .expect("lib reexport callers")
14181                .is_empty(),
14182            "call should target the reexported source function, not lib.rs"
14183        );
14184        assert_projected_call(
14185            root,
14186            &snapshot,
14187            "crates/but-action/src/action.rs",
14188            "list_actions",
14189        );
14190        assert_no_projected_call(
14191            root,
14192            &snapshot,
14193            "crates/but-action/src/action.rs",
14194            "planted_dead",
14195        );
14196    }
14197
14198    #[test]
14199    fn rust_generic_self_turbofish_method_dispatch_resolves() {
14200        let dir = tempdir().expect("tempdir");
14201        let root = dir.path();
14202        write_rust_manifest(root, "generic-self-fixture");
14203        write_file(
14204            root,
14205            "src/lib.rs",
14206            r#"pub struct Matcher;
14207
14208impl Matcher {
14209    pub fn run(&self) -> bool {
14210        self.fuzzy_match_optimal::<usize>("needle")
14211    }
14212
14213    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
14214        let _ = std::marker::PhantomData::<T>;
14215        true
14216    }
14217
14218    fn planted_dead(&self) {}
14219}
14220
14221pub fn entry() -> bool {
14222    let matcher = Matcher;
14223    matcher.run()
14224}
14225"#,
14226        );
14227
14228        let (store, snapshot) = cold_build_twice(root);
14229        assert_direct_caller(
14230            &store,
14231            "src/lib.rs",
14232            "Matcher::fuzzy_match_optimal",
14233            "src/lib.rs",
14234            "Matcher::run",
14235        );
14236        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
14237        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
14238    }
14239
14240    #[test]
14241    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
14242        let dir = tempdir().expect("tempdir");
14243        let root = dir.path();
14244        write_rust_manifest(root, "manifest-operations-fixture");
14245        write_file(
14246            root,
14247            "src/main.rs",
14248            r#"mod dispatch;
14249use dispatch::{manifest_operations};
14250
14251fn main() {
14252    manifest_operations();
14253}
14254"#,
14255        );
14256        write_file(
14257            root,
14258            "src/dispatch.rs",
14259            r#"mod work_graph { fn operations() {} }
14260mod manifest { fn operations() {} }
14261mod audit { fn operations() {} }
14262mod descriptor { fn operations() {} }
14263mod writer { fn operations() {} }
14264
14265pub fn manifest_operations() {
14266    manifest::operations();
14267}
14268
14269pub fn work_graph_operations() {
14270    work_graph::operations();
14271}
14272
14273pub fn audit_operations() {
14274    audit::operations();
14275}
14276
14277pub fn descriptor_operations() {
14278    descriptor::operations();
14279}
14280
14281pub fn writer_operations() {
14282    writer::operations();
14283}
14284
14285fn planted_dead() {}
14286"#,
14287        );
14288
14289        let (store, snapshot) = cold_build_twice(root);
14290        assert_direct_caller(
14291            &store,
14292            "src/dispatch.rs",
14293            "manifest_operations",
14294            "src/main.rs",
14295            "main",
14296        );
14297        assert_direct_caller(
14298            &store,
14299            "src/dispatch.rs",
14300            "manifest::operations",
14301            "src/dispatch.rs",
14302            "manifest_operations",
14303        );
14304        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
14305        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
14306        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
14307    }
14308
14309    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
14310        let files = rust_files(root);
14311        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
14312            .expect("open first store");
14313        first.cold_build(&files).expect("first cold build");
14314        let first_snapshot =
14315            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
14316
14317        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
14318            .expect("open second store");
14319        second.cold_build(&files).expect("second cold build");
14320        let second_snapshot =
14321            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
14322
14323        assert_eq!(
14324            projection_rows(&first_snapshot),
14325            projection_rows(&second_snapshot),
14326            "cold-build projection should be deterministic"
14327        );
14328        (first, first_snapshot)
14329    }
14330
14331    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
14332        let mut rows = Vec::new();
14333        for export in &snapshot.exported_symbols {
14334            rows.push(format!(
14335                "export\t{}\t{}\t{}\t{}",
14336                export.file.display(),
14337                export.symbol,
14338                export.kind,
14339                export.line
14340            ));
14341        }
14342        for call in &snapshot.outbound_calls {
14343            rows.push(format!(
14344                "call\t{}\t{}\t{}\t{}\t{}",
14345                call.caller_file.display(),
14346                call.caller_symbol,
14347                call.target,
14348                call.line,
14349                call.provenance
14350            ));
14351        }
14352        for file in &snapshot.entry_points {
14353            rows.push(format!("entry_file\t{}", file.display()));
14354        }
14355        for (file, symbols) in &snapshot.entry_point_symbols {
14356            for symbol in symbols {
14357                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
14358            }
14359        }
14360        rows.sort();
14361        rows
14362    }
14363
14364    fn assert_direct_caller(
14365        store: &CallGraphStore,
14366        target_rel: &str,
14367        target_symbol: &str,
14368        caller_rel: &str,
14369        caller_symbol: &str,
14370    ) {
14371        let callers = store
14372            .direct_callers_of(Path::new(target_rel), target_symbol)
14373            .unwrap_or_else(|error| {
14374                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
14375            });
14376        assert!(
14377            callers.iter().any(|site| {
14378                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
14379            }),
14380            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
14381        );
14382    }
14383
14384    fn assert_projected_call(
14385        root: &Path,
14386        snapshot: &CallgraphSnapshot,
14387        target_rel: &str,
14388        symbol: &str,
14389    ) {
14390        let target = projected_target(root, target_rel, symbol);
14391        assert!(
14392            snapshot.outbound_calls.iter().any(|call| {
14393                call.target == target
14394                    || call.target.starts_with(&format!(
14395                        "{target}{}",
14396                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14397                    ))
14398            }),
14399            "expected projected call to {target}; calls: {:#?}",
14400            snapshot.outbound_calls
14401        );
14402    }
14403
14404    fn assert_no_projected_call(
14405        root: &Path,
14406        snapshot: &CallgraphSnapshot,
14407        target_rel: &str,
14408        symbol: &str,
14409    ) {
14410        let target = projected_target(root, target_rel, symbol);
14411        assert!(
14412            snapshot.outbound_calls.iter().all(|call| {
14413                call.target != target
14414                    && !call.target.starts_with(&format!(
14415                        "{target}{}",
14416                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
14417                    ))
14418            }),
14419            "did not expect projected call to {target}; calls: {:#?}",
14420            snapshot.outbound_calls
14421        );
14422    }
14423
14424    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
14425        // Projection targets carry the normalized (verbatim-stripped)
14426        // canonical form; bare fs::canonicalize diverges on Windows.
14427        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
14428        format!("{}::{symbol}", path.display())
14429    }
14430
14431    fn write_rust_manifest(root: &Path, name: &str) {
14432        write_file(
14433            root,
14434            "Cargo.toml",
14435            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
14436        );
14437    }
14438
14439    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
14440        let path = root.join(rel_path);
14441        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
14442        fs::write(&path, source).expect("write fixture file");
14443        path
14444    }
14445
14446    fn rust_files(root: &Path) -> Vec<PathBuf> {
14447        let mut files = Vec::new();
14448        collect_rust_files(root, &mut files);
14449        files.sort();
14450        files
14451    }
14452
14453    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
14454        for entry in fs::read_dir(dir).expect("read fixture dir") {
14455            let entry = entry.expect("read fixture entry");
14456            let path = entry.path();
14457            if path.is_dir() {
14458                let name = path
14459                    .file_name()
14460                    .and_then(|name| name.to_str())
14461                    .unwrap_or("");
14462                if !name.starts_with(".store") {
14463                    collect_rust_files(&path, files);
14464                }
14465            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
14466                files.push(path);
14467            }
14468        }
14469    }
14470}
14471
14472#[cfg(test)]
14473mod build_pool_tests {
14474    use super::build_pool_size;
14475
14476    #[test]
14477    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
14478        let size = build_pool_size();
14479        // Never zero, never the full core count, never above the 8 cap — this is
14480        // the starvation guard for the cold-build's all-cores tree-sitter pass.
14481        assert!(size >= 1, "pool size must be at least 1");
14482        assert!(size <= 8, "pool size must be capped at 8, got {size}");
14483
14484        let cores = std::thread::available_parallelism()
14485            .map(|p| p.get())
14486            .unwrap_or(1);
14487        let expected = cores.div_ceil(2).clamp(1, 8);
14488        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
14489    }
14490}
14491
14492#[cfg(test)]
14493mod reexport_resolution_tests {
14494    use super::*;
14495
14496    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
14497        ProjectIndex {
14498            project_root: PathBuf::from("/fixture"),
14499            files: files.into_iter().collect(),
14500            caller_data: HashMap::new(),
14501            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
14502        }
14503    }
14504
14505    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
14506        DbFileIndex {
14507            lang: None,
14508            exports: HashSet::new(),
14509            default_export: None,
14510            export_aliases: HashMap::new(),
14511            node_by_scoped: HashMap::new(),
14512            node_by_bare: HashMap::new(),
14513            module_targets: HashMap::new(),
14514            reexports: reexport_targets
14515                .iter()
14516                .map(|target| ReexportIndex {
14517                    target_file: Some((*target).to_string()),
14518                    named: HashMap::new(),
14519                    wildcard: true,
14520                })
14521                .collect(),
14522        }
14523    }
14524
14525    /// A dense wildcard re-export cycle (barrel files re-exporting each
14526    /// other) must resolve in O(files), not O(branching^depth). Without the
14527    /// resolver's memoization, resolving a MISSING symbol through this
14528    /// 12-file complete digraph explores ~11^16 paths and this test never
14529    /// finishes: the depth cap bounds path length, not path count, and one
14530    /// such resolution can pin a worker thread at 100% CPU indefinitely.
14531    #[test]
14532    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
14533        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
14534        let files = names
14535            .iter()
14536            .map(|name| {
14537                let targets: Vec<&str> = names
14538                    .iter()
14539                    .filter(|other| *other != name)
14540                    .map(String::as_str)
14541                    .collect();
14542                (name.clone(), barrel_file(&targets))
14543            })
14544            .collect();
14545        let index = barrel_index(files);
14546
14547        assert_eq!(
14548            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
14549            None
14550        );
14551    }
14552
14553    /// Depth-dominance counterexample: the walk first reaches `shared` down a
14554    /// 16-hop chain (no budget left for its leaf), then reaches it again
14555    /// directly at depth 1. Plain visited-set pruning would skip the second
14556    /// visit and lose a resolution the capped resolver finds; the
14557    /// depth-dominance memo revisits because the second arrival is shallower.
14558    #[test]
14559    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
14560        let mut leaf = barrel_file(&[]);
14561        leaf.exports.insert("deep_symbol".to_string());
14562        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
14563        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
14564        // entry's SECOND reexport goes straight to shared.
14565        files.push((
14566            "src/entry.ts".to_string(),
14567            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
14568        ));
14569        for i in 0..15 {
14570            let next = if i == 14 {
14571                "src/shared.ts".to_string()
14572            } else {
14573                format!("src/chain{}.ts", i + 1)
14574            };
14575            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
14576        }
14577        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
14578        files.push(("src/leaf.ts".to_string(), leaf));
14579        let index = barrel_index(files);
14580
14581        assert_eq!(
14582            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
14583            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
14584            "a shallower re-visit must not be pruned by a deeper capped visit"
14585        );
14586    }
14587
14588    #[test]
14589    fn symbol_reachable_through_reexport_cycle_still_resolves() {
14590        let mut leaf = barrel_file(&[]);
14591        leaf.exports.insert("real_symbol".to_string());
14592        let index = barrel_index(vec![
14593            (
14594                "src/a.ts".to_string(),
14595                barrel_file(&["src/b.ts", "src/a.ts"]),
14596            ),
14597            (
14598                "src/b.ts".to_string(),
14599                barrel_file(&["src/a.ts", "src/leaf.ts"]),
14600            ),
14601            ("src/leaf.ts".to_string(), leaf),
14602        ]);
14603
14604        assert_eq!(
14605            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
14606            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
14607        );
14608    }
14609}
14610
14611#[cfg(test)]
14612mod method_dispatch_inference_tests {
14613    use super::*;
14614    use std::fs;
14615    use tempfile::tempdir;
14616
14617    #[test]
14618    fn java_field_receiver_type_selects_declared_class_method() {
14619        let source = r#"class EntryPoint {
14620    private UserService userService;
14621
14622    void handle() {
14623        userService.find();
14624    }
14625}
14626
14627class UserService {
14628    void find() {}
14629}
14630
14631class AuditService {
14632    void find() {}
14633}
14634"#;
14635        let dir = tempdir().expect("temp dir");
14636        let root = dir.path();
14637        write_fixture(root, "src/EntryPoint.java", source);
14638        let reference = reference(
14639            "java",
14640            "src/EntryPoint.java",
14641            "EntryPoint::handle",
14642            "userService",
14643            "find",
14644            line_of(source, "userService.find()"),
14645        );
14646        let mut cache = DispatchSourceCache::new();
14647
14648        let receiver_type =
14649            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
14650        assert_eq!(receiver_type, "UserService");
14651
14652        let candidates = vec![
14653            method_candidate("audit", "AuditService::find"),
14654            method_candidate("user", "UserService::find"),
14655        ];
14656        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
14657            .expect("type candidate");
14658        assert_eq!(selected.scoped_name, "UserService::find");
14659
14660        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
14661        assert!(
14662            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
14663        );
14664    }
14665
14666    #[test]
14667    fn kotlin_property_and_local_value_types_are_inferred() {
14668        let source = r#"class Handler {
14669    private val auditService: AuditService = AuditService()
14670
14671    fun handle() {
14672        auditService.find()
14673        val userService: UserService = UserService()
14674        userService.find()
14675        val billingService = BillingService()
14676        billingService.find()
14677    }
14678}
14679
14680class UserService { fun find() {} }
14681class AuditService { fun find() {} }
14682class BillingService { fun find() {} }
14683"#;
14684        let dir = tempdir().expect("temp dir");
14685        let root = dir.path();
14686        write_fixture(root, "src/Handler.kt", source);
14687        let mut cache = DispatchSourceCache::new();
14688
14689        let audit_ref = reference(
14690            "kotlin",
14691            "src/Handler.kt",
14692            "Handler::handle",
14693            "auditService",
14694            "find",
14695            line_of(source, "auditService.find()"),
14696        );
14697        assert_eq!(
14698            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
14699            Some("AuditService")
14700        );
14701
14702        let user_ref = reference(
14703            "kotlin",
14704            "src/Handler.kt",
14705            "Handler::handle",
14706            "userService",
14707            "find",
14708            line_of(source, "userService.find()"),
14709        );
14710        assert_eq!(
14711            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
14712            Some("UserService")
14713        );
14714
14715        let billing_ref = reference(
14716            "kotlin",
14717            "src/Handler.kt",
14718            "Handler::handle",
14719            "billingService",
14720            "find",
14721            line_of(source, "billingService.find()"),
14722        );
14723        assert_eq!(
14724            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
14725            Some("BillingService")
14726        );
14727    }
14728
14729    #[test]
14730    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
14731        let source = r#"struct Foo { void run(); };
14732struct PointerFoo { void run(); };
14733struct FactoryFoo { void run(); };
14734FactoryFoo makeFactoryFoo();
14735
14736void handle() {
14737    Foo foo;
14738    foo.run();
14739    PointerFoo* pointerFoo = nullptr;
14740    pointerFoo->run();
14741    auto factoryFoo = makeFactoryFoo();
14742    factoryFoo.run();
14743}
14744"#;
14745        let dir = tempdir().expect("temp dir");
14746        let root = dir.path();
14747        write_fixture(root, "src/fixture.cpp", source);
14748        let mut cache = DispatchSourceCache::new();
14749
14750        let foo_ref = reference(
14751            "cpp",
14752            "src/fixture.cpp",
14753            "handle",
14754            "foo",
14755            "run",
14756            line_of(source, "foo.run()"),
14757        );
14758        assert_eq!(
14759            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
14760            Some("Foo")
14761        );
14762
14763        let pointer_ref = reference(
14764            "cpp",
14765            "src/fixture.cpp",
14766            "handle",
14767            "pointerFoo",
14768            "run",
14769            line_of(source, "pointerFoo->run()"),
14770        );
14771        assert_eq!(
14772            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
14773            Some("PointerFoo")
14774        );
14775
14776        let factory_ref = reference(
14777            "cpp",
14778            "src/fixture.cpp",
14779            "handle",
14780            "factoryFoo",
14781            "run",
14782            line_of(source, "factoryFoo.run()"),
14783        );
14784        assert_eq!(
14785            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
14786            Some("FactoryFoo")
14787        );
14788    }
14789
14790    #[test]
14791    fn rust_direct_self_field_name_trims_separator_whitespace() {
14792        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
14793            assert_eq!(
14794                rust_direct_self_field_name(receiver_expression),
14795                Some("engine")
14796            );
14797        }
14798    }
14799
14800    #[test]
14801    fn rust_direct_self_field_receiver_type_is_conservative() {
14802        let source = r#"struct Engine;
14803
14804struct Car {
14805    engine: Engine,
14806}
14807
14808impl Car {
14809    fn run(&self) {
14810        self.engine.start();
14811    }
14812}
14813
14814struct NestedCar {
14815    engine: Engine,
14816}
14817
14818impl NestedCar {
14819    fn run(&self) {
14820        self.inner.engine.start();
14821    }
14822}
14823
14824struct WrappedCar {
14825    engine: Option<Engine>,
14826}
14827
14828impl WrappedCar {
14829    fn run(&self) {
14830        self.engine.start(); // wrapped
14831    }
14832}
14833
14834struct GenericCar<T> {
14835    engine: T,
14836}
14837
14838impl<T> GenericCar<T> {
14839    fn run(&self) {
14840        self.engine.start(); // generic
14841    }
14842}
14843
14844type EngineAlias = Engine;
14845
14846struct AliasCar {
14847    engine: EngineAlias,
14848}
14849
14850impl AliasCar {
14851    fn run(&self) {
14852        self.engine.start(); // alias
14853    }
14854}
14855"#;
14856        let dir = tempdir().expect("temp dir");
14857        let root = dir.path();
14858        write_fixture(root, "src/lib.rs", source);
14859        let mut cache = DispatchSourceCache::new();
14860
14861        let mut direct = reference(
14862            "rust",
14863            "src/lib.rs",
14864            "Car::run",
14865            "engine",
14866            "start",
14867            line_of(source, "self.engine.start()"),
14868        );
14869        direct.receiver_expression = "self.engine".to_string();
14870        assert_eq!(
14871            infer_receiver_type(root, &direct, &mut cache).as_deref(),
14872            Some("Engine")
14873        );
14874
14875        let mut mismatched_impl_target = direct.clone();
14876        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
14877        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
14878
14879        let mut nested = reference(
14880            "rust",
14881            "src/lib.rs",
14882            "NestedCar::run",
14883            "engine",
14884            "start",
14885            line_of(source, "self.inner.engine.start()"),
14886        );
14887        nested.receiver_expression = "self.inner.engine".to_string();
14888        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
14889
14890        let mut wrapped = reference(
14891            "rust",
14892            "src/lib.rs",
14893            "WrappedCar::run",
14894            "engine",
14895            "start",
14896            line_of(source, "self.engine.start(); // wrapped"),
14897        );
14898        wrapped.receiver_expression = "self.engine".to_string();
14899        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
14900
14901        let mut generic = reference(
14902            "rust",
14903            "src/lib.rs",
14904            "GenericCar::run",
14905            "engine",
14906            "start",
14907            line_of(source, "self.engine.start(); // generic"),
14908        );
14909        generic.receiver_expression = "self.engine".to_string();
14910        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
14911
14912        let mut alias = reference(
14913            "rust",
14914            "src/lib.rs",
14915            "AliasCar::run",
14916            "engine",
14917            "start",
14918            line_of(source, "self.engine.start(); // alias"),
14919        );
14920        alias.receiver_expression = "self.engine".to_string();
14921        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
14922    }
14923
14924    #[test]
14925    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
14926        let source = r#"struct Engine;
14927
14928struct Car {
14929    engine: &'static Engine,
14930}
14931
14932impl Car {
14933    fn run(&self) {
14934        self.engine.start();
14935    }
14936}
14937"#;
14938        let dir = tempdir().expect("temp dir");
14939        let root = dir.path();
14940        write_fixture(root, "src/lib.rs", source);
14941        let mut cache = DispatchSourceCache::new();
14942        let mut reference = reference(
14943            "rust",
14944            "src/lib.rs",
14945            "Car::run",
14946            "engine",
14947            "start",
14948            line_of(source, "self.engine.start()"),
14949        );
14950        reference.receiver_expression = "self.engine".to_string();
14951
14952        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
14953    }
14954
14955    #[test]
14956    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
14957        let source = r#"trait Drive {
14958    fn run(&self);
14959}
14960
14961struct Engine;
14962
14963struct Car {
14964    engine: Engine,
14965}
14966
14967impl Drive for Car {
14968    fn run(&self) {
14969        self.engine.start();
14970    }
14971}
14972"#;
14973        let dir = tempdir().expect("temp dir");
14974        let root = dir.path();
14975        write_fixture(root, "src/lib.rs", source);
14976        let mut cache = DispatchSourceCache::new();
14977        let mut reference = reference(
14978            "rust",
14979            "src/lib.rs",
14980            "Car::run",
14981            "engine",
14982            "start",
14983            line_of(source, "self.engine.start()"),
14984        );
14985        reference.receiver_expression = "self.engine".to_string();
14986
14987        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
14988    }
14989
14990    #[test]
14991    fn rust_self_field_does_not_bind_struct_from_another_module() {
14992        let source = r#"struct Engine;
14993
14994mod unrelated {
14995    struct Car {
14996        engine: Engine,
14997    }
14998}
14999
15000impl Car {
15001    fn run(&self) {
15002        self.engine.start();
15003    }
15004}
15005"#;
15006        let dir = tempdir().expect("temp dir");
15007        let root = dir.path();
15008        write_fixture(root, "src/lib.rs", source);
15009        let mut cache = DispatchSourceCache::new();
15010        let mut reference = reference(
15011            "rust",
15012            "src/lib.rs",
15013            "Car::run",
15014            "engine",
15015            "start",
15016            line_of(source, "self.engine.start()"),
15017        );
15018        reference.receiver_expression = "self.engine".to_string();
15019
15020        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15021    }
15022
15023    #[test]
15024    fn unknown_java_receiver_still_uses_name_match_fallback() {
15025        let source = r#"class EntryPoint {
15026    void handle() {
15027        service.runSpecial();
15028    }
15029}
15030
15031class OnlyService {
15032    void runSpecial() {}
15033}
15034"#;
15035        let dir = tempdir().expect("temp dir");
15036        let root = dir.path();
15037        write_fixture(root, "src/EntryPoint.java", source);
15038        let reference = reference(
15039            "java",
15040            "src/EntryPoint.java",
15041            "EntryPoint::handle",
15042            "service",
15043            "runSpecial",
15044            line_of(source, "service.runSpecial()"),
15045        );
15046        let mut cache = DispatchSourceCache::new();
15047
15048        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
15049        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
15050        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
15051        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
15052    }
15053
15054    fn reference(
15055        lang: &str,
15056        caller_file: &str,
15057        caller_symbol: &str,
15058        receiver: &str,
15059        method_name: &str,
15060        line: u32,
15061    ) -> NameMatchRef {
15062        NameMatchRef {
15063            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
15064            caller_node: format!("{caller_symbol}:node"),
15065            caller_file: caller_file.to_string(),
15066            caller_symbol: caller_symbol.to_string(),
15067            caller_signature: None,
15068            receiver_expression: receiver.to_string(),
15069            receiver: receiver.to_string(),
15070            method_name: method_name.to_string(),
15071            colon_dispatch: false,
15072            line,
15073            lang: lang.to_string(),
15074        }
15075    }
15076
15077    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
15078        NameMatchCandidate {
15079            node_id: node_id.to_string(),
15080            file_path: "src/targets.fixture".to_string(),
15081            scoped_name: scoped_name.to_string(),
15082            kind: "method".to_string(),
15083            start_line: 1,
15084        }
15085    }
15086
15087    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
15088        let path = root.join(rel_path);
15089        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
15090        fs::write(path, source).expect("write fixture");
15091    }
15092
15093    fn line_of(source: &str, needle: &str) -> u32 {
15094        source
15095            .lines()
15096            .position(|line| line.contains(needle))
15097            .map(|index| index as u32 + 1)
15098            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
15099    }
15100}