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, 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);
51
52type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
53#[cfg(test)]
54type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
55// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
56// thread running the cold build, and the only caller (a test) installs and
57// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
58// across parallel tests — one test's installed observer fired during ANOTHER
59// test's `cold_build_with_lease`, asserting against the wrong build's edges
60// (flaked on Windows CI under parallel scheduling). Production never sets it.
61thread_local! {
62    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
63        const { std::cell::RefCell::new(None) };
64    #[cfg(test)]
65    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
66        const { std::cell::RefCell::new(None) };
67    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
68        const { std::cell::RefCell::new(None) };
69    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
70    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
71        const { std::cell::Cell::new(false) };
72    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
73        const { std::cell::RefCell::new(None) };
74    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
75        const { std::cell::RefCell::new(None) };
76}
77
78mod dead_code_projection;
79pub use dead_code_projection::project_dead_code_snapshot;
80
81#[doc(hidden)]
82pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
83    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
84}
85
86#[cfg(test)]
87fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
88    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
89}
90
91#[cfg(test)]
92fn notify_cold_build_before_publish_observer() {
93    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
94    if let Some(observer) = observer {
95        observer();
96    }
97}
98
99#[cfg(not(test))]
100fn notify_cold_build_before_publish_observer() {}
101
102#[doc(hidden)]
103pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
104    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
105}
106
107#[doc(hidden)]
108pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
109    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
110}
111
112#[doc(hidden)]
113pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
114    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
115}
116
117struct PublishAdmissionGuard {
118    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
119}
120
121impl Drop for PublishAdmissionGuard {
122    fn drop(&mut self) {
123        PUBLISH_ADMISSION.with(|slot| {
124            *slot.borrow_mut() = self.previous.take();
125        });
126    }
127}
128
129pub(crate) fn with_publish_epoch<R>(
130    epoch: crate::root_cache::ArtifactPublishEpoch,
131    expected: u64,
132    run: impl FnOnce() -> R,
133) -> R {
134    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
135    let _guard = PublishAdmissionGuard { previous };
136    run()
137}
138
139fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
140    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
141    match admission {
142        Some((epoch, expected)) => epoch
143            .run_if_current(expected, publish)
144            .unwrap_or(Err(CallGraphStoreError::Superseded)),
145        None => publish(),
146    }
147}
148
149struct RefreshCommitAdmissionGuard {
150    previous: Option<(
151        SubcLifecycleAdmission,
152        Arc<std::sync::atomic::AtomicU64>,
153        u64,
154    )>,
155}
156
157impl Drop for RefreshCommitAdmissionGuard {
158    fn drop(&mut self) {
159        REFRESH_COMMIT_ADMISSION.with(|slot| {
160            *slot.borrow_mut() = self.previous.take();
161        });
162    }
163}
164
165fn with_refresh_commit_admission<R>(
166    lifecycle: SubcLifecycleAdmission,
167    generation_flag: Arc<std::sync::atomic::AtomicU64>,
168    expected_generation: u64,
169    run: impl FnOnce() -> R,
170) -> R {
171    let previous = REFRESH_COMMIT_ADMISSION
172        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
173    let _guard = RefreshCommitAdmissionGuard { previous };
174    run()
175}
176
177fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
178    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
179    let commit = || {
180        publish_if_current(|| {
181            tx.commit()?;
182            Ok(())
183        })
184    };
185    match admission {
186        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
187            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
188            .unwrap_or(Err(CallGraphStoreError::Superseded)),
189        None => commit(),
190    }
191}
192
193fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
194    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
195    if let Some(observer) = observer {
196        observer(temp_path, target_path);
197    }
198}
199
200#[derive(Debug)]
201pub enum CallGraphStoreError {
202    Io(std::io::Error),
203    Sqlite(rusqlite::Error),
204    Json(serde_json::Error),
205    Aft(AftError),
206    Lock(crate::fs_lock::AcquireError),
207    MissingCallerData { file: String },
208    Unavailable(String),
209    Superseded,
210    StaleFiles(Vec<String>),
211}
212
213impl fmt::Display for CallGraphStoreError {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            Self::Io(error) => write!(formatter, "I/O error: {error}"),
217            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
218            Self::Json(error) => write!(formatter, "json error: {error}"),
219            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
220            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
221            Self::MissingCallerData { file } => {
222                write!(formatter, "missing extracted caller data for {file}")
223            }
224            Self::Unavailable(message) => {
225                write!(formatter, "callgraph store unavailable: {message}")
226            }
227            Self::Superseded => {
228                write!(formatter, "callgraph store build superseded before publish")
229            }
230            Self::StaleFiles(files) => {
231                write!(
232                    formatter,
233                    "callgraph store has stale files: {}",
234                    files.join(", ")
235                )
236            }
237        }
238    }
239}
240
241impl std::error::Error for CallGraphStoreError {}
242
243impl From<std::io::Error> for CallGraphStoreError {
244    fn from(error: std::io::Error) -> Self {
245        Self::Io(error)
246    }
247}
248
249impl From<rusqlite::Error> for CallGraphStoreError {
250    fn from(error: rusqlite::Error) -> Self {
251        Self::Sqlite(error)
252    }
253}
254
255impl From<serde_json::Error> for CallGraphStoreError {
256    fn from(error: serde_json::Error) -> Self {
257        Self::Json(error)
258    }
259}
260
261impl From<AftError> for CallGraphStoreError {
262    fn from(error: AftError) -> Self {
263        Self::Aft(error)
264    }
265}
266
267impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
268    fn from(error: crate::fs_lock::AcquireError) -> Self {
269        Self::Lock(error)
270    }
271}
272
273pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
274
275/// Config flag name gating whether the store is opened (default on). Production
276/// commands open it through `open_if_enabled` so the substrate can be disabled
277/// without code changes.
278pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
279
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub struct CallGraphStoreOptions {
282    pub enabled: bool,
283}
284
285pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
286
287#[derive(Clone, Debug, Hash, PartialEq, Eq)]
288struct RefreshRoot {
289    callgraph_dir: PathBuf,
290    project_root: PathBuf,
291}
292
293#[derive(Clone)]
294pub(crate) struct CallgraphRefreshTicket {
295    lifecycle: SubcLifecycleAdmission,
296    generation_flag: Arc<std::sync::atomic::AtomicU64>,
297    expected_generation: u64,
298    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
299    expected_publish_epoch: u64,
300}
301
302impl CallgraphRefreshTicket {
303    pub(crate) fn new(
304        lifecycle: SubcLifecycleAdmission,
305        generation_flag: Arc<std::sync::atomic::AtomicU64>,
306        expected_generation: u64,
307        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
308        expected_publish_epoch: u64,
309    ) -> Self {
310        Self {
311            lifecycle,
312            generation_flag,
313            expected_generation,
314            publish_epoch,
315            expected_publish_epoch,
316        }
317    }
318
319    fn is_current(&self) -> bool {
320        self.lifecycle
321            .is_current(self.generation_flag.as_ref(), self.expected_generation)
322            && self.publish_epoch.current() == self.expected_publish_epoch
323    }
324}
325
326#[derive(Clone)]
327struct RefreshBatch {
328    root: RefreshRoot,
329    paths: BTreeSet<PathBuf>,
330    pending_sinks: Vec<PendingCallGraphStorePaths>,
331    ticket: Option<CallgraphRefreshTicket>,
332}
333
334impl RefreshBatch {
335    fn defer(&self) {
336        for sink in &self.pending_sinks {
337            sink.lock().extend(self.paths.iter().cloned());
338        }
339    }
340
341    fn merge(
342        &mut self,
343        paths: impl IntoIterator<Item = PathBuf>,
344        sink: PendingCallGraphStorePaths,
345        ticket: Option<CallgraphRefreshTicket>,
346    ) {
347        self.paths.extend(paths);
348        if ticket.is_some() {
349            self.ticket = ticket;
350        }
351        if !self
352            .pending_sinks
353            .iter()
354            .any(|existing| Arc::ptr_eq(existing, &sink))
355        {
356            self.pending_sinks.push(sink);
357        }
358    }
359}
360
361#[derive(Default)]
362struct RefreshQueue {
363    order: VecDeque<RefreshRoot>,
364    queued: HashMap<RefreshRoot, RefreshBatch>,
365    active: Option<RefreshBatch>,
366    shutdown_requested: bool,
367}
368
369struct RefreshWorkerShared {
370    queue: Mutex<RefreshQueue>,
371    wake: Condvar,
372}
373
374struct RefreshWorker {
375    shared: Arc<RefreshWorkerShared>,
376    thread: Mutex<Option<JoinHandle<()>>>,
377}
378
379struct RefreshWorkerWatchdog {
380    first_path: PathBuf,
381    batch_len: usize,
382    started: Instant,
383}
384
385impl RefreshWorkerWatchdog {
386    fn start(paths: &[PathBuf]) -> Self {
387        Self {
388            first_path: paths
389                .first()
390                .expect("non-empty callgraph refresh batch has a first path")
391                .clone(),
392            batch_len: paths.len(),
393            started: Instant::now(),
394        }
395    }
396}
397
398impl Drop for RefreshWorkerWatchdog {
399    fn drop(&mut self) {
400        let elapsed = self.started.elapsed();
401        if elapsed < REFRESH_WORKER_WARN_AFTER {
402            return;
403        }
404        let path = if self.batch_len == 1 {
405            self.first_path.display().to_string()
406        } else {
407            format!(
408                "{} (+{} paths)",
409                self.first_path.display(),
410                self.batch_len - 1
411            )
412        };
413        log::warn!(
414            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
415            path,
416            elapsed.as_millis()
417        );
418        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
419            log::warn!(
420                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
421                path,
422                elapsed.as_millis()
423            );
424        }
425    }
426}
427
428impl RefreshWorker {
429    fn spawn() -> Arc<Self> {
430        let shared = Arc::new(RefreshWorkerShared {
431            queue: Mutex::new(RefreshQueue::default()),
432            wake: Condvar::new(),
433        });
434        let thread_shared = Arc::clone(&shared);
435        let thread = std::thread::Builder::new()
436            .name("aft-callgraph-refresh".to_string())
437            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
438            .expect("failed to spawn callgraph refresh worker");
439        Arc::new(Self {
440            shared,
441            thread: Mutex::new(Some(thread)),
442        })
443    }
444
445    fn enqueue(
446        &self,
447        root: RefreshRoot,
448        paths: Vec<PathBuf>,
449        pending_sink: PendingCallGraphStorePaths,
450        ticket: Option<CallgraphRefreshTicket>,
451    ) -> bool {
452        let mut queue = self
453            .shared
454            .queue
455            .lock()
456            .expect("callgraph refresh queue mutex poisoned");
457        if queue.shutdown_requested {
458            pending_sink.lock().extend(paths);
459            return false;
460        }
461        if let Some(batch) = queue.queued.get_mut(&root) {
462            batch.merge(paths, pending_sink, ticket);
463        } else {
464            queue.order.push_back(root.clone());
465            queue.queued.insert(
466                root.clone(),
467                RefreshBatch {
468                    root,
469                    paths: paths.into_iter().collect(),
470                    pending_sinks: vec![pending_sink],
471                    ticket,
472                },
473            );
474        }
475        self.shared.wake.notify_one();
476        true
477    }
478
479    fn shutdown_with_budget(&self, budget: Duration) -> bool {
480        let deadline = Instant::now() + budget;
481        let mut queue = self
482            .shared
483            .queue
484            .lock()
485            .expect("callgraph refresh queue mutex poisoned");
486        queue.shutdown_requested = true;
487        self.shared.wake.notify_one();
488        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
489            let remaining = deadline.saturating_duration_since(Instant::now());
490            let (next, _) = self
491                .shared
492                .wake
493                .wait_timeout(queue, remaining)
494                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
495            queue = next;
496        }
497        let drained = queue.active.is_none() && queue.order.is_empty();
498        if !drained {
499            if let Some(active) = queue.active.as_ref() {
500                active.defer();
501            }
502            for batch in queue.queued.values() {
503                batch.defer();
504            }
505            queue.order.clear();
506            queue.queued.clear();
507        }
508        drop(queue);
509
510        if drained {
511            if let Some(thread) = self
512                .thread
513                .lock()
514                .expect("callgraph refresh worker thread mutex poisoned")
515                .take()
516            {
517                let _ = thread.join();
518            }
519        }
520        drained
521    }
522}
523
524static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
525
526pub fn enqueue_callgraph_store_refresh(
527    callgraph_dir: PathBuf,
528    project_root: PathBuf,
529    paths: Vec<PathBuf>,
530    pending_sink: PendingCallGraphStorePaths,
531) -> bool {
532    enqueue_callgraph_store_refresh_inner(callgraph_dir, project_root, paths, pending_sink, None)
533}
534
535pub(crate) fn enqueue_callgraph_store_refresh_fenced(
536    callgraph_dir: PathBuf,
537    project_root: PathBuf,
538    paths: Vec<PathBuf>,
539    pending_sink: PendingCallGraphStorePaths,
540    ticket: CallgraphRefreshTicket,
541) -> bool {
542    enqueue_callgraph_store_refresh_inner(
543        callgraph_dir,
544        project_root,
545        paths,
546        pending_sink,
547        Some(ticket),
548    )
549}
550
551fn enqueue_callgraph_store_refresh_inner(
552    callgraph_dir: PathBuf,
553    project_root: PathBuf,
554    paths: Vec<PathBuf>,
555    pending_sink: PendingCallGraphStorePaths,
556    ticket: Option<CallgraphRefreshTicket>,
557) -> bool {
558    if paths.is_empty() {
559        return true;
560    }
561    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
562    let worker = {
563        let mut worker = slot
564            .lock()
565            .expect("callgraph refresh worker mutex poisoned");
566        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
567    };
568    worker.enqueue(
569        RefreshRoot {
570            callgraph_dir,
571            project_root,
572        },
573        paths,
574        pending_sink,
575        ticket,
576    )
577}
578
579pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
580    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
581}
582
583#[doc(hidden)]
584pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
585    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
586    let worker = slot
587        .lock()
588        .expect("callgraph refresh worker mutex poisoned")
589        .clone();
590    let Some(worker) = worker else {
591        return true;
592    };
593    let drained = worker.shutdown_with_budget(budget);
594    if drained {
595        let mut current = slot
596            .lock()
597            .expect("callgraph refresh worker mutex poisoned");
598        if current
599            .as_ref()
600            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
601        {
602            *current = None;
603        }
604    }
605    drained
606}
607
608fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
609    loop {
610        let batch = {
611            let mut queue = shared
612                .queue
613                .lock()
614                .expect("callgraph refresh queue mutex poisoned");
615            loop {
616                if let Some(root) = queue.order.pop_front() {
617                    let batch = queue
618                        .queued
619                        .remove(&root)
620                        .expect("queued callgraph refresh root has a batch");
621                    queue.active = Some(batch.clone());
622                    break batch;
623                }
624                if queue.shutdown_requested {
625                    return;
626                }
627                queue = shared
628                    .wake
629                    .wait(queue)
630                    .expect("callgraph refresh queue mutex poisoned while waiting");
631            }
632        };
633
634        process_callgraph_refresh_batch(&batch);
635
636        let mut queue = shared
637            .queue
638            .lock()
639            .expect("callgraph refresh queue mutex poisoned");
640        queue.active = None;
641        shared.wake.notify_all();
642    }
643}
644
645fn process_callgraph_refresh_batch(batch: &RefreshBatch) {
646    if batch
647        .ticket
648        .as_ref()
649        .is_some_and(|ticket| !ticket.is_current())
650    {
651        // Superseded before starting: park the paths so the next configure's
652        // pending replay (or unbind cleanup) decides their fate.
653        batch.defer();
654        return;
655    }
656    let paths = batch.paths.iter().cloned().collect::<Vec<_>>();
657    let _watchdog = RefreshWorkerWatchdog::start(&paths);
658    let store = match CallGraphStore::open_ready(
659        batch.root.callgraph_dir.clone(),
660        batch.root.project_root.clone(),
661    ) {
662        Ok(Some(store)) => store,
663        Ok(None) => {
664            batch.defer();
665            return;
666        }
667        Err(error) => {
668            batch.defer();
669            crate::slog_warn!(
670                "callgraph store writer open failed during refresh; deferred paths: {}",
671                error
672            );
673            return;
674        }
675    };
676
677    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
678    note_refresh_worker_call_for_test(&batch.root.project_root);
679    #[cfg(test)]
680    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
681        let _ = gate.held_tx.send(());
682        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
683    }
684    if !test_seam.delay.is_zero() {
685        std::thread::sleep(test_seam.delay);
686    }
687    if batch
688        .ticket
689        .as_ref()
690        .is_some_and(|ticket| !ticket.is_current())
691    {
692        batch.defer();
693        return;
694    }
695    let refresh_result = if test_seam.fail_refresh {
696        Err(CallGraphStoreError::Unavailable(
697            "injected refresh worker failure".to_string(),
698        ))
699    } else if let Some(ticket) = &batch.ticket {
700        with_publish_epoch(
701            ticket.publish_epoch.clone(),
702            ticket.expected_publish_epoch,
703            || {
704                with_refresh_commit_admission(
705                    ticket.lifecycle.clone(),
706                    Arc::clone(&ticket.generation_flag),
707                    ticket.expected_generation,
708                    || store.refresh_files(&paths).map(|_| ()),
709                )
710            },
711        )
712    } else {
713        store.refresh_files(&paths).map(|_| ())
714    };
715    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
716        // The commit lost the fence race: a newer configure or publication
717        // owns the store now. Defer instead of stale-marking — the paths were
718        // never committed, and the replacement generation re-indexes them.
719        batch.defer();
720        return;
721    }
722    if let Err(error) = refresh_result {
723        crate::slog_warn!("callgraph store refresh failed: {}", error);
724        match store.mark_files_stale(&paths) {
725            Ok(marked) => {
726                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
727                crate::slog_warn!(
728                    "marked {} callgraph store file(s) stale after refresh failure",
729                    marked.len()
730                );
731            }
732            Err(mark_error) => crate::slog_warn!(
733                "failed to mark callgraph store files stale after refresh failure: {}",
734                mark_error
735            ),
736        }
737    } else {
738        crate::logging::note_callgraph_invalidations(paths.len());
739    }
740}
741
742#[derive(Clone, Copy, Default)]
743struct RefreshWorkerTestSeam {
744    delay: Duration,
745    fail_refresh: bool,
746    refresh_calls: usize,
747    stale_marks: usize,
748}
749
750static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
751    OnceLock::new();
752
753#[cfg(test)]
754struct RefreshWorkerTestGate {
755    held_tx: crossbeam_channel::Sender<()>,
756    release_rx: crossbeam_channel::Receiver<()>,
757}
758
759#[cfg(test)]
760static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
761    OnceLock::new();
762
763#[cfg(test)]
764fn install_refresh_worker_test_gate(
765    project_root: PathBuf,
766) -> (
767    crossbeam_channel::Receiver<()>,
768    crossbeam_channel::Sender<()>,
769) {
770    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
771    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
772    REFRESH_WORKER_TEST_GATES
773        .get_or_init(|| Mutex::new(HashMap::new()))
774        .lock()
775        .expect("callgraph refresh test gate mutex poisoned")
776        .insert(
777            project_root,
778            RefreshWorkerTestGate {
779                held_tx,
780                release_rx,
781            },
782        );
783    (held_rx, release_tx)
784}
785
786#[cfg(test)]
787fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
788    REFRESH_WORKER_TEST_GATES
789        .get_or_init(|| Mutex::new(HashMap::new()))
790        .lock()
791        .expect("callgraph refresh test gate mutex poisoned")
792        .remove(project_root)
793}
794
795fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
796    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
797        return RefreshWorkerTestSeam::default();
798    };
799    seams
800        .lock()
801        .expect("callgraph refresh test seam mutex poisoned")
802        .get(project_root)
803        .copied()
804        .unwrap_or_default()
805}
806
807fn note_refresh_worker_call_for_test(project_root: &Path) {
808    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
809        if let Some(seam) = seams
810            .lock()
811            .expect("callgraph refresh test seam mutex poisoned")
812            .get_mut(project_root)
813        {
814            seam.refresh_calls += 1;
815        }
816    }
817}
818
819fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
820    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
821        if let Some(seam) = seams
822            .lock()
823            .expect("callgraph refresh test seam mutex poisoned")
824            .get_mut(project_root)
825        {
826            seam.stale_marks += 1;
827        }
828    }
829}
830
831#[doc(hidden)]
832pub fn set_callgraph_refresh_worker_test_seam(
833    project_root: PathBuf,
834    delay: Duration,
835    fail_refresh: bool,
836) {
837    REFRESH_WORKER_TEST_SEAMS
838        .get_or_init(|| Mutex::new(HashMap::new()))
839        .lock()
840        .expect("callgraph refresh test seam mutex poisoned")
841        .insert(
842            project_root,
843            RefreshWorkerTestSeam {
844                delay,
845                fail_refresh,
846                ..RefreshWorkerTestSeam::default()
847            },
848        );
849}
850
851#[doc(hidden)]
852pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
853    let seam = refresh_worker_test_seam(project_root);
854    (seam.refresh_calls, seam.stale_marks)
855}
856
857#[doc(hidden)]
858pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
859    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
860        seams
861            .lock()
862            .expect("callgraph refresh test seam mutex poisoned")
863            .remove(project_root);
864    }
865}
866
867#[derive(Debug)]
868pub struct CallGraphStore {
869    project_root: PathBuf,
870    project_key: String,
871    /// The concrete on-disk DB file this store opened. With the generation
872    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
873    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
874    sqlite_path: PathBuf,
875    /// Root-keyed directory whose pointer controls this store. For a legacy
876    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
877    /// newly published root-keyed generation invalidates the fallback reader.
878    publication_dir: PathBuf,
879    /// True only when the root-keyed read path opened data from a legacy
880    /// harness partition. Writer-capable callers use this to schedule migration
881    /// without making read-only/worktree callers acquire a writer lease.
882    legacy_fallback: bool,
883    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
884    /// or `None` when it opened the legacy single-file DB. Used to detect when
885    /// another process has published a newer generation so this process can
886    /// drop its connection and reopen (see `current_generation`).
887    generation: Option<String>,
888    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
889    read_marker: Option<crate::root_cache::ReadMarker>,
890    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
891    // Failed validations are not cached, so a later successful build remains visible.
892    database_ready: AtomicBool,
893    conn: Mutex<Connection>,
894}
895
896#[derive(Debug)]
897pub struct ReadonlyCallGraphStore {
898    inner: CallGraphStore,
899}
900
901pub trait CallGraphRead {
902    fn project_root(&self) -> &Path;
903    fn project_key(&self) -> &str;
904    fn sqlite_path(&self) -> &Path;
905    fn is_current(&self) -> bool;
906    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
907    fn indexed_file_count(&self) -> Result<usize>;
908    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
909    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
910    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
911    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
912    fn direct_caller_counts_of(
913        &self,
914        targets: &[(String, String)],
915    ) -> Result<HashMap<(String, String), usize>>;
916    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
917        -> Result<StoreCallersResult>;
918    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
919    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
920    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
921    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
922    fn call_tree(
923        &self,
924        file_rel: &Path,
925        symbol: &str,
926        depth: usize,
927    ) -> Result<callgraph::CallTreeNode>;
928    fn trace_to(
929        &self,
930        file_rel: &Path,
931        symbol: &str,
932        max_depth: usize,
933    ) -> Result<callgraph::TraceToResult>;
934    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
935    fn trace_to_symbol(
936        &self,
937        file_rel: &Path,
938        symbol: &str,
939        to_symbol: &str,
940        to_file: Option<&Path>,
941        max_depth: usize,
942    ) -> Result<callgraph::TraceToSymbolResult>;
943}
944
945#[derive(Debug, Clone, PartialEq, Eq)]
946enum OpenRootRepair {
947    None,
948    ReRooted,
949    NeedsRebuild {
950        previous_roots: Vec<String>,
951        current_root: String,
952        reason: String,
953    },
954}
955
956struct OpenedStore {
957    store: CallGraphStore,
958    root_repair: OpenRootRepair,
959}
960
961#[derive(Clone, Debug)]
962struct LegacyCallgraphPartition {
963    harness: String,
964    dir: PathBuf,
965    key: String,
966    bytes: u64,
967    freshness: Option<SystemTime>,
968}
969
970#[derive(Clone, Debug)]
971struct LegacyCallgraphTarget {
972    partition: LegacyCallgraphPartition,
973    sqlite_path: PathBuf,
974    generation: Option<String>,
975    source_bytes: u64,
976    source_blake3: String,
977}
978
979#[derive(Clone, Debug)]
980struct SourceFingerprint {
981    bytes: u64,
982    blake3: String,
983}
984
985#[derive(Clone, Debug)]
986struct PublishedLegacyMigration {
987    generation: String,
988    migrated_bytes: u64,
989}
990
991#[derive(Debug, Clone)]
992pub struct ColdBuildStats {
993    pub files: usize,
994    pub nodes: usize,
995    pub refs: usize,
996    pub edges: usize,
997    pub failed_files: Vec<String>,
998    pub elapsed_ms: u128,
999}
1000
1001#[derive(Debug, Clone)]
1002pub struct IncrementalStats {
1003    pub changed_files: Vec<String>,
1004    pub surface_changed: Vec<String>,
1005    pub deleted_files: Vec<String>,
1006    pub dependency_selected_refs: usize,
1007    pub refreshed_own_files: usize,
1008}
1009
1010/// Phase timings for the copy-based incremental refresh benchmark.
1011#[doc(hidden)]
1012#[derive(Debug, Clone, Default, PartialEq, Eq)]
1013pub struct RefreshFilesProfile {
1014    pub parse: Duration,
1015    pub dependency_selection: Duration,
1016    pub row_deletes: Duration,
1017    pub row_inserts: Duration,
1018    pub dependent_parse: Duration,
1019    pub index_load: Duration,
1020    pub ref_resolution: Duration,
1021    pub method_dispatch: Duration,
1022    pub commit: Duration,
1023    pub total: Duration,
1024}
1025
1026impl RefreshFilesProfile {
1027    pub fn report(&self) -> String {
1028        format!(
1029            "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",
1030            self.parse.as_millis(),
1031            self.dependency_selection.as_millis(),
1032            self.row_deletes.as_millis(),
1033            self.row_inserts.as_millis(),
1034            self.dependent_parse.as_millis(),
1035            self.index_load.as_millis(),
1036            self.ref_resolution.as_millis(),
1037            self.method_dispatch.as_millis(),
1038            self.commit.as_millis(),
1039            self.total.as_millis(),
1040        )
1041    }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1045pub struct StoredEdge {
1046    pub source_file: String,
1047    pub source_symbol: String,
1048    pub target_file: String,
1049    pub target_symbol: String,
1050    pub kind: String,
1051    pub line: u32,
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Eq)]
1055pub struct StoreNode {
1056    node_id: String,
1057    pub file: String,
1058    pub symbol: String,
1059    pub name: String,
1060    pub kind: String,
1061    pub line: u32,
1062    pub end_line: u32,
1063    pub signature: Option<String>,
1064    pub exported: bool,
1065    pub is_entry_point: bool,
1066    pub lang: LangId,
1067}
1068
1069#[cfg(test)]
1070impl StoreNode {
1071    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1072        Self {
1073            node_id: format!("{file}:{symbol}"),
1074            file: file.to_string(),
1075            symbol: symbol.to_string(),
1076            name: symbol.to_string(),
1077            kind: "function".to_string(),
1078            line: 1,
1079            end_line: 1,
1080            signature: None,
1081            exported: is_entry_point,
1082            is_entry_point,
1083            lang: LangId::TypeScript,
1084        }
1085    }
1086}
1087
1088#[derive(Debug, Clone, PartialEq, Eq)]
1089pub struct StoreCallSite {
1090    pub caller: StoreNode,
1091    pub target_file: String,
1092    pub target_symbol: String,
1093    pub target: Option<StoreNode>,
1094    pub line: u32,
1095    pub byte_start: usize,
1096    pub byte_end: usize,
1097    pub resolved: bool,
1098    pub provenance: String,
1099}
1100
1101impl StoreCallSite {
1102    pub fn approximate(&self) -> bool {
1103        self.provenance == PROVENANCE_NAME_MATCH
1104    }
1105
1106    pub fn resolved_by(&self) -> &str {
1107        &self.provenance
1108    }
1109
1110    pub fn supplemental_resolution(&self) -> Option<&str> {
1111        match self.provenance.as_str() {
1112            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1113            _ => None,
1114        }
1115    }
1116}
1117
1118#[derive(Debug, Clone, PartialEq, Eq)]
1119pub struct StoreUnresolvedCall {
1120    pub caller: StoreNode,
1121    pub symbol: String,
1122    pub full_ref: Option<String>,
1123    pub line: u32,
1124    pub byte_start: usize,
1125    pub byte_end: usize,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Eq)]
1129pub struct StoreCallersResult {
1130    pub target: StoreNode,
1131    pub callers: Vec<StoreCallSite>,
1132    pub scanned_files: usize,
1133    pub depth_limited: bool,
1134    pub truncated: usize,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq)]
1138pub struct StoreImpactCaller {
1139    pub site: StoreCallSite,
1140    pub signature: Option<String>,
1141    pub is_entry_point: bool,
1142    pub call_expression: Option<String>,
1143    pub parameters: Vec<String>,
1144}
1145
1146#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct StoreImpactResult {
1148    pub target: StoreNode,
1149    pub parameters: Vec<String>,
1150    pub callers: Vec<StoreImpactCaller>,
1151    pub depth_limited: bool,
1152    pub truncated: usize,
1153}
1154
1155#[derive(Debug, Clone)]
1156struct ExtractFailure {
1157    rel_path: String,
1158    freshness: Option<FileFreshness>,
1159}
1160
1161#[derive(Debug, Clone)]
1162struct BuildExtractsResult {
1163    extracts: Vec<FileExtract>,
1164    failures: Vec<ExtractFailure>,
1165}
1166
1167#[derive(Debug, Clone)]
1168enum StoreForwardCall {
1169    Resolved(StoreCallSite),
1170    Unresolved(StoreUnresolvedCall),
1171}
1172
1173impl StoreForwardCall {
1174    fn byte_start(&self) -> usize {
1175        match self {
1176            Self::Resolved(site) => site.byte_start,
1177            Self::Unresolved(call) => call.byte_start,
1178        }
1179    }
1180
1181    fn line(&self) -> u32 {
1182        match self {
1183            Self::Resolved(site) => site.line,
1184            Self::Unresolved(call) => call.line,
1185        }
1186    }
1187}
1188
1189#[derive(Debug, Clone)]
1190struct FileExtract {
1191    rel_path: String,
1192    freshness: FileFreshness,
1193    lang: LangId,
1194    data: FileCallData,
1195    nodes: Vec<NodeRecord>,
1196    raw_refs: Vec<RawRef>,
1197    dispatch_hints: Vec<DispatchHint>,
1198    surface_fingerprint: String,
1199}
1200
1201#[derive(Debug, Clone)]
1202struct NodeRecord {
1203    id: String,
1204    file_path: String,
1205    name: String,
1206    scoped_name: String,
1207    kind: String,
1208    range: Range,
1209    range_ordinal: u32,
1210    signature: Option<String>,
1211    exported: bool,
1212    is_default_export: bool,
1213    is_type_like: bool,
1214    is_callgraph_entry_point: bool,
1215}
1216
1217#[derive(Debug, Clone)]
1218struct RawRef {
1219    ref_id: String,
1220    caller_node: Option<String>,
1221    caller_symbol: Option<String>,
1222    caller_file: String,
1223    kind: String,
1224    short_name: Option<String>,
1225    full_ref: Option<String>,
1226    module_path: Option<String>,
1227    import_kind: Option<String>,
1228    local_name: Option<String>,
1229    requested_name: Option<String>,
1230    namespace_alias: Option<String>,
1231    wildcard: bool,
1232    line: u32,
1233    byte_start: usize,
1234    byte_end: usize,
1235    dependencies: BTreeSet<String>,
1236}
1237
1238#[derive(Debug, Clone)]
1239struct ResolvedRef {
1240    raw: RawRef,
1241    status: String,
1242    target_node: Option<String>,
1243    target_file: Option<String>,
1244    target_symbol: Option<String>,
1245    dependencies: BTreeSet<String>,
1246    edge: Option<EdgeRecord>,
1247}
1248
1249#[derive(Debug, Clone)]
1250struct EdgeRecord {
1251    edge_id: String,
1252    source_node: String,
1253    target_node: Option<String>,
1254    target_file: String,
1255    target_symbol: String,
1256    kind: String,
1257    line: u32,
1258}
1259
1260#[derive(Debug, Clone)]
1261struct DispatchHint {
1262    id: String,
1263    method_name: String,
1264    caller_node: String,
1265    file: String,
1266    line: u32,
1267    byte_start: usize,
1268    byte_end: usize,
1269}
1270
1271#[derive(Debug, Clone)]
1272struct NameMatchRef {
1273    ref_id: String,
1274    caller_node: String,
1275    caller_file: String,
1276    caller_symbol: String,
1277    caller_signature: Option<String>,
1278    receiver: String,
1279    method_name: String,
1280    colon_dispatch: bool,
1281    line: u32,
1282    lang: String,
1283}
1284
1285#[derive(Debug, Clone)]
1286struct NameMatchCandidate {
1287    node_id: String,
1288    file_path: String,
1289    scoped_name: String,
1290    kind: String,
1291}
1292
1293#[derive(Debug, Clone)]
1294struct FileRow {
1295    surface_fingerprint: String,
1296    freshness: FileFreshness,
1297}
1298
1299#[derive(Debug, Clone)]
1300struct DbFileIndex {
1301    lang: Option<LangId>,
1302    exports: HashSet<String>,
1303    default_export: Option<String>,
1304    export_aliases: HashMap<String, String>,
1305    node_by_scoped: HashMap<String, String>,
1306    node_by_bare: HashMap<String, String>,
1307    module_targets: HashMap<String, Option<String>>,
1308    reexports: Vec<ReexportIndex>,
1309}
1310
1311#[derive(Debug, Clone)]
1312struct ReexportIndex {
1313    target_file: Option<String>,
1314    named: HashMap<String, String>,
1315    wildcard: bool,
1316}
1317
1318#[derive(Debug, Clone)]
1319struct ProjectIndex<'a> {
1320    project_root: PathBuf,
1321    files: HashMap<String, DbFileIndex>,
1322    caller_data: HashMap<String, &'a FileCallData>,
1323    /// Lazily-built `crate_name -> src prefix` map for Rust workspace resolution.
1324    /// Built once (whole-tree walk) on first qualified-ref resolution and reused,
1325    /// instead of re-walking the project per ref. Skipped entirely when no Rust
1326    /// workspace ref is resolved (e.g. warm query path with no Rust changes).
1327    workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
1328}
1329
1330impl ProjectIndex<'_> {
1331    /// Resolve a crate name to its `src` prefix, building the workspace map on
1332    /// first use. The map walks the project tree exactly once per index.
1333    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1334        self.workspace_crate_prefixes
1335            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1336            .get(crate_name)
1337            .cloned()
1338    }
1339}
1340
1341impl CallGraphStore {
1342    pub fn open_if_enabled(
1343        options: CallGraphStoreOptions,
1344        callgraph_dir: PathBuf,
1345        project_root: PathBuf,
1346    ) -> Result<Option<Self>> {
1347        if !options.enabled {
1348            return Ok(None);
1349        }
1350        Self::open(callgraph_dir, project_root).map(Some)
1351    }
1352
1353    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1354        let project_key = crate::search_index::artifact_cache_key(&project_root);
1355        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1356        else {
1357            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1358                Some(store) => Ok(store.into_inner()),
1359                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1360            };
1361        };
1362        std::fs::create_dir_all(&callgraph_dir)?;
1363        // Resolve the current generation via the pointer (falling back to the
1364        // legacy single-file DB). If nothing is published yet, open the legacy
1365        // path so a brand-new store still gets a writable DB + schema.
1366        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1367            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1368        let OpenedStore { store, root_repair } = Self::open_at_path(
1369            project_root.clone(),
1370            project_key,
1371            sqlite_path,
1372            generation,
1373            true,
1374            Some(Arc::clone(&writer_lease)),
1375            None,
1376        )?;
1377        match root_repair {
1378            OpenRootRepair::NeedsRebuild { .. } => {
1379                log_root_repair_rebuild(&root_repair);
1380                drop(store);
1381                drop(writer_lease);
1382                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1383                let (store, _stats) =
1384                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1385                Ok(store)
1386            }
1387            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1388        }
1389    }
1390
1391    pub fn open_readonly(
1392        callgraph_dir: PathBuf,
1393        project_root: PathBuf,
1394    ) -> Result<Option<ReadonlyCallGraphStore>> {
1395        let project_key = crate::search_index::artifact_cache_key(&project_root);
1396        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1397        {
1398            let conn = open_readonly_connection(&sqlite_path)?;
1399            if !database_ready(&conn).unwrap_or(false) {
1400                return Ok(None);
1401            }
1402            let marker_label = generation.as_deref().unwrap_or("legacy");
1403            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1404            return Ok(Some(ReadonlyCallGraphStore::from_inner(
1405                Self::from_connection(
1406                    project_root,
1407                    project_key,
1408                    sqlite_path,
1409                    callgraph_dir,
1410                    false,
1411                    generation,
1412                    None,
1413                    Some(read_marker),
1414                    conn,
1415                ),
1416            )));
1417        }
1418
1419        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1420            return Ok(None);
1421        };
1422        crate::slog_warn!(
1423            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1424            target.partition.harness,
1425            target.sqlite_path.display()
1426        );
1427        let conn = open_readonly_connection(&target.sqlite_path)?;
1428        if !database_ready(&conn).unwrap_or(false) {
1429            return Ok(None);
1430        }
1431        let marker_label =
1432            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1433        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1434        Ok(Some(ReadonlyCallGraphStore::from_inner(
1435            Self::from_connection(
1436                project_root,
1437                project_key,
1438                target.sqlite_path,
1439                callgraph_dir,
1440                true,
1441                target.generation,
1442                None,
1443                Some(read_marker),
1444                conn,
1445            ),
1446        )))
1447    }
1448
1449    /// Open the currently-published ready store with write access so moved-root
1450    /// metadata can be repaired before projection readers consume it. Unlike
1451    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
1452    /// ready generation exists, it returns `Ok(None)` instead of creating an
1453    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
1454    pub fn open_ready_repairing(
1455        callgraph_dir: PathBuf,
1456        project_root: PathBuf,
1457    ) -> Result<Option<Self>> {
1458        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1459    }
1460
1461    /// Open a ready store for bounded maintenance work without repairing root
1462    /// metadata or starting a cold rebuild. A store that needs either action is
1463    /// reported as unavailable so a background build can own that work.
1464    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1465        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1466    }
1467
1468    pub fn open_ready_no_rebuild(
1469        callgraph_dir: PathBuf,
1470        project_root: PathBuf,
1471    ) -> Result<Option<Self>> {
1472        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1473    }
1474
1475    fn open_ready_with_rebuild_policy(
1476        callgraph_dir: PathBuf,
1477        project_root: PathBuf,
1478        allow_cold_build: bool,
1479        allow_root_repair: bool,
1480        allow_borrow_only: bool,
1481    ) -> Result<Option<Self>> {
1482        let project_key = crate::search_index::artifact_cache_key(&project_root);
1483        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1484        else {
1485            if !allow_borrow_only {
1486                return Ok(None);
1487            }
1488            return Self::open_readonly(callgraph_dir, project_root)
1489                .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1490        };
1491        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1492        else {
1493            return Ok(None);
1494        };
1495        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1496            project_root.clone(),
1497            project_key,
1498            sqlite_path,
1499            generation,
1500            true,
1501            Some(Arc::clone(&writer_lease)),
1502            None,
1503            allow_root_repair,
1504        )?;
1505        match root_repair {
1506            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1507                log_root_repair_rebuild(&root_repair);
1508                drop(store);
1509                drop(writer_lease);
1510                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1511                let (store, _stats) =
1512                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1513                Ok(Some(store))
1514            }
1515            OpenRootRepair::NeedsRebuild { .. } => {
1516                crate::slog_info!(
1517                    "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1518                );
1519                Ok(None)
1520            }
1521            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1522        }
1523    }
1524
1525    pub fn cold_build_with_lease(
1526        callgraph_dir: PathBuf,
1527        project_root: PathBuf,
1528        files: &[PathBuf],
1529    ) -> Result<(Self, ColdBuildStats)> {
1530        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1531    }
1532
1533    pub fn cold_build_with_lease_chunked(
1534        callgraph_dir: PathBuf,
1535        project_root: PathBuf,
1536        files: &[PathBuf],
1537        chunk_size: usize,
1538    ) -> Result<(Self, ColdBuildStats)> {
1539        Self::cold_build_with_lease_chunked_inner(
1540            callgraph_dir,
1541            project_root,
1542            files,
1543            chunk_size,
1544            false,
1545        )
1546    }
1547
1548    pub(crate) fn force_cold_build_with_lease_chunked(
1549        callgraph_dir: PathBuf,
1550        project_root: PathBuf,
1551        files: &[PathBuf],
1552        chunk_size: usize,
1553    ) -> Result<(Self, ColdBuildStats)> {
1554        Self::cold_build_with_lease_chunked_inner(
1555            callgraph_dir,
1556            project_root,
1557            files,
1558            chunk_size,
1559            true,
1560        )
1561    }
1562
1563    fn cold_build_with_lease_chunked_inner(
1564        callgraph_dir: PathBuf,
1565        project_root: PathBuf,
1566        files: &[PathBuf],
1567        chunk_size: usize,
1568        require_new_publication: bool,
1569    ) -> Result<(Self, ColdBuildStats)> {
1570        let project_key = crate::search_index::artifact_cache_key(&project_root);
1571        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1572        else {
1573            if require_new_publication {
1574                return Err(CallGraphStoreError::Unavailable(
1575                    "forced rebuild could not acquire the writer lease".to_string(),
1576                ));
1577            }
1578            let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1579                Some(store) => store.into_inner(),
1580                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1581            };
1582            return Ok((
1583                store,
1584                ColdBuildStats {
1585                    files: 0,
1586                    nodes: 0,
1587                    refs: 0,
1588                    edges: 0,
1589                    failed_files: Vec::new(),
1590                    elapsed_ms: 0,
1591                },
1592            ));
1593        };
1594        std::fs::create_dir_all(&callgraph_dir)?;
1595        let (stats, generation) = Self::cold_build_publish_locked(
1596            &callgraph_dir,
1597            &project_root,
1598            &project_key,
1599            files,
1600            chunk_size,
1601            Arc::clone(&writer_lease),
1602        )?;
1603        let store = Self::open_generation(
1604            &callgraph_dir,
1605            project_root,
1606            project_key,
1607            generation,
1608            writer_lease,
1609        )?;
1610        Ok((store, stats))
1611    }
1612
1613    pub fn ensure_built_with_lease(
1614        callgraph_dir: PathBuf,
1615        project_root: PathBuf,
1616        files: &[PathBuf],
1617    ) -> Result<(Self, Option<ColdBuildStats>)> {
1618        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1619    }
1620
1621    pub fn ensure_built_with_lease_chunked(
1622        callgraph_dir: PathBuf,
1623        project_root: PathBuf,
1624        files: &[PathBuf],
1625        chunk_size: usize,
1626    ) -> Result<(Self, Option<ColdBuildStats>)> {
1627        let project_key = crate::search_index::artifact_cache_key(&project_root);
1628        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1629        else {
1630            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1631                Some(store) => Ok((store.into_inner(), None)),
1632                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1633                    .map(|store| (store, None)),
1634            };
1635        };
1636        std::fs::create_dir_all(&callgraph_dir)?;
1637        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1638        // Another process may have published a ready generation while we waited
1639        // for the lock — open it instead of rebuilding. If that generation is
1640        // from this same project at an older filesystem root, repair the root
1641        // metadata in-place while still holding the build lease. If data rows
1642        // contain absolute paths, publish a fresh generation under this lease
1643        // rather than recursively reacquiring the same lock.
1644        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1645        {
1646            let OpenedStore { store, root_repair } = Self::open_at_path(
1647                project_root.clone(),
1648                project_key.clone(),
1649                sqlite_path,
1650                generation,
1651                true,
1652                Some(Arc::clone(&writer_lease)),
1653                None,
1654            )?;
1655            match root_repair {
1656                OpenRootRepair::NeedsRebuild { .. } => {
1657                    log_root_repair_rebuild(&root_repair);
1658                    drop(store);
1659                    let (stats, generation) = Self::cold_build_publish_locked(
1660                        &callgraph_dir,
1661                        &project_root,
1662                        &project_key,
1663                        files,
1664                        chunk_size,
1665                        Arc::clone(&writer_lease),
1666                    )?;
1667                    let store = Self::open_generation(
1668                        &callgraph_dir,
1669                        project_root,
1670                        project_key,
1671                        generation,
1672                        writer_lease,
1673                    )?;
1674                    return Ok((store, Some(stats)));
1675                }
1676                OpenRootRepair::None | OpenRootRepair::ReRooted => {
1677                    return Ok((store, None));
1678                }
1679            }
1680        }
1681        if let Some(store) = try_legacy_migration_or_fallback(
1682            &callgraph_dir,
1683            &project_root,
1684            &project_key,
1685            Arc::clone(&writer_lease),
1686        )? {
1687            return Ok((store, None));
1688        }
1689        let (stats, generation) = Self::cold_build_publish_locked(
1690            &callgraph_dir,
1691            &project_root,
1692            &project_key,
1693            files,
1694            chunk_size,
1695            Arc::clone(&writer_lease),
1696        )?;
1697        let store = Self::open_generation(
1698            &callgraph_dir,
1699            project_root,
1700            project_key,
1701            generation,
1702            writer_lease,
1703        )?;
1704        Ok((store, Some(stats)))
1705    }
1706
1707    /// Migrate a legacy harness-partition store without falling through to a
1708    /// cold build. This is used after a query has already opened a read-only
1709    /// fallback: the caller runs it on the same limited background lane as cold
1710    /// builds while queries continue using that fallback. Public so crash/retry
1711    /// tests can drive the migration synchronously on a thread where the
1712    /// thread-local failure seams apply.
1713    pub fn migrate_legacy_with_lease(
1714        callgraph_dir: PathBuf,
1715        project_root: PathBuf,
1716    ) -> Result<Option<Self>> {
1717        let project_key = crate::search_index::artifact_cache_key(&project_root);
1718        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1719        else {
1720            return Ok(None);
1721        };
1722        std::fs::create_dir_all(&callgraph_dir)?;
1723        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1724
1725        // Another writer may have completed the migration while this worker was
1726        // waiting for the lease. Adopt its root-keyed generation rather than
1727        // copying the legacy source a second time.
1728        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1729        {
1730            let OpenedStore { store, root_repair } = Self::open_at_path(
1731                project_root,
1732                project_key,
1733                sqlite_path,
1734                generation,
1735                true,
1736                Some(writer_lease),
1737                None,
1738            )?;
1739            return match root_repair {
1740                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1741                OpenRootRepair::NeedsRebuild { reason, .. } => {
1742                    Err(CallGraphStoreError::Unavailable(format!(
1743                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1744                    )))
1745                }
1746            };
1747        }
1748
1749        let store = try_legacy_migration_or_fallback(
1750            &callgraph_dir,
1751            &project_root,
1752            &project_key,
1753            writer_lease,
1754        )?;
1755        // A disk-floor or backup-budget failure returns a readable legacy store.
1756        // Keep the already-resident fallback instead of sending this duplicate
1757        // reader through the background-install channel.
1758        Ok(store.filter(|store| !store.is_legacy_fallback()))
1759    }
1760
1761    /// Build a fresh DB and publish it as a new generation, then atomically flip
1762    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
1763    /// succeeds even when other processes hold an older generation open (the
1764    /// multi-TUI Windows case). The builder owns the temp + generation files
1765    /// exclusively (unique pid+nanos names), so it can rename/replace them
1766    /// freely; only the tiny pointer is shared, and only Rust std touches it.
1767    ///
1768    /// Returns the published generation file name so callers open exactly the
1769    /// generation they built (avoiding a race where a concurrent build's flip
1770    /// would otherwise reopen a different generation).
1771    fn cold_build_publish_locked(
1772        callgraph_dir: &Path,
1773        project_root: &Path,
1774        project_key: &str,
1775        files: &[PathBuf],
1776        chunk_size: usize,
1777        writer_lease: Arc<crate::root_cache::WriterLease>,
1778    ) -> Result<(ColdBuildStats, String)> {
1779        let generation = generation_file_name(project_key);
1780        let gen_path = callgraph_dir.join(&generation);
1781        let temp_path = callgraph_dir.join(format!(
1782            "{generation}.tmp.{}.{}",
1783            std::process::id(),
1784            now_nanos()
1785        ));
1786        remove_sqlite_file_set(&temp_path);
1787
1788        let stats = {
1789            let temp_store = Self::open_at_path(
1790                project_root.to_path_buf(),
1791                project_key.to_string(),
1792                temp_path.clone(),
1793                None,
1794                false,
1795                Some(Arc::clone(&writer_lease)),
1796                None,
1797            )?
1798            .store;
1799            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
1800            temp_store.prepare_for_atomic_swap()?;
1801            stats
1802        };
1803
1804        notify_cold_build_before_publish_observer();
1805        let publication = publish_if_current(|| {
1806            verify_writer_lease(&writer_lease)?;
1807            // Move the finished build to its final generation path. This target is
1808            // brand-new and owned by us, so the rename never hits an open file.
1809            remove_sqlite_file_set(&gen_path);
1810            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
1811            crate::fs_lock::sync_parent(&gen_path);
1812            remove_sqlite_sidecars(&gen_path);
1813
1814            notify_cold_build_swap_observer(&temp_path, &gen_path);
1815
1816            // Atomically publish the new generation, then best-effort GC old ones.
1817            verify_writer_lease(&writer_lease)?;
1818            publish_pointer(callgraph_dir, project_key, &generation)?;
1819            gc_old_generations(callgraph_dir, project_key, &generation);
1820            Ok(())
1821        });
1822        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
1823            remove_sqlite_file_set(&temp_path);
1824        }
1825        publication?;
1826        Ok((stats, generation))
1827    }
1828
1829    /// Open a specific just-published generation (read-write, WAL) so a builder
1830    /// returns a store pinned to exactly what it built.
1831    fn open_generation(
1832        callgraph_dir: &Path,
1833        project_root: PathBuf,
1834        project_key: String,
1835        generation: String,
1836        writer_lease: Arc<crate::root_cache::WriterLease>,
1837    ) -> Result<Self> {
1838        let gen_path = callgraph_dir.join(&generation);
1839        Ok(Self::open_at_path(
1840            project_root,
1841            project_key,
1842            gen_path,
1843            Some(generation),
1844            true,
1845            Some(writer_lease),
1846            None,
1847        )?
1848        .store)
1849    }
1850
1851    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
1852        let project_key = crate::search_index::artifact_cache_key(project_root);
1853        // A cold build is needed unless a ready generation (or ready legacy DB)
1854        // is currently published.
1855        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
1856    }
1857
1858    fn open_at_path(
1859        project_root: PathBuf,
1860        project_key: String,
1861        sqlite_path: PathBuf,
1862        generation: Option<String>,
1863        use_wal: bool,
1864        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1865        read_marker: Option<crate::root_cache::ReadMarker>,
1866    ) -> Result<OpenedStore> {
1867        Self::open_at_path_with_root_repair(
1868            project_root,
1869            project_key,
1870            sqlite_path,
1871            generation,
1872            use_wal,
1873            writer_lease,
1874            read_marker,
1875            true,
1876        )
1877    }
1878
1879    fn open_at_path_with_root_repair(
1880        project_root: PathBuf,
1881        project_key: String,
1882        sqlite_path: PathBuf,
1883        generation: Option<String>,
1884        use_wal: bool,
1885        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1886        read_marker: Option<crate::root_cache::ReadMarker>,
1887        allow_root_repair: bool,
1888    ) -> Result<OpenedStore> {
1889        if let Some(lease) = writer_lease.as_ref() {
1890            verify_writer_lease(lease)?;
1891        }
1892        if let Some(parent) = sqlite_path.parent() {
1893            std::fs::create_dir_all(parent)?;
1894        }
1895        let mut conn = Connection::open(&sqlite_path)?;
1896        if use_wal {
1897            configure_connection(&conn)?;
1898        } else {
1899            configure_build_connection(&conn)?;
1900        }
1901        if let Some(lease) = writer_lease.as_ref() {
1902            verify_writer_lease(lease)?;
1903        }
1904        initialize_schema(&conn)?;
1905        if let Some(lease) = writer_lease.as_ref() {
1906            verify_writer_lease(lease)?;
1907        }
1908        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
1909        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
1910            (Some(marker), _, _) => Some(marker),
1911            (None, Some(label), Some(cache_dir)) => {
1912                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
1913            }
1914            (None, _, _) => None,
1915        };
1916        let publication_dir = sqlite_path
1917            .parent()
1918            .map(Path::to_path_buf)
1919            .unwrap_or_default();
1920        let store = Self::from_connection(
1921            project_root,
1922            project_key,
1923            sqlite_path,
1924            publication_dir,
1925            false,
1926            generation,
1927            writer_lease,
1928            read_marker,
1929            conn,
1930        );
1931        Ok(OpenedStore { store, root_repair })
1932    }
1933
1934    fn borrow_only_empty(
1935        callgraph_dir: PathBuf,
1936        project_root: PathBuf,
1937        project_key: String,
1938    ) -> Result<Self> {
1939        let conn = Connection::open_in_memory()?;
1940        initialize_schema(&conn)?;
1941        conn.pragma_update(None, "query_only", true)?;
1942        Ok(Self::from_connection(
1943            project_root,
1944            project_key.clone(),
1945            callgraph_dir.join(format!("{project_key}.borrow-only")),
1946            callgraph_dir,
1947            false,
1948            None,
1949            None,
1950            None,
1951            conn,
1952        ))
1953    }
1954
1955    fn prepare_for_atomic_swap(&self) -> Result<()> {
1956        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1957        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
1958        Ok(())
1959    }
1960
1961    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
1962        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
1963            self.sqlite_path
1964                .parent()
1965                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
1966        });
1967        if protected_reader {
1968            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
1969        } else {
1970            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
1971        }
1972    }
1973
1974    fn from_connection(
1975        project_root: PathBuf,
1976        project_key: String,
1977        sqlite_path: PathBuf,
1978        publication_dir: PathBuf,
1979        legacy_fallback: bool,
1980        generation: Option<String>,
1981        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1982        read_marker: Option<crate::root_cache::ReadMarker>,
1983        conn: Connection,
1984    ) -> Self {
1985        Self {
1986            project_root,
1987            project_key,
1988            sqlite_path,
1989            publication_dir,
1990            legacy_fallback,
1991            generation,
1992            writer_lease,
1993            read_marker,
1994            database_ready: AtomicBool::new(false),
1995            conn: Mutex::new(conn),
1996        }
1997    }
1998
1999    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2000        if self.database_ready.load(AtomicOrdering::Acquire) {
2001            return Ok(());
2002        }
2003        ensure_database_ready(conn)?;
2004        self.database_ready.store(true, AtomicOrdering::Release);
2005        Ok(())
2006    }
2007
2008    pub fn project_root(&self) -> &Path {
2009        &self.project_root
2010    }
2011
2012    pub fn project_key(&self) -> &str {
2013        &self.project_key
2014    }
2015
2016    pub fn sqlite_path(&self) -> &Path {
2017        &self.sqlite_path
2018    }
2019
2020    /// Whether this store is reading from a legacy harness partition because
2021    /// the root-keyed store has not published a generation yet.
2022    pub fn is_legacy_fallback(&self) -> bool {
2023        self.legacy_fallback
2024    }
2025
2026    pub(crate) fn is_legacy_migration(&self) -> bool {
2027        self.generation.as_deref().is_some_and(|generation| {
2028            migration_generation_requires_manifest(generation)
2029                && migration_manifest_valid(&self.publication_dir, generation)
2030        })
2031    }
2032
2033    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2034        self.writer_lease.as_ref().map(|lease| lease.epoch())
2035    }
2036
2037    fn verify_writer_lease(&self) -> Result<()> {
2038        let Some(lease) = self.writer_lease.as_ref() else {
2039            return Err(CallGraphStoreError::Unavailable(
2040                "callgraph store opened read-only; write API is unavailable".to_string(),
2041            ));
2042        };
2043        verify_writer_lease(lease)
2044    }
2045
2046    fn refresh_read_marker(&self) -> Result<()> {
2047        if let Some(marker) = self.read_marker.as_ref() {
2048            marker.touch_if_due()?;
2049        }
2050        Ok(())
2051    }
2052
2053    /// True if this store still reflects the currently-published generation.
2054    /// Cheap (one small pointer-file read). When false, another process (or a
2055    /// local cold rebuild) has published a newer generation and the holder
2056    /// should drop this store and reopen via the pointer to converge. A missing
2057    /// pointer keeps the current store (legacy DB still valid, or transient).
2058    pub fn is_current(&self) -> bool {
2059        let _ = self.refresh_read_marker();
2060        match (
2061            read_pointer(&self.publication_dir, &self.project_key),
2062            &self.generation,
2063        ) {
2064            // Even when both generations happen to have the same filename, the
2065            // root-keyed pointer names a different directory from the fallback.
2066            (Some(_), _) if self.legacy_fallback => false,
2067            (Some(published), Some(opened)) => &published == opened,
2068            // A generation now supersedes the legacy single-file DB we opened.
2069            (Some(_), None) => false,
2070            // No pointer: keep serving (legacy DB, or an anomalous pointer
2071            // removal where our open generation file is still valid).
2072            (None, _) => true,
2073        }
2074    }
2075
2076    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2077        self.cold_build_chunked(files, 0)
2078    }
2079
2080    pub fn cold_build_chunked(
2081        &self,
2082        files: &[PathBuf],
2083        chunk_size: usize,
2084    ) -> Result<ColdBuildStats> {
2085        let started = Instant::now();
2086        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2087        macro_rules! phase {
2088            ($label:expr, $t:expr) => {
2089                if bench {
2090                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2091                    let _ = std::io::Write::flush(&mut std::io::stderr());
2092                }
2093            };
2094        }
2095        let files = normalize_file_list(&self.project_root, files)?;
2096
2097        if chunk_size == 0 {
2098            let t = Instant::now();
2099            let build = build_extracts_parallel(&self.project_root, &files);
2100            phase!("extract_parallel", t);
2101            let extracts = build.extracts;
2102            let failures = build.failures;
2103            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2104
2105            let t = Instant::now();
2106            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2107            phase!("build_index", t);
2108            let t = Instant::now();
2109            let mut resolved_refs = Vec::new();
2110            for extract in &extracts {
2111                for raw_ref in &extract.raw_refs {
2112                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2113                }
2114            }
2115            phase!("resolve_refs", t);
2116            let ref_count = resolved_refs.len();
2117            let edge_count = resolved_refs
2118                .iter()
2119                .filter(|item| item.edge.is_some())
2120                .count();
2121
2122            let t = Instant::now();
2123            self.verify_writer_lease()?;
2124            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2125            let tx = conn.transaction()?;
2126            clear_tables(&tx)?;
2127            insert_meta(&tx)?;
2128            drop_cold_build_secondary_indexes(&tx)?;
2129            {
2130                let workspace_root = self.project_root.display().to_string();
2131                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2132                for extract in &extracts {
2133                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2134                }
2135                for failure in &failures {
2136                    insert_backend_state_prepared(
2137                        &mut inserts.backend_state,
2138                        &workspace_root,
2139                        &failure.rel_path,
2140                        failure
2141                            .freshness
2142                            .as_ref()
2143                            .map(|freshness| &freshness.content_hash),
2144                        "stale",
2145                    )?;
2146                }
2147                for resolved in &resolved_refs {
2148                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2149                }
2150            }
2151            create_cold_build_secondary_indexes(&tx)?;
2152            let supplemental_edge_count =
2153                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2154            set_meta_ready(&tx, true)?;
2155            tx.commit()?;
2156            phase!("sqlite_insert", t);
2157
2158            let elapsed_ms = started.elapsed().as_millis();
2159            crate::slog_info!(
2160                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2161                extracts.len(),
2162                node_count,
2163                ref_count,
2164                edge_count + supplemental_edge_count,
2165                elapsed_ms
2166            );
2167            return Ok(ColdBuildStats {
2168                files: extracts.len(),
2169                nodes: node_count,
2170                refs: ref_count,
2171                edges: edge_count + supplemental_edge_count,
2172                failed_files: failures
2173                    .into_iter()
2174                    .map(|failure| failure.rel_path)
2175                    .collect(),
2176                elapsed_ms,
2177            });
2178        }
2179
2180        // Chunked implementation: parse and resolve in batches to reduce peak
2181        // memory during cold build without changing the persisted graph.
2182        let t = Instant::now();
2183        self.verify_writer_lease()?;
2184        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2185        let tx = conn.transaction()?;
2186        clear_tables(&tx)?;
2187        insert_meta(&tx)?;
2188        drop_cold_build_secondary_indexes(&tx)?;
2189
2190        let mut all_raw_refs = Vec::new();
2191        let mut failures = Vec::new();
2192        let mut node_count = 0;
2193        let mut files_parsed = 0;
2194
2195        let mut persistent_call_data = Vec::new();
2196        let mut file_to_call_data_index = HashMap::new();
2197        let mut files_index = HashMap::new();
2198
2199        let workspace_root = self.project_root.display().to_string();
2200
2201        {
2202            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2203            for chunk in files.chunks(chunk_size) {
2204                let build = build_extracts_parallel(&self.project_root, chunk);
2205                failures.extend(build.failures.clone());
2206
2207                for extract in build.extracts {
2208                    files_parsed += 1;
2209                    node_count += extract.nodes.len();
2210                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2211
2212                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2213                    files_index.insert(extract.rel_path.clone(), db_file_index);
2214
2215                    persistent_call_data.push(extract.data);
2216                    let idx = persistent_call_data.len() - 1;
2217                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2218
2219                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2220                }
2221                for failure in &build.failures {
2222                    insert_backend_state_prepared(
2223                        &mut inserts.backend_state,
2224                        &workspace_root,
2225                        &failure.rel_path,
2226                        failure
2227                            .freshness
2228                            .as_ref()
2229                            .map(|freshness| &freshness.content_hash),
2230                        "stale",
2231                    )?;
2232                }
2233            }
2234        }
2235
2236        let mut caller_data = HashMap::new();
2237        for (rel_path, idx) in &file_to_call_data_index {
2238            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2239        }
2240        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2241        let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
2242
2243        let mut resolved_refs = Vec::new();
2244        for (_, raw_refs) in all_raw_refs {
2245            for raw_ref in raw_refs {
2246                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2247            }
2248        }
2249
2250        let ref_count = resolved_refs.len();
2251        let edge_count = resolved_refs
2252            .iter()
2253            .filter(|item| item.edge.is_some())
2254            .count();
2255
2256        {
2257            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2258            for resolved in &resolved_refs {
2259                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2260            }
2261        }
2262        create_cold_build_secondary_indexes(&tx)?;
2263        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2264            &tx,
2265            &self.project_root,
2266            &indexed_caller_files,
2267            chunk_size,
2268        )?;
2269        set_meta_ready(&tx, true)?;
2270        tx.commit()?;
2271        phase!("sqlite_insert", t);
2272
2273        let elapsed_ms = started.elapsed().as_millis();
2274        crate::slog_info!(
2275            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2276            files_parsed,
2277            node_count,
2278            ref_count,
2279            edge_count + supplemental_edge_count,
2280            elapsed_ms
2281        );
2282        Ok(ColdBuildStats {
2283            files: files_parsed,
2284            nodes: node_count,
2285            refs: ref_count,
2286            edges: edge_count + supplemental_edge_count,
2287            failed_files: failures
2288                .into_iter()
2289                .map(|failure| failure.rel_path)
2290                .collect(),
2291            elapsed_ms,
2292        })
2293    }
2294
2295    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2296        let (stats, profile) = self.refresh_files_profiled(changed_files)?;
2297        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2298            eprintln!("refresh_files phases: {}", profile.report());
2299        }
2300        Ok(stats)
2301    }
2302
2303    /// Run an incremental refresh and return phase timings for an offline store copy.
2304    #[doc(hidden)]
2305    pub fn refresh_files_profiled(
2306        &self,
2307        changed_files: &[PathBuf],
2308    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2309        let total_started = Instant::now();
2310        let mut profile = RefreshFilesProfile::default();
2311        self.verify_writer_lease()?;
2312        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2313        let tx = conn.transaction()?;
2314        ensure_database_ready(&tx)?;
2315        let mut changed = Vec::new();
2316        let mut surface_changed = BTreeSet::new();
2317        let mut deleted = BTreeSet::new();
2318        let mut own_refresh = BTreeSet::new();
2319        let mut selected_ref_ids = BTreeSet::new();
2320        let mut selected_refs_by_caller = BTreeMap::new();
2321        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2322
2323        for input in changed_files {
2324            let abs_path = normalize_file_path(&self.project_root, input)?;
2325            let rel_path = relative_path(&self.project_root, &abs_path);
2326            changed.push(rel_path.clone());
2327            let old_row = load_file_row(&tx, &rel_path)?;
2328            if !abs_path.exists() {
2329                if old_row.is_some() {
2330                    surface_changed.insert(rel_path.clone());
2331                    deleted.insert(rel_path.clone());
2332                    let started = Instant::now();
2333                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2334                    profile.dependency_selection += started.elapsed();
2335                    record_dependent_refs(
2336                        &mut selected_ref_ids,
2337                        &mut selected_refs_by_caller,
2338                        dependent_refs,
2339                    );
2340                    let started = Instant::now();
2341                    delete_file_rows(&tx, &rel_path)?;
2342                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2343                    profile.row_deletes += started.elapsed();
2344                }
2345                continue;
2346            }
2347
2348            if let Some(row) = &old_row {
2349                match cache_freshness::verify_file(&abs_path, &row.freshness) {
2350                    FreshnessVerdict::HotFresh => continue,
2351                    FreshnessVerdict::ContentFresh {
2352                        new_mtime,
2353                        new_size,
2354                    } => {
2355                        update_file_fresh_metadata(
2356                            &tx,
2357                            &rel_path,
2358                            &row.freshness.content_hash,
2359                            new_mtime,
2360                            new_size,
2361                        )?;
2362                        continue;
2363                    }
2364                    FreshnessVerdict::Deleted => {
2365                        surface_changed.insert(rel_path.clone());
2366                        deleted.insert(rel_path.clone());
2367                        let started = Instant::now();
2368                        let dependent_refs =
2369                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2370                        profile.dependency_selection += started.elapsed();
2371                        record_dependent_refs(
2372                            &mut selected_ref_ids,
2373                            &mut selected_refs_by_caller,
2374                            dependent_refs,
2375                        );
2376                        let started = Instant::now();
2377                        delete_file_rows(&tx, &rel_path)?;
2378                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2379                        profile.row_deletes += started.elapsed();
2380                        continue;
2381                    }
2382                    FreshnessVerdict::Stale => {}
2383                }
2384            }
2385
2386            let started = Instant::now();
2387            let extract = build_file_extract(&self.project_root, &abs_path)?;
2388            profile.parse += started.elapsed();
2389            let surface_is_changed = old_row
2390                .as_ref()
2391                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2392                .unwrap_or(true);
2393            if surface_is_changed {
2394                surface_changed.insert(rel_path.clone());
2395                let started = Instant::now();
2396                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2397                profile.dependency_selection += started.elapsed();
2398                record_dependent_refs(
2399                    &mut selected_ref_ids,
2400                    &mut selected_refs_by_caller,
2401                    dependent_refs,
2402                );
2403            }
2404            own_refresh.insert(rel_path.clone());
2405            let started = Instant::now();
2406            delete_file_rows(&tx, &rel_path)?;
2407            profile.row_deletes += started.elapsed();
2408            let started = Instant::now();
2409            insert_file_extract(&tx, &self.project_root, &extract)?;
2410            profile.row_inserts += started.elapsed();
2411            changed_extracts.insert(rel_path, extract);
2412        }
2413
2414        let dependency_selected_refs = selected_ref_ids.len();
2415        let mut touched_callers: BTreeSet<String> =
2416            selected_refs_by_caller.keys().cloned().collect();
2417        touched_callers.extend(own_refresh.iter().cloned());
2418
2419        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2420        for rel_path in &touched_callers {
2421            if deleted.contains(rel_path) {
2422                continue;
2423            }
2424            if let Some(extract) = changed_extracts.get(rel_path) {
2425                caller_extracts.insert(rel_path.clone(), extract.clone());
2426                continue;
2427            }
2428            let abs_path = self.project_root.join(rel_path);
2429            if abs_path.exists() {
2430                let started = Instant::now();
2431                let extract = build_file_extract(&self.project_root, &abs_path)?;
2432                profile.dependent_parse += started.elapsed();
2433                caller_extracts.insert(rel_path.clone(), extract);
2434            }
2435        }
2436
2437        let dependency_callers = touched_callers
2438            .iter()
2439            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2440            .cloned()
2441            .collect::<Vec<_>>();
2442        for rel_path in dependency_callers {
2443            let Some(extract) = caller_extracts.get(&rel_path) else {
2444                continue;
2445            };
2446            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2447                continue;
2448            }
2449
2450            own_refresh.insert(rel_path.clone());
2451            let started = Instant::now();
2452            delete_file_rows(&tx, &rel_path)?;
2453            profile.row_deletes += started.elapsed();
2454            let started = Instant::now();
2455            insert_file_extract(&tx, &self.project_root, extract)?;
2456            profile.row_inserts += started.elapsed();
2457        }
2458
2459        let started = Instant::now();
2460        let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
2461        profile.index_load += started.elapsed();
2462        let started = Instant::now();
2463        for rel_path in &touched_callers {
2464            if deleted.contains(rel_path) {
2465                continue;
2466            }
2467            let Some(extract) = caller_extracts.get(rel_path) else {
2468                continue;
2469            };
2470            if own_refresh.contains(rel_path) {
2471                delete_refs_for_caller(&tx, rel_path)?;
2472                for raw_ref in &extract.raw_refs {
2473                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2474                    insert_resolved_ref(&tx, &resolved)?;
2475                }
2476                continue;
2477            }
2478
2479            let selected_for_caller = selected_refs_by_caller
2480                .get(rel_path)
2481                .cloned()
2482                .unwrap_or_default();
2483            delete_ref_ids(&tx, &selected_for_caller)?;
2484            for raw_ref in &extract.raw_refs {
2485                if selected_for_caller.contains(&raw_ref.ref_id) {
2486                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2487                    insert_resolved_ref(&tx, &resolved)?;
2488                }
2489            }
2490        }
2491        profile.ref_resolution += started.elapsed();
2492
2493        let started = Instant::now();
2494        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2495        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2496        profile.method_dispatch += started.elapsed();
2497
2498        let started = Instant::now();
2499        commit_incremental_if_current(tx)?;
2500        profile.commit += started.elapsed();
2501        profile.total = total_started.elapsed();
2502        Ok((
2503            IncrementalStats {
2504                changed_files: changed,
2505                surface_changed: surface_changed.into_iter().collect(),
2506                deleted_files: deleted.into_iter().collect(),
2507                dependency_selected_refs,
2508                refreshed_own_files: own_refresh.len(),
2509            },
2510            profile,
2511        ))
2512    }
2513
2514    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2515        self.cold_build(current_files)
2516    }
2517
2518    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2519        self.verify_writer_lease()?;
2520        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2521        let tx = conn.transaction()?;
2522        let mut marked = Vec::new();
2523        for path in files {
2524            let abs_path = normalize_file_path(&self.project_root, path)?;
2525            let rel_path = relative_path(&self.project_root, &abs_path);
2526            let freshness = cache_freshness::collect(&abs_path).ok();
2527            mark_backend_state(
2528                &tx,
2529                &self.project_root,
2530                &rel_path,
2531                freshness.as_ref().map(|freshness| &freshness.content_hash),
2532                "stale",
2533            )?;
2534            marked.push(rel_path);
2535        }
2536        tx.commit()?;
2537        marked.sort();
2538        marked.dedup();
2539        Ok(marked)
2540    }
2541
2542    pub fn stale_files(&self) -> Result<Vec<String>> {
2543        self.refresh_read_marker()?;
2544        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2545        let mut stmt = conn.prepare(
2546            "SELECT DISTINCT file_path FROM backend_file_state
2547             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2548             ORDER BY file_path",
2549        )?;
2550        let rows = stmt.query_map(
2551            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2552            |row| row.get::<_, String>(0),
2553        )?;
2554        rows.collect::<std::result::Result<Vec<_>, _>>()
2555            .map_err(Into::into)
2556    }
2557
2558    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2559        self.refresh_read_marker()?;
2560        let rel_path = relative_path(
2561            &self.project_root,
2562            &normalize_file_path(&self.project_root, file)?,
2563        );
2564        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2565        conn.query_row(
2566            "SELECT status FROM backend_file_state
2567             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2568             ORDER BY updated_at DESC LIMIT 1",
2569            params![
2570                BACKEND_TREESITTER,
2571                self.project_root.display().to_string(),
2572                rel_path
2573            ],
2574            |row| row.get(0),
2575        )
2576        .optional()
2577        .map_err(Into::into)
2578    }
2579
2580    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2581        self.refresh_read_marker()?;
2582        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2583        self.ensure_ready(&conn)?;
2584        edge_snapshot_with_conn(&conn)
2585    }
2586
2587    pub fn indexed_file_count(&self) -> Result<usize> {
2588        self.refresh_read_marker()?;
2589        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2590        self.ensure_ready(&conn)?;
2591        indexed_file_count(&conn)
2592    }
2593
2594    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2595        self.refresh_read_marker()?;
2596        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2597        let rel_path = relative_path(&self.project_root, &abs_path);
2598        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2599        self.ensure_ready(&conn)?;
2600        resolve_node_for_rel(&conn, &rel_path, symbol)
2601    }
2602
2603    /// Return all positional nodes matching a legacy symbol query in a file.
2604    ///
2605    /// Consumers that need legacy compatibility can collapse these by
2606    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
2607    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2608        self.refresh_read_marker()?;
2609        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2610        let rel_path = relative_path(&self.project_root, &abs_path);
2611        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2612        self.ensure_ready(&conn)?;
2613        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2614    }
2615
2616    /// Return all positional nodes matching a symbol query anywhere in the store.
2617    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2618        self.refresh_read_marker()?;
2619        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2620        self.ensure_ready(&conn)?;
2621        nodes_matching_symbol(&conn, symbol)
2622    }
2623
2624    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
2625    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2626        self.refresh_read_marker()?;
2627        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2628        let rel_path = relative_path(&self.project_root, &abs_path);
2629        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2630        self.ensure_ready(&conn)?;
2631        direct_callers_for_tuple(&conn, &rel_path, symbol)
2632    }
2633
2634    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
2635    pub fn direct_caller_counts_of(
2636        &self,
2637        targets: &[(String, String)],
2638    ) -> Result<HashMap<(String, String), usize>> {
2639        if targets.is_empty() {
2640            return Ok(HashMap::new());
2641        }
2642        self.refresh_read_marker()?;
2643        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2644        self.ensure_ready(&conn)?;
2645        direct_caller_counts_for_tuples(&conn, targets)
2646    }
2647
2648    pub fn callers_of(
2649        &self,
2650        file_rel: &Path,
2651        symbol: &str,
2652        depth: usize,
2653    ) -> Result<StoreCallersResult> {
2654        let target = self.node_for(file_rel, symbol)?;
2655        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2656        self.ensure_ready(&conn)?;
2657        let effective_depth = depth.max(1);
2658        let mut visited = HashSet::new();
2659        let mut callers = Vec::new();
2660        let mut depth_limited = false;
2661        let mut truncated = 0usize;
2662        collect_callers_recursive(
2663            &conn,
2664            &target.file,
2665            &target.symbol,
2666            effective_depth,
2667            0,
2668            &mut visited,
2669            &mut callers,
2670            &mut depth_limited,
2671            &mut truncated,
2672        )?;
2673        Ok(StoreCallersResult {
2674            target,
2675            callers,
2676            scanned_files: indexed_file_count(&conn)?,
2677            depth_limited,
2678            truncated,
2679        })
2680    }
2681
2682    pub fn impact_of(
2683        &self,
2684        file_rel: &Path,
2685        symbol: &str,
2686        depth: usize,
2687    ) -> Result<StoreImpactResult> {
2688        let callers = self.callers_of(file_rel, symbol, depth)?;
2689        let target_parameters = callers
2690            .target
2691            .signature
2692            .as_deref()
2693            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2694            .unwrap_or_default();
2695        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2696        for site in &callers.callers {
2697            source_lines_by_file
2698                .entry(site.caller.file.clone())
2699                .or_insert_with(|| {
2700                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2701                });
2702        }
2703        let enriched = callers
2704            .callers
2705            .iter()
2706            .map(|site| StoreImpactCaller {
2707                site: site.clone(),
2708                signature: site.caller.signature.clone(),
2709                is_entry_point: site.caller.is_entry_point,
2710                call_expression: source_lines_by_file
2711                    .get(&site.caller.file)
2712                    .and_then(|lines| lines.as_ref())
2713                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2714                    .cloned(),
2715                parameters: site
2716                    .caller
2717                    .signature
2718                    .as_deref()
2719                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2720                    .unwrap_or_default(),
2721            })
2722            .collect();
2723        Ok(StoreImpactResult {
2724            target: callers.target,
2725            parameters: target_parameters,
2726            callers: enriched,
2727            depth_limited: callers.depth_limited,
2728            truncated: callers.truncated,
2729        })
2730    }
2731
2732    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2733        self.refresh_read_marker()?;
2734        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2735        self.ensure_ready(&conn)?;
2736        outgoing_calls_for_node(&conn, node)
2737    }
2738
2739    /// Return resolved direct self-call refs suppressed from the general edge table.
2740    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2741        self.refresh_read_marker()?;
2742        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2743        self.ensure_ready(&conn)?;
2744        resolved_self_calls_for_node(&conn, node)
2745    }
2746
2747    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
2748        self.refresh_read_marker()?;
2749        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2750        self.ensure_ready(&conn)?;
2751        unresolved_calls_for_node(&conn, node)
2752    }
2753
2754    pub fn call_tree(
2755        &self,
2756        file_rel: &Path,
2757        symbol: &str,
2758        max_depth: usize,
2759    ) -> Result<callgraph::CallTreeNode> {
2760        let node = self.node_for(file_rel, symbol)?;
2761        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2762        self.ensure_ready(&conn)?;
2763        let mut visited = HashSet::new();
2764        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
2765    }
2766
2767    pub fn trace_to(
2768        &self,
2769        file_rel: &Path,
2770        symbol: &str,
2771        max_depth: usize,
2772    ) -> Result<callgraph::TraceToResult> {
2773        let target = self.node_for(file_rel, symbol)?;
2774        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2775        self.ensure_ready(&conn)?;
2776        let effective_max = if max_depth == 0 { 10 } else { max_depth };
2777
2778        #[derive(Clone)]
2779        struct PathElem {
2780            node: StoreNode,
2781        }
2782
2783        let initial = vec![PathElem {
2784            node: target.clone(),
2785        }];
2786        let mut complete_paths = Vec::new();
2787        if target.is_entry_point {
2788            complete_paths.push(initial.clone());
2789        }
2790
2791        let mut queue = vec![(initial, 0usize)];
2792        let mut max_depth_reached = false;
2793        let mut truncated_paths = 0usize;
2794
2795        while let Some((path, depth)) = queue.pop() {
2796            if depth >= effective_max {
2797                max_depth_reached = true;
2798                continue;
2799            }
2800            let Some(current) = path.last() else {
2801                continue;
2802            };
2803            let callers =
2804                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
2805            if callers.is_empty() {
2806                if path.len() > 1 {
2807                    truncated_paths += 1;
2808                }
2809                continue;
2810            }
2811
2812            let mut has_new_path = false;
2813            for site in callers {
2814                if path.iter().any(|elem| {
2815                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
2816                }) {
2817                    continue;
2818                }
2819                has_new_path = true;
2820                let mut new_path = path.clone();
2821                new_path.push(PathElem {
2822                    node: site.caller.clone(),
2823                });
2824                if site.caller.is_entry_point {
2825                    complete_paths.push(new_path.clone());
2826                }
2827                queue.push((new_path, depth + 1));
2828            }
2829            if !has_new_path && path.len() > 1 {
2830                truncated_paths += 1;
2831            }
2832        }
2833
2834        let mut paths: Vec<callgraph::TracePath> = complete_paths
2835            .into_iter()
2836            .map(|mut elems| {
2837                elems.reverse();
2838                let hops = elems
2839                    .iter()
2840                    .enumerate()
2841                    .map(|(index, elem)| callgraph::TraceHop {
2842                        symbol: elem.node.symbol.clone(),
2843                        file: elem.node.file.clone(),
2844                        line: elem.node.line,
2845                        signature: elem.node.signature.clone(),
2846                        is_entry_point: index == 0 && elem.node.is_entry_point,
2847                    })
2848                    .collect();
2849                callgraph::TracePath { hops }
2850            })
2851            .collect();
2852        paths.sort_by(|left, right| {
2853            let left_entry = left
2854                .hops
2855                .first()
2856                .map(|hop| hop.symbol.as_str())
2857                .unwrap_or("");
2858            let right_entry = right
2859                .hops
2860                .first()
2861                .map(|hop| hop.symbol.as_str())
2862                .unwrap_or("");
2863            left_entry
2864                .cmp(right_entry)
2865                .then(left.hops.len().cmp(&right.hops.len()))
2866        });
2867        let entry_points_found = paths
2868            .iter()
2869            .filter_map(|path| path.hops.first())
2870            .filter(|hop| hop.is_entry_point)
2871            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
2872            .collect::<HashSet<_>>()
2873            .len();
2874
2875        Ok(callgraph::TraceToResult {
2876            target_symbol: target.symbol,
2877            target_file: target.file,
2878            total_paths: paths.len(),
2879            paths,
2880            entry_points_found,
2881            max_depth_reached,
2882            truncated_paths,
2883        })
2884    }
2885
2886    pub fn trace_to_symbol_candidates(
2887        &self,
2888        to_symbol: &str,
2889    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
2890        self.refresh_read_marker()?;
2891        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2892        self.ensure_ready(&conn)?;
2893        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
2894        for node in nodes_matching_symbol(&conn, to_symbol)? {
2895            candidates_by_file
2896                .entry(node.file)
2897                .and_modify(|line| *line = (*line).min(node.line))
2898                .or_insert(node.line);
2899        }
2900        let mut candidates: Vec<_> = candidates_by_file
2901            .into_iter()
2902            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
2903            .collect();
2904        candidates
2905            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
2906        Ok(candidates)
2907    }
2908
2909    pub fn trace_to_symbol(
2910        &self,
2911        file_rel: &Path,
2912        symbol: &str,
2913        to_symbol: &str,
2914        to_file: Option<&Path>,
2915        max_depth: usize,
2916    ) -> Result<callgraph::TraceToSymbolResult> {
2917        let origin = self.node_for(file_rel, symbol)?;
2918        let target_file = to_file
2919            .map(|path| normalize_file_path(&self.project_root, path))
2920            .transpose()?
2921            .map(|path| relative_path(&self.project_root, &path));
2922        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2923        self.ensure_ready(&conn)?;
2924        let effective_max = if max_depth == 0 {
2925            10
2926        } else {
2927            max_depth.min(16)
2928        };
2929
2930        let start_hop = trace_to_symbol_hop(&origin);
2931        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
2932            return Ok(callgraph::TraceToSymbolResult {
2933                path: Some(vec![start_hop]),
2934                complete: true,
2935                reason: None,
2936            });
2937        }
2938
2939        let mut queue = VecDeque::new();
2940        queue.push_back((origin.clone(), vec![start_hop], 0usize));
2941        let mut visited = HashSet::new();
2942        visited.insert((origin.file.clone(), origin.symbol.clone()));
2943        let mut max_depth_exhausted = false;
2944
2945        while let Some((current, path, depth)) = queue.pop_front() {
2946            let callees = outgoing_calls_for_node(&conn, &current)?
2947                .into_iter()
2948                .filter_map(|site| site.target)
2949                .collect::<Vec<_>>();
2950
2951            if depth >= effective_max {
2952                if callees
2953                    .iter()
2954                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
2955                {
2956                    max_depth_exhausted = true;
2957                }
2958                continue;
2959            }
2960
2961            for callee in callees {
2962                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
2963                    continue;
2964                }
2965                let mut next_path = path.clone();
2966                next_path.push(trace_to_symbol_hop(&callee));
2967                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
2968                    return Ok(callgraph::TraceToSymbolResult {
2969                        path: Some(next_path),
2970                        complete: true,
2971                        reason: None,
2972                    });
2973                }
2974                queue.push_back((callee, next_path, depth + 1));
2975            }
2976        }
2977
2978        if max_depth_exhausted {
2979            Ok(callgraph::TraceToSymbolResult {
2980                path: None,
2981                complete: false,
2982                reason: Some("max_depth_exhausted".to_string()),
2983            })
2984        } else {
2985            Ok(callgraph::TraceToSymbolResult {
2986                path: None,
2987                complete: true,
2988                reason: Some("no_path_found".to_string()),
2989            })
2990        }
2991    }
2992}
2993
2994impl ReadonlyCallGraphStore {
2995    fn from_inner(inner: CallGraphStore) -> Self {
2996        Self { inner }
2997    }
2998
2999    fn into_inner(self) -> CallGraphStore {
3000        self.inner
3001    }
3002
3003    pub fn project_root(&self) -> &Path {
3004        self.inner.project_root()
3005    }
3006
3007    pub fn project_key(&self) -> &str {
3008        self.inner.project_key()
3009    }
3010
3011    pub fn sqlite_path(&self) -> &Path {
3012        self.inner.sqlite_path()
3013    }
3014
3015    /// Report the open generation handle. SQLite-owned allocations are measured
3016    /// once by the process-wide SQLite allocator counters.
3017    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3018        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3019    }
3020
3021    /// Whether this reader is temporarily serving a legacy harness partition.
3022    pub fn is_legacy_fallback(&self) -> bool {
3023        self.inner.is_legacy_fallback()
3024    }
3025
3026    pub fn is_current(&self) -> bool {
3027        self.inner.is_current()
3028    }
3029
3030    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3031        self.inner.edge_snapshot()
3032    }
3033
3034    pub fn indexed_file_count(&self) -> Result<usize> {
3035        self.inner.indexed_file_count()
3036    }
3037
3038    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3039        self.inner.node_for(file_rel, symbol)
3040    }
3041
3042    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3043        self.inner.nodes_for(file_rel, symbol)
3044    }
3045
3046    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3047        self.inner.nodes_matching(symbol)
3048    }
3049
3050    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3051        self.inner.direct_callers_of(file_rel, symbol)
3052    }
3053
3054    pub fn direct_caller_counts_of(
3055        &self,
3056        targets: &[(String, String)],
3057    ) -> Result<HashMap<(String, String), usize>> {
3058        self.inner.direct_caller_counts_of(targets)
3059    }
3060
3061    pub fn callers_of(
3062        &self,
3063        file_rel: &Path,
3064        symbol: &str,
3065        depth: usize,
3066    ) -> Result<StoreCallersResult> {
3067        self.inner.callers_of(file_rel, symbol, depth)
3068    }
3069
3070    pub fn impact_of(
3071        &self,
3072        file_rel: &Path,
3073        symbol: &str,
3074        depth: usize,
3075    ) -> Result<StoreImpactResult> {
3076        self.inner.impact_of(file_rel, symbol, depth)
3077    }
3078
3079    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3080        self.inner.outgoing_calls_of(node)
3081    }
3082
3083    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3084        self.inner.resolved_self_calls_of(node)
3085    }
3086
3087    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3088        self.inner.unresolved_calls_of(node)
3089    }
3090
3091    pub fn call_tree(
3092        &self,
3093        file_rel: &Path,
3094        symbol: &str,
3095        depth: usize,
3096    ) -> Result<callgraph::CallTreeNode> {
3097        self.inner.call_tree(file_rel, symbol, depth)
3098    }
3099
3100    pub fn trace_to(
3101        &self,
3102        file_rel: &Path,
3103        symbol: &str,
3104        max_depth: usize,
3105    ) -> Result<callgraph::TraceToResult> {
3106        self.inner.trace_to(file_rel, symbol, max_depth)
3107    }
3108
3109    pub fn trace_to_symbol_candidates(
3110        &self,
3111        to_symbol: &str,
3112    ) -> Result<Vec<TraceToSymbolCandidate>> {
3113        self.inner.trace_to_symbol_candidates(to_symbol)
3114    }
3115
3116    pub fn trace_to_symbol(
3117        &self,
3118        file_rel: &Path,
3119        symbol: &str,
3120        to_symbol: &str,
3121        to_file: Option<&Path>,
3122        max_depth: usize,
3123    ) -> Result<callgraph::TraceToSymbolResult> {
3124        self.inner
3125            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3126    }
3127}
3128
3129impl CallGraphRead for CallGraphStore {
3130    fn project_root(&self) -> &Path {
3131        CallGraphStore::project_root(self)
3132    }
3133    fn project_key(&self) -> &str {
3134        CallGraphStore::project_key(self)
3135    }
3136    fn sqlite_path(&self) -> &Path {
3137        CallGraphStore::sqlite_path(self)
3138    }
3139    fn is_current(&self) -> bool {
3140        CallGraphStore::is_current(self)
3141    }
3142    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3143        CallGraphStore::edge_snapshot(self)
3144    }
3145    fn indexed_file_count(&self) -> Result<usize> {
3146        CallGraphStore::indexed_file_count(self)
3147    }
3148    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3149        CallGraphStore::node_for(self, file_rel, symbol)
3150    }
3151    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3152        CallGraphStore::nodes_for(self, file_rel, symbol)
3153    }
3154    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3155        CallGraphStore::nodes_matching(self, symbol)
3156    }
3157    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3158        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3159    }
3160    fn direct_caller_counts_of(
3161        &self,
3162        targets: &[(String, String)],
3163    ) -> Result<HashMap<(String, String), usize>> {
3164        CallGraphStore::direct_caller_counts_of(self, targets)
3165    }
3166    fn callers_of(
3167        &self,
3168        file_rel: &Path,
3169        symbol: &str,
3170        depth: usize,
3171    ) -> Result<StoreCallersResult> {
3172        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3173    }
3174    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3175        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3176    }
3177    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3178        CallGraphStore::outgoing_calls_of(self, node)
3179    }
3180    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3181        CallGraphStore::resolved_self_calls_of(self, node)
3182    }
3183    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3184        CallGraphStore::unresolved_calls_of(self, node)
3185    }
3186    fn call_tree(
3187        &self,
3188        file_rel: &Path,
3189        symbol: &str,
3190        depth: usize,
3191    ) -> Result<callgraph::CallTreeNode> {
3192        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3193    }
3194    fn trace_to(
3195        &self,
3196        file_rel: &Path,
3197        symbol: &str,
3198        max_depth: usize,
3199    ) -> Result<callgraph::TraceToResult> {
3200        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3201    }
3202    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3203        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3204    }
3205    fn trace_to_symbol(
3206        &self,
3207        file_rel: &Path,
3208        symbol: &str,
3209        to_symbol: &str,
3210        to_file: Option<&Path>,
3211        max_depth: usize,
3212    ) -> Result<callgraph::TraceToSymbolResult> {
3213        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3214    }
3215}
3216
3217impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3218    fn project_root(&self) -> &Path {
3219        (**self).project_root()
3220    }
3221    fn project_key(&self) -> &str {
3222        (**self).project_key()
3223    }
3224    fn sqlite_path(&self) -> &Path {
3225        (**self).sqlite_path()
3226    }
3227    fn is_current(&self) -> bool {
3228        (**self).is_current()
3229    }
3230    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3231        (**self).edge_snapshot()
3232    }
3233    fn indexed_file_count(&self) -> Result<usize> {
3234        (**self).indexed_file_count()
3235    }
3236    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3237        (**self).node_for(file_rel, symbol)
3238    }
3239    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3240        (**self).nodes_for(file_rel, symbol)
3241    }
3242    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3243        (**self).nodes_matching(symbol)
3244    }
3245    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3246        (**self).direct_callers_of(file_rel, symbol)
3247    }
3248    fn direct_caller_counts_of(
3249        &self,
3250        targets: &[(String, String)],
3251    ) -> Result<HashMap<(String, String), usize>> {
3252        (**self).direct_caller_counts_of(targets)
3253    }
3254    fn callers_of(
3255        &self,
3256        file_rel: &Path,
3257        symbol: &str,
3258        depth: usize,
3259    ) -> Result<StoreCallersResult> {
3260        (**self).callers_of(file_rel, symbol, depth)
3261    }
3262    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3263        (**self).impact_of(file_rel, symbol, depth)
3264    }
3265    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3266        (**self).outgoing_calls_of(node)
3267    }
3268    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3269        (**self).resolved_self_calls_of(node)
3270    }
3271    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3272        (**self).unresolved_calls_of(node)
3273    }
3274    fn call_tree(
3275        &self,
3276        file_rel: &Path,
3277        symbol: &str,
3278        depth: usize,
3279    ) -> Result<callgraph::CallTreeNode> {
3280        (**self).call_tree(file_rel, symbol, depth)
3281    }
3282    fn trace_to(
3283        &self,
3284        file_rel: &Path,
3285        symbol: &str,
3286        max_depth: usize,
3287    ) -> Result<callgraph::TraceToResult> {
3288        (**self).trace_to(file_rel, symbol, max_depth)
3289    }
3290    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3291        (**self).trace_to_symbol_candidates(to_symbol)
3292    }
3293    fn trace_to_symbol(
3294        &self,
3295        file_rel: &Path,
3296        symbol: &str,
3297        to_symbol: &str,
3298        to_file: Option<&Path>,
3299        max_depth: usize,
3300    ) -> Result<callgraph::TraceToSymbolResult> {
3301        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3302    }
3303}
3304
3305impl CallGraphRead for ReadonlyCallGraphStore {
3306    fn project_root(&self) -> &Path {
3307        self.project_root()
3308    }
3309    fn project_key(&self) -> &str {
3310        self.project_key()
3311    }
3312    fn sqlite_path(&self) -> &Path {
3313        self.sqlite_path()
3314    }
3315    fn is_current(&self) -> bool {
3316        self.is_current()
3317    }
3318    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3319        self.edge_snapshot()
3320    }
3321    fn indexed_file_count(&self) -> Result<usize> {
3322        self.indexed_file_count()
3323    }
3324    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3325        self.node_for(file_rel, symbol)
3326    }
3327    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3328        self.nodes_for(file_rel, symbol)
3329    }
3330    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3331        self.nodes_matching(symbol)
3332    }
3333    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3334        self.direct_callers_of(file_rel, symbol)
3335    }
3336    fn direct_caller_counts_of(
3337        &self,
3338        targets: &[(String, String)],
3339    ) -> Result<HashMap<(String, String), usize>> {
3340        self.direct_caller_counts_of(targets)
3341    }
3342    fn callers_of(
3343        &self,
3344        file_rel: &Path,
3345        symbol: &str,
3346        depth: usize,
3347    ) -> Result<StoreCallersResult> {
3348        self.callers_of(file_rel, symbol, depth)
3349    }
3350    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3351        self.impact_of(file_rel, symbol, depth)
3352    }
3353    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3354        self.outgoing_calls_of(node)
3355    }
3356    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3357        self.resolved_self_calls_of(node)
3358    }
3359    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3360        self.unresolved_calls_of(node)
3361    }
3362    fn call_tree(
3363        &self,
3364        file_rel: &Path,
3365        symbol: &str,
3366        depth: usize,
3367    ) -> Result<callgraph::CallTreeNode> {
3368        self.call_tree(file_rel, symbol, depth)
3369    }
3370    fn trace_to(
3371        &self,
3372        file_rel: &Path,
3373        symbol: &str,
3374        max_depth: usize,
3375    ) -> Result<callgraph::TraceToResult> {
3376        self.trace_to(file_rel, symbol, max_depth)
3377    }
3378    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3379        self.trace_to_symbol_candidates(to_symbol)
3380    }
3381    fn trace_to_symbol(
3382        &self,
3383        file_rel: &Path,
3384        symbol: &str,
3385        to_symbol: &str,
3386        to_file: Option<&Path>,
3387        max_depth: usize,
3388    ) -> Result<callgraph::TraceToSymbolResult> {
3389        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3390    }
3391}
3392
3393fn indexed_file_count(conn: &Connection) -> Result<usize> {
3394    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3395    Ok(count.max(0) as usize)
3396}
3397
3398fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3399    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3400    match candidates.as_slice() {
3401        [candidate] => Ok(candidate.clone()),
3402        [] => Err(AftError::SymbolNotFound {
3403            name: symbol.to_string(),
3404            file: rel_path.to_string(),
3405        }
3406        .into()),
3407        _ => Err(AftError::AmbiguousSymbol {
3408            name: symbol.to_string(),
3409            candidates: candidates
3410                .iter()
3411                .map(|candidate| candidate.symbol.clone())
3412                .collect(),
3413        }
3414        .into()),
3415    }
3416}
3417
3418fn nodes_for_file_matching_symbol(
3419    conn: &Connection,
3420    rel_path: &str,
3421    symbol: &str,
3422) -> Result<Vec<StoreNode>> {
3423    let qualified_query = symbol.contains("::");
3424    let sql = if qualified_query {
3425        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3426                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3427         FROM nodes n JOIN files f ON f.path = n.file_path
3428         WHERE n.file_path = ?1 AND n.scoped_name = ?2
3429         ORDER BY n.scoped_name, n.start_line, n.start_col"
3430    } else {
3431        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3432                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3433         FROM nodes n JOIN files f ON f.path = n.file_path
3434         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3435         ORDER BY n.scoped_name, n.start_line, n.start_col"
3436    };
3437    let mut stmt = conn.prepare(sql)?;
3438    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3439    rows.collect::<std::result::Result<Vec<_>, _>>()
3440        .map_err(Into::into)
3441}
3442
3443fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3444    let qualified_query = symbol.contains("::");
3445    let sql = if qualified_query {
3446        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3447                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3448         FROM nodes n JOIN files f ON f.path = n.file_path
3449         WHERE n.scoped_name = ?1
3450         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3451    } else {
3452        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3453                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3454         FROM nodes n JOIN files f ON f.path = n.file_path
3455         WHERE n.scoped_name = ?1 OR n.name = ?1
3456         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3457    };
3458    let mut stmt = conn.prepare(sql)?;
3459    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3460    rows.collect::<std::result::Result<Vec<_>, _>>()
3461        .map_err(Into::into)
3462}
3463
3464fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3465    store_node_from_row_at(row, 0)
3466}
3467
3468fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3469    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3470    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3471    let lang_label_value: String = row.get(offset + 10)?;
3472    Ok(StoreNode {
3473        node_id: row.get(offset)?,
3474        file: row.get(offset + 1)?,
3475        symbol: row.get(offset + 2)?,
3476        name: row.get(offset + 3)?,
3477        kind: row.get(offset + 4)?,
3478        line: start_line.saturating_add(1),
3479        end_line: end_line.saturating_add(1),
3480        signature: row.get(offset + 7)?,
3481        exported: row.get::<_, i64>(offset + 8)? != 0,
3482        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3483        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3484    })
3485}
3486
3487fn optional_store_node_from_row_at(
3488    row: &rusqlite::Row<'_>,
3489    offset: usize,
3490) -> rusqlite::Result<Option<StoreNode>> {
3491    if row.get::<_, Option<String>>(offset)?.is_some() {
3492        store_node_from_row_at(row, offset).map(Some)
3493    } else {
3494        Ok(None)
3495    }
3496}
3497
3498#[allow(clippy::too_many_arguments)]
3499fn collect_callers_recursive(
3500    conn: &Connection,
3501    file: &str,
3502    symbol: &str,
3503    max_depth: usize,
3504    current_depth: usize,
3505    visited: &mut HashSet<(String, String)>,
3506    result: &mut Vec<StoreCallSite>,
3507    depth_limited: &mut bool,
3508    truncated: &mut usize,
3509) -> Result<()> {
3510    if current_depth >= max_depth {
3511        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3512        if omitted > 0 {
3513            *depth_limited = true;
3514            *truncated += omitted;
3515        }
3516        return Ok(());
3517    }
3518
3519    if !visited.insert((file.to_string(), symbol.to_string())) {
3520        return Ok(());
3521    }
3522
3523    let sites = direct_callers_for_tuple(conn, file, symbol)?;
3524    for site in sites {
3525        result.push(site.clone());
3526        if current_depth + 1 < max_depth {
3527            collect_callers_recursive(
3528                conn,
3529                &site.caller.file,
3530                &site.caller.symbol,
3531                max_depth,
3532                current_depth + 1,
3533                visited,
3534                result,
3535                depth_limited,
3536                truncated,
3537            )?;
3538        } else {
3539            let omitted =
3540                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3541            if omitted > 0 {
3542                *depth_limited = true;
3543                *truncated += omitted;
3544            }
3545        }
3546    }
3547    Ok(())
3548}
3549
3550// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
3551const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
3552
3553fn direct_caller_counts_for_tuples(
3554    conn: &Connection,
3555    targets: &[(String, String)],
3556) -> Result<HashMap<(String, String), usize>> {
3557    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
3558    let mut counts = unique_targets
3559        .iter()
3560        .cloned()
3561        .map(|target| (target, 0usize))
3562        .collect::<HashMap<_, _>>();
3563
3564    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
3565    for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
3566        let requested_values = (0..chunk.len())
3567            .map(|_| "(?, ?)")
3568            .collect::<Vec<_>>()
3569            .join(", ");
3570        let sql = format!(
3571            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
3572             deduped AS (
3573                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
3574                 FROM requested requested
3575                 JOIN edges e
3576                   ON e.target_file = requested.target_file
3577                  AND e.target_symbol = requested.target_symbol
3578                  AND e.kind = 'call'
3579                 JOIN refs r ON r.ref_id = e.ref_id
3580                 JOIN nodes src ON src.id = e.source_node
3581                 JOIN files src_file ON src_file.path = src.file_path
3582                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
3583             )
3584             SELECT target_file, target_symbol, COUNT(*)
3585             FROM deduped
3586             GROUP BY target_file, target_symbol"
3587        );
3588        let bindings = chunk
3589            .iter()
3590            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3591        let mut stmt = conn.prepare(&sql)?;
3592        let rows = stmt.query_map(params_from_iter(bindings), |row| {
3593            Ok((
3594                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3595                row.get::<_, i64>(2)?,
3596            ))
3597        })?;
3598        for row in rows {
3599            let (target, count) = row?;
3600            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
3601        }
3602    }
3603
3604    Ok(counts)
3605}
3606
3607fn direct_caller_count_for_tuple(
3608    conn: &Connection,
3609    target_file: &str,
3610    target_symbol: &str,
3611) -> Result<usize> {
3612    let count: i64 = conn.query_row(
3613        "SELECT COUNT(*)
3614         FROM edges e
3615         JOIN refs r ON r.ref_id = e.ref_id
3616         JOIN nodes src ON src.id = e.source_node
3617         JOIN files src_file ON src_file.path = src.file_path
3618         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3619        params![target_file, target_symbol],
3620        |row| row.get(0),
3621    )?;
3622    Ok(usize::try_from(count).unwrap_or(usize::MAX))
3623}
3624
3625fn direct_callers_for_tuple(
3626    conn: &Connection,
3627    target_file: &str,
3628    target_symbol: &str,
3629) -> Result<Vec<StoreCallSite>> {
3630    let mut stmt = conn.prepare(
3631        "SELECT e.target_file, e.target_symbol, e.line,
3632                r.byte_start, r.byte_end, r.status, e.provenance,
3633                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3634                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3635                src_file.lang,
3636                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3637                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3638                tgt_file.lang
3639         FROM edges e
3640         JOIN refs r ON r.ref_id = e.ref_id
3641         JOIN nodes src ON src.id = e.source_node
3642         JOIN files src_file ON src_file.path = src.file_path
3643         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3644             ON tgt.id = e.target_node
3645         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3646         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3647    )?;
3648    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3649        let caller = store_node_from_row_at(row, 7)?;
3650        let target = optional_store_node_from_row_at(row, 18)?;
3651        Ok(StoreCallSite {
3652            caller,
3653            target_file: row.get(0)?,
3654            target_symbol: row.get(1)?,
3655            target,
3656            line: row.get::<_, i64>(2)?.max(0) as u32,
3657            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3658            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3659            resolved: row.get::<_, String>(5)? == "resolved",
3660            provenance: row.get(6)?,
3661        })
3662    })?;
3663    rows.collect::<std::result::Result<Vec<_>, _>>()
3664        .map_err(Into::into)
3665}
3666
3667fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3668    let mut stmt = conn.prepare(
3669        "SELECT e.target_file, e.target_symbol, e.line,
3670                r.byte_start, r.byte_end, r.status, e.provenance,
3671                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3672                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3673                tgt_file.lang
3674         FROM edges e
3675         JOIN refs r ON r.ref_id = e.ref_id
3676         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3677             ON tgt.id = e.target_node
3678         WHERE e.kind = 'call' AND e.source_node = ?1
3679         ORDER BY r.byte_start, r.line, r.ref_id",
3680    )?;
3681    let rows = stmt.query_map(params![node.node_id], |row| {
3682        let target = optional_store_node_from_row_at(row, 7)?;
3683        Ok(StoreCallSite {
3684            caller: node.clone(),
3685            target_file: row.get(0)?,
3686            target_symbol: row.get(1)?,
3687            target,
3688            line: row.get::<_, i64>(2)?.max(0) as u32,
3689            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3690            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3691            resolved: row.get::<_, String>(5)? == "resolved",
3692            provenance: row.get(6)?,
3693        })
3694    })?;
3695    rows.collect::<std::result::Result<Vec<_>, _>>()
3696        .map_err(Into::into)
3697}
3698
3699fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3700    let mut stmt = conn.prepare(
3701        "SELECT r.target_file, r.target_symbol, r.line,
3702                r.byte_start, r.byte_end, r.status, r.provenance,
3703                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3704                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3705                tgt_file.lang
3706         FROM refs r
3707         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3708             ON tgt.id = r.target_node
3709         WHERE r.caller_node = ?1
3710           AND r.kind = 'call'
3711           AND r.status <> 'unresolved'
3712           AND r.target_file = ?2
3713           AND r.target_symbol = ?3
3714           AND r.provenance = ?4
3715           AND NOT EXISTS (
3716               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
3717           )
3718         ORDER BY r.byte_start, r.line, r.ref_id",
3719    )?;
3720    let rows = stmt.query_map(
3721        params![
3722            &node.node_id,
3723            &node.file,
3724            &node.symbol,
3725            PROVENANCE_TREESITTER
3726        ],
3727        |row| {
3728            let target = optional_store_node_from_row_at(row, 7)?;
3729            Ok(StoreCallSite {
3730                caller: node.clone(),
3731                target_file: row.get(0)?,
3732                target_symbol: row.get(1)?,
3733                target,
3734                line: row.get::<_, i64>(2)?.max(0) as u32,
3735                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3736                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3737                resolved: row.get::<_, String>(5)? == "resolved",
3738                provenance: row.get(6)?,
3739            })
3740        },
3741    )?;
3742    rows.collect::<std::result::Result<Vec<_>, _>>()
3743        .map_err(Into::into)
3744}
3745
3746fn unresolved_calls_for_node(
3747    conn: &Connection,
3748    node: &StoreNode,
3749) -> Result<Vec<StoreUnresolvedCall>> {
3750    let mut stmt = conn.prepare(
3751        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
3752         FROM refs
3753         WHERE caller_node = ?1
3754           AND kind = 'call'
3755           AND status = 'unresolved'
3756           AND NOT EXISTS (
3757               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3758           )
3759         ORDER BY byte_start, line, ref_id",
3760    )?;
3761    let rows = stmt.query_map(params![node.node_id], |row| {
3762        Ok(StoreUnresolvedCall {
3763            caller: node.clone(),
3764            symbol: row.get(0)?,
3765            full_ref: row.get(1)?,
3766            line: row.get::<_, i64>(2)?.max(0) as u32,
3767            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3768            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3769        })
3770    })?;
3771    rows.collect::<std::result::Result<Vec<_>, _>>()
3772        .map_err(Into::into)
3773}
3774
3775fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
3776    let mut calls = Vec::new();
3777    calls.extend(
3778        outgoing_calls_for_node(conn, node)?
3779            .into_iter()
3780            .map(StoreForwardCall::Resolved),
3781    );
3782    calls.extend(
3783        unresolved_calls_for_node(conn, node)?
3784            .into_iter()
3785            .map(StoreForwardCall::Unresolved),
3786    );
3787    calls.sort_by(|left, right| {
3788        left.byte_start()
3789            .cmp(&right.byte_start())
3790            .then(left.line().cmp(&right.line()))
3791    });
3792    Ok(calls)
3793}
3794
3795fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
3796    let resolved_count: i64 = conn.query_row(
3797        "SELECT COUNT(*)
3798         FROM edges e
3799         JOIN refs r ON r.ref_id = e.ref_id
3800         WHERE e.kind = 'call' AND e.source_node = ?1",
3801        params![&node.node_id],
3802        |row| row.get(0),
3803    )?;
3804    let unresolved_count: i64 = conn.query_row(
3805        "SELECT COUNT(*)
3806         FROM refs
3807         WHERE caller_node = ?1
3808           AND kind = 'call'
3809           AND status = 'unresolved'
3810           AND NOT EXISTS (
3811               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
3812           )",
3813        params![&node.node_id],
3814        |row| row.get(0),
3815    )?;
3816    let total = resolved_count.saturating_add(unresolved_count);
3817    Ok(usize::try_from(total).unwrap_or(usize::MAX))
3818}
3819
3820fn call_tree_inner(
3821    conn: &Connection,
3822    node: &StoreNode,
3823    max_depth: usize,
3824    current_depth: usize,
3825    visited: &mut HashSet<(String, String)>,
3826) -> Result<callgraph::CallTreeNode> {
3827    let visit_key = (node.file.clone(), node.symbol.clone());
3828    if visited.contains(&visit_key) {
3829        return Ok(callgraph::CallTreeNode {
3830            name: node.symbol.clone(),
3831            file: node.file.clone(),
3832            line: node.line,
3833            signature: node.signature.clone(),
3834            resolved: true,
3835            children: Vec::new(),
3836            depth_limited: false,
3837            truncated: 0,
3838        });
3839    }
3840    visited.insert(visit_key.clone());
3841
3842    let mut children = Vec::new();
3843    let mut depth_limited = false;
3844    let mut truncated = 0usize;
3845
3846    if current_depth < max_depth {
3847        let calls = forward_calls_for_node(conn, node)?;
3848        for call in calls {
3849            match call {
3850                StoreForwardCall::Resolved(site) => {
3851                    if let Some(target) = site.target {
3852                        let child =
3853                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
3854                        depth_limited |= child.depth_limited;
3855                        truncated += child.truncated;
3856                        children.push(child);
3857                    } else {
3858                        children.push(callgraph::CallTreeNode {
3859                            name: site.target_symbol,
3860                            file: site.target_file,
3861                            line: site.line,
3862                            signature: None,
3863                            resolved: false,
3864                            children: Vec::new(),
3865                            depth_limited: false,
3866                            truncated: 0,
3867                        });
3868                    }
3869                }
3870                StoreForwardCall::Unresolved(call) => {
3871                    children.push(callgraph::CallTreeNode {
3872                        name: call.symbol,
3873                        file: call.caller.file,
3874                        line: call.line,
3875                        signature: None,
3876                        resolved: false,
3877                        children: Vec::new(),
3878                        depth_limited: false,
3879                        truncated: 0,
3880                    });
3881                }
3882            }
3883        }
3884    } else {
3885        truncated = forward_call_count_for_node(conn, node)?;
3886        depth_limited = truncated > 0;
3887    }
3888
3889    visited.remove(&visit_key);
3890    Ok(callgraph::CallTreeNode {
3891        name: node.symbol.clone(),
3892        file: node.file.clone(),
3893        line: node.line,
3894        signature: node.signature.clone(),
3895        resolved: true,
3896        children,
3897        depth_limited,
3898        truncated,
3899    })
3900}
3901
3902fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
3903    callgraph::TraceToSymbolHop {
3904        symbol: node.symbol.clone(),
3905        file: node.file.clone(),
3906        line: node.line,
3907    }
3908}
3909
3910fn trace_to_symbol_matches_target(
3911    node: &StoreNode,
3912    to_symbol: &str,
3913    to_file: Option<&str>,
3914) -> bool {
3915    if !symbol_query_matches(&node.symbol, to_symbol) {
3916        return false;
3917    }
3918    match to_file {
3919        Some(file) => node.file == file,
3920        None => true,
3921    }
3922}
3923
3924fn symbol_query_matches(symbol: &str, query: &str) -> bool {
3925    symbol == query || unqualified_name(symbol) == query
3926}
3927
3928fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
3929    let source = std::fs::read_to_string(path).ok()?;
3930    Some(source.lines().map(|line| line.trim().to_string()).collect())
3931}
3932
3933#[doc(hidden)]
3934pub fn live_callgraph_edge_snapshot(
3935    project_root: &Path,
3936    files: &[PathBuf],
3937) -> Result<BTreeSet<StoredEdge>> {
3938    let files = normalize_file_list(project_root, files)?;
3939    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
3940    let mut file_data = Vec::new();
3941    for file in &files {
3942        let canon = canonicalize_path(file);
3943        let data = graph.build_file(&canon)?.clone();
3944        file_data.push((canon, data));
3945    }
3946
3947    let mut edges = BTreeSet::new();
3948    for (caller_file, data) in &file_data {
3949        for (caller_symbol, call_sites) in &data.calls_by_symbol {
3950            for call_site in call_sites {
3951                let resolution = graph.resolve_cross_file_edge(
3952                    &call_site.full_callee,
3953                    &call_site.callee_name,
3954                    caller_file,
3955                    &data.import_block,
3956                );
3957                let (target_file, target_symbol) = match resolution {
3958                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
3959                    EdgeResolution::Unresolved { callee_name } => {
3960                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
3961                            continue;
3962                        }
3963                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
3964                            data,
3965                            caller_file,
3966                            &callee_name,
3967                        ) else {
3968                            continue;
3969                        };
3970                        (caller_file.clone(), target_symbol)
3971                    }
3972                };
3973                if target_file == *caller_file && target_symbol == *caller_symbol {
3974                    continue;
3975                }
3976                edges.insert(StoredEdge {
3977                    source_file: relative_path(project_root, caller_file),
3978                    source_symbol: caller_symbol.clone(),
3979                    target_file: relative_path(project_root, &target_file),
3980                    target_symbol,
3981                    kind: "call".to_string(),
3982                    line: call_site.line,
3983                });
3984            }
3985        }
3986    }
3987    Ok(edges)
3988}
3989
3990fn acquire_writer_lease(
3991    callgraph_dir: &Path,
3992    project_key: &str,
3993    project_root: &Path,
3994) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
3995    crate::root_cache::WriterLease::acquire_shared(
3996        crate::root_cache::RootCacheDomain::Callgraph,
3997        callgraph_dir,
3998        project_key,
3999        project_root,
4000    )
4001    .map_err(CallGraphStoreError::from)
4002}
4003
4004fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4005    if lease.verify()? {
4006        Ok(())
4007    } else {
4008        Err(CallGraphStoreError::Unavailable(format!(
4009            "callgraph writer lease for key {} lost epoch {}; aborting write",
4010            lease.key(),
4011            lease.epoch()
4012        )))
4013    }
4014}
4015
4016fn legacy_migration_completion_line(
4017    project_key: &str,
4018    method: &str,
4019    legacy_bytes: u64,
4020    migrated_bytes: u64,
4021) -> String {
4022    format!(
4023        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4024    )
4025}
4026
4027fn log_legacy_migration_completion(
4028    project_key: &str,
4029    method: &str,
4030    legacy_bytes: u64,
4031    migrated_bytes: u64,
4032) {
4033    crate::slog_info!(
4034        "{}",
4035        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4036    );
4037}
4038
4039fn try_legacy_migration_or_fallback(
4040    callgraph_dir: &Path,
4041    project_root: &Path,
4042    project_key: &str,
4043    writer_lease: Arc<crate::root_cache::WriterLease>,
4044) -> Result<Option<CallGraphStore>> {
4045    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4046    if partitions.is_empty() {
4047        return Ok(None);
4048    }
4049
4050    for partition in &partitions {
4051        if let Some(source) = newest_superseded_legacy_generation(partition)? {
4052            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4053                return open_legacy_fallback_store(
4054                    callgraph_dir,
4055                    project_root,
4056                    project_key,
4057                    &partitions,
4058                );
4059            }
4060            match publish_generation_copy_migration(
4061                callgraph_dir,
4062                project_key,
4063                &source,
4064                Arc::clone(&writer_lease),
4065            ) {
4066                Ok(published) => {
4067                    log_legacy_migration_completion(
4068                        project_key,
4069                        "generation_copy",
4070                        source.source_bytes,
4071                        published.migrated_bytes,
4072                    );
4073                    return CallGraphStore::open_generation(
4074                        callgraph_dir,
4075                        project_root.to_path_buf(),
4076                        project_key.to_string(),
4077                        published.generation,
4078                        writer_lease,
4079                    )
4080                    .map(Some);
4081                }
4082                Err(error) => {
4083                    crate::slog_warn!(
4084                        "root-keyed callgraph generation-copy migration failed from {}: {}",
4085                        source.sqlite_path.display(),
4086                        error
4087                    );
4088                    return open_legacy_fallback_store(
4089                        callgraph_dir,
4090                        project_root,
4091                        project_key,
4092                        &partitions,
4093                    );
4094                }
4095            }
4096        }
4097
4098        if let Some(source) = current_legacy_generation(partition)? {
4099            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4100                return open_legacy_fallback_store(
4101                    callgraph_dir,
4102                    project_root,
4103                    project_key,
4104                    &partitions,
4105                );
4106            }
4107            match publish_backup_migration(
4108                callgraph_dir,
4109                project_key,
4110                &source,
4111                Arc::clone(&writer_lease),
4112            ) {
4113                Ok(published) => {
4114                    log_legacy_migration_completion(
4115                        project_key,
4116                        "sqlite_backup",
4117                        source.source_bytes,
4118                        published.migrated_bytes,
4119                    );
4120                    return CallGraphStore::open_generation(
4121                        callgraph_dir,
4122                        project_root.to_path_buf(),
4123                        project_key.to_string(),
4124                        published.generation,
4125                        writer_lease,
4126                    )
4127                    .map(Some);
4128                }
4129                Err(error) => {
4130                    crate::slog_warn!(
4131                        "root-keyed callgraph backup migration failed from {}: {}",
4132                        source.sqlite_path.display(),
4133                        error
4134                    );
4135                    return open_legacy_fallback_store(
4136                        callgraph_dir,
4137                        project_root,
4138                        project_key,
4139                        &partitions,
4140                    );
4141                }
4142            }
4143        }
4144    }
4145
4146    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4147}
4148
4149fn open_legacy_fallback_store(
4150    callgraph_dir: &Path,
4151    project_root: &Path,
4152    project_key: &str,
4153    partitions: &[LegacyCallgraphPartition],
4154) -> Result<Option<CallGraphStore>> {
4155    let Some(target) = first_ready_legacy_target(partitions)? else {
4156        return Ok(None);
4157    };
4158    crate::slog_warn!(
4159        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4160        target.partition.harness,
4161        target.sqlite_path.display()
4162    );
4163    let conn = open_readonly_connection(&target.sqlite_path)?;
4164    if !database_ready(&conn).unwrap_or(false) {
4165        return Ok(None);
4166    }
4167    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4168    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4169    Ok(Some(CallGraphStore::from_connection(
4170        project_root.to_path_buf(),
4171        project_key.to_string(),
4172        target.sqlite_path,
4173        callgraph_dir.to_path_buf(),
4174        true,
4175        target.generation,
4176        None,
4177        Some(read_marker),
4178        conn,
4179    )))
4180}
4181
4182fn migration_disk_floor_allows(
4183    source: &LegacyCallgraphTarget,
4184    callgraph_dir: &Path,
4185) -> Result<bool> {
4186    let available = migration_available_disk(callgraph_dir)?;
4187    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4188        source.source_bytes,
4189        available,
4190    );
4191    if decision.should_skip_copy() {
4192        crate::slog_warn!(
4193            "{}",
4194            decision.warning_message(&source.sqlite_path, callgraph_dir)
4195        );
4196        return Ok(false);
4197    }
4198    Ok(true)
4199}
4200
4201fn migration_available_disk(path: &Path) -> Result<u64> {
4202    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4203        return Ok(bytes);
4204    }
4205    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4206}
4207
4208fn legacy_callgraph_partitions(
4209    callgraph_dir: &Path,
4210    project_key: &str,
4211) -> Result<Vec<LegacyCallgraphPartition>> {
4212    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4213        return Ok(Vec::new());
4214    };
4215    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4216    let mut partitions = inventory
4217        .into_iter()
4218        .filter(|entry| {
4219            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4220                && entry.key == project_key
4221        })
4222        .map(|entry| {
4223            let dir = if entry.path.is_dir() {
4224                entry.path.clone()
4225            } else {
4226                entry
4227                    .path
4228                    .parent()
4229                    .map(Path::to_path_buf)
4230                    .unwrap_or_else(|| entry.path.clone())
4231            };
4232            LegacyCallgraphPartition {
4233                harness: entry.harness,
4234                dir,
4235                key: entry.key,
4236                bytes: entry.bytes,
4237                freshness: entry.callgraph_pointer_mtime,
4238            }
4239        })
4240        .collect::<Vec<_>>();
4241    partitions.sort_by(|left, right| {
4242        right
4243            .freshness
4244            .cmp(&left.freshness)
4245            .then_with(|| right.bytes.cmp(&left.bytes))
4246            .then_with(|| left.harness.cmp(&right.harness))
4247    });
4248    Ok(partitions)
4249}
4250
4251fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4252    let domain_dir = callgraph_dir.parent()?;
4253    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4254        return None;
4255    }
4256    domain_dir.parent().map(Path::to_path_buf)
4257}
4258
4259pub(crate) fn all_legacy_partitions_migrated_for_keys(
4260    callgraph_dir: &Path,
4261    configured_keys: &BTreeSet<String>,
4262) -> Result<bool> {
4263    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4264        return Ok(false);
4265    };
4266    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4267        .into_iter()
4268        .filter(|entry| {
4269            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4270                && configured_keys.contains(&entry.key)
4271        })
4272        .map(|entry| entry.key)
4273        .collect::<BTreeSet<_>>();
4274    if legacy_keys.is_empty() {
4275        return Ok(false);
4276    }
4277
4278    for key in legacy_keys {
4279        let migrated_dir = storage_root.join("callgraph").join(&key);
4280        let Some(generation) = read_pointer(&migrated_dir, &key) else {
4281            return Ok(false);
4282        };
4283        if !migration_generation_requires_manifest(&generation)
4284            || !migration_manifest_valid(&migrated_dir, &generation)
4285        {
4286            return Ok(false);
4287        }
4288    }
4289    Ok(true)
4290}
4291
4292fn newest_superseded_legacy_generation(
4293    partition: &LegacyCallgraphPartition,
4294) -> Result<Option<LegacyCallgraphTarget>> {
4295    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4296        return Ok(None);
4297    };
4298    let prefix = format!("{}.g", partition.key);
4299    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4300        return Ok(None);
4301    };
4302    let mut candidates = Vec::new();
4303    for entry in entries.flatten() {
4304        let name = entry.file_name().to_string_lossy().to_string();
4305        if name == current
4306            || name.contains(".tmp.")
4307            || !name.starts_with(&prefix)
4308            || !name.ends_with(".sqlite")
4309        {
4310            continue;
4311        }
4312        let path = entry.path();
4313        if !db_path_ready(&path) {
4314            continue;
4315        }
4316        let modified = entry
4317            .metadata()
4318            .and_then(|metadata| metadata.modified())
4319            .unwrap_or(SystemTime::UNIX_EPOCH);
4320        candidates.push((modified, path, name));
4321    }
4322    candidates.sort_by(|left, right| right.0.cmp(&left.0));
4323    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4324        return Ok(None);
4325    };
4326    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4327    Ok(Some(LegacyCallgraphTarget {
4328        partition: partition.clone(),
4329        sqlite_path,
4330        generation: Some(generation),
4331        source_bytes,
4332        source_blake3: String::new(),
4333    }))
4334}
4335
4336fn current_legacy_generation(
4337    partition: &LegacyCallgraphPartition,
4338) -> Result<Option<LegacyCallgraphTarget>> {
4339    let Some(target) = ready_legacy_target(partition)? else {
4340        return Ok(None);
4341    };
4342    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4343    if has_superseded {
4344        return Ok(None);
4345    }
4346    Ok(Some(target))
4347}
4348
4349fn freshest_legacy_fallback_target(
4350    callgraph_dir: &Path,
4351    project_key: &str,
4352) -> Result<Option<LegacyCallgraphTarget>> {
4353    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4354    first_ready_legacy_target(&partitions)
4355}
4356
4357fn first_ready_legacy_target(
4358    partitions: &[LegacyCallgraphPartition],
4359) -> Result<Option<LegacyCallgraphTarget>> {
4360    for partition in partitions {
4361        if let Some(target) = ready_legacy_target(partition)? {
4362            return Ok(Some(target));
4363        }
4364    }
4365    Ok(None)
4366}
4367
4368fn ready_legacy_target(
4369    partition: &LegacyCallgraphPartition,
4370) -> Result<Option<LegacyCallgraphTarget>> {
4371    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4372        let sqlite_path = partition.dir.join(&generation);
4373        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4374            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4375            return Ok(Some(LegacyCallgraphTarget {
4376                partition: partition.clone(),
4377                sqlite_path,
4378                generation: Some(generation),
4379                source_bytes,
4380                source_blake3: String::new(),
4381            }));
4382        }
4383    }
4384
4385    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4386    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4387        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4388        return Ok(Some(LegacyCallgraphTarget {
4389            partition: partition.clone(),
4390            sqlite_path,
4391            generation: None,
4392            source_bytes,
4393            source_blake3: String::new(),
4394        }));
4395    }
4396    Ok(None)
4397}
4398
4399fn publish_generation_copy_migration(
4400    callgraph_dir: &Path,
4401    project_key: &str,
4402    source: &LegacyCallgraphTarget,
4403    writer_lease: Arc<crate::root_cache::WriterLease>,
4404) -> Result<PublishedLegacyMigration> {
4405    let generation = migration_generation_file_name(project_key, "copy");
4406    let temp_path = migration_temp_path(callgraph_dir, &generation);
4407    remove_sqlite_file_set(&temp_path);
4408    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4409    fail_after_temp_copy_for_test()?;
4410
4411    let mut source = source.clone();
4412    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4413    source.source_blake3 = fingerprint.blake3;
4414    let generation = publish_migrated_generation(
4415        callgraph_dir,
4416        project_key,
4417        &generation,
4418        &temp_path,
4419        &source,
4420        fingerprint.bytes,
4421        writer_lease,
4422        "generation_copy",
4423    )?;
4424    Ok(PublishedLegacyMigration {
4425        generation,
4426        migrated_bytes: fingerprint.bytes,
4427    })
4428}
4429
4430fn publish_backup_migration(
4431    callgraph_dir: &Path,
4432    project_key: &str,
4433    source: &LegacyCallgraphTarget,
4434    writer_lease: Arc<crate::root_cache::WriterLease>,
4435) -> Result<PublishedLegacyMigration> {
4436    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4437        return Err(CallGraphStoreError::Unavailable(
4438            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4439        ));
4440    }
4441
4442    let generation = migration_generation_file_name(project_key, "backup");
4443    let temp_path = migration_temp_path(callgraph_dir, &generation);
4444    remove_sqlite_file_set(&temp_path);
4445
4446    let source_conn = open_readonly_connection(&source.sqlite_path)?;
4447    let mut destination = Connection::open(&temp_path)?;
4448    destination.busy_timeout(Duration::from_secs(5))?;
4449    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4450    let started = Instant::now();
4451    let mut retries = 0;
4452    loop {
4453        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4454            rusqlite::backup::StepResult::Done => break,
4455            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4456            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4457                retries += 1;
4458                if retries > MIGRATION_BACKUP_RETRY_BUDGET
4459                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4460                {
4461                    return Err(CallGraphStoreError::Unavailable(format!(
4462                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4463                    )));
4464                }
4465                std::thread::sleep(Duration::from_millis(20));
4466            }
4467            _ => {
4468                return Err(CallGraphStoreError::Unavailable(
4469                    "legacy callgraph backup returned an unknown step result".to_string(),
4470                ));
4471            }
4472        }
4473    }
4474    drop(backup);
4475
4476    let integrity: String =
4477        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4478    if integrity != "ok" {
4479        return Err(CallGraphStoreError::Unavailable(format!(
4480            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4481        )));
4482    }
4483    if !database_ready(&destination)? {
4484        return Err(CallGraphStoreError::Unavailable(
4485            "legacy callgraph backup produced a database without ready metadata".to_string(),
4486        ));
4487    }
4488    destination.execute_batch("PRAGMA optimize;")?;
4489    drop(destination);
4490    sync_file(&temp_path)?;
4491    fail_after_temp_copy_for_test()?;
4492
4493    let mut source = source.clone();
4494    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4495    source.source_blake3 = fingerprint.blake3;
4496    let generation = publish_migrated_generation(
4497        callgraph_dir,
4498        project_key,
4499        &generation,
4500        &temp_path,
4501        &source,
4502        fingerprint.bytes,
4503        writer_lease,
4504        "sqlite_backup",
4505    )?;
4506    Ok(PublishedLegacyMigration {
4507        generation,
4508        migrated_bytes: fingerprint.bytes,
4509    })
4510}
4511
4512fn publish_migrated_generation(
4513    callgraph_dir: &Path,
4514    project_key: &str,
4515    generation: &str,
4516    temp_path: &Path,
4517    source: &LegacyCallgraphTarget,
4518    migrated_bytes: u64,
4519    writer_lease: Arc<crate::root_cache::WriterLease>,
4520    method: &str,
4521) -> Result<String> {
4522    let gen_path = callgraph_dir.join(generation);
4523    let publication = publish_if_current(|| {
4524        verify_writer_lease(&writer_lease)?;
4525        remove_sqlite_file_set(&gen_path);
4526        rename_sqlite_file_set(temp_path, &gen_path)?;
4527        crate::fs_lock::sync_parent(&gen_path);
4528
4529        verify_writer_lease(&writer_lease)?;
4530        publish_pointer(callgraph_dir, project_key, generation)?;
4531        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
4532        Ok(generation.to_string())
4533    });
4534    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
4535        remove_sqlite_file_set(temp_path);
4536    }
4537    publication
4538}
4539
4540fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4541    if let Some(parent) = destination.parent() {
4542        std::fs::create_dir_all(parent)?;
4543    }
4544    for suffix in SQLITE_FILE_SET_SUFFIXES {
4545        let source_path = sqlite_file_set_path(source, suffix);
4546        if !source_path.is_file() {
4547            continue;
4548        }
4549        let destination_path = sqlite_file_set_path(destination, suffix);
4550        std::fs::copy(&source_path, &destination_path)?;
4551        sync_file(&destination_path)?;
4552    }
4553    Ok(())
4554}
4555
4556fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4557    for suffix in SQLITE_FILE_SET_SUFFIXES {
4558        let source_path = sqlite_file_set_path(source, suffix);
4559        if !source_path.exists() {
4560            continue;
4561        }
4562        let destination_path = sqlite_file_set_path(destination, suffix);
4563        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
4564            let _ = std::fs::remove_file(&source_path);
4565            return Err(error.into());
4566        }
4567    }
4568    Ok(())
4569}
4570
4571fn sqlite_file_set_size(path: &Path) -> Result<u64> {
4572    let mut bytes = 0_u64;
4573    for suffix in SQLITE_FILE_SET_SUFFIXES {
4574        let member = sqlite_file_set_path(path, suffix);
4575        if !member.is_file() {
4576            continue;
4577        }
4578        bytes = bytes.saturating_add(member.metadata()?.len());
4579    }
4580    Ok(bytes)
4581}
4582
4583fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
4584    let mut hasher = blake3::Hasher::new();
4585    let mut bytes = 0_u64;
4586    let mut buffer = [0_u8; 64 * 1024];
4587    for suffix in SQLITE_FILE_SET_SUFFIXES {
4588        let member = sqlite_file_set_path(path, suffix);
4589        if !member.is_file() {
4590            continue;
4591        }
4592        hasher.update(suffix.as_bytes());
4593        let mut file = std::fs::File::open(&member)?;
4594        loop {
4595            let read = file.read(&mut buffer)?;
4596            if read == 0 {
4597                break;
4598            }
4599            bytes = bytes.saturating_add(read as u64);
4600            hasher.update(&buffer[..read]);
4601        }
4602    }
4603    Ok(SourceFingerprint {
4604        bytes,
4605        blake3: hash_to_hex(hasher.finalize()),
4606    })
4607}
4608
4609fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
4610    if suffix.is_empty() {
4611        path.to_path_buf()
4612    } else {
4613        PathBuf::from(format!("{}{suffix}", path.display()))
4614    }
4615}
4616
4617fn sync_file(path: &Path) -> Result<()> {
4618    let file = std::fs::OpenOptions::new()
4619        .read(true)
4620        .write(true)
4621        .open(path)?;
4622    file.sync_all()?;
4623    Ok(())
4624}
4625
4626fn fail_after_temp_copy_for_test() -> Result<()> {
4627    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
4628        return Err(CallGraphStoreError::Unavailable(
4629            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
4630        ));
4631    }
4632    Ok(())
4633}
4634
4635fn migration_generation_file_name(project_key: &str, method: &str) -> String {
4636    format!(
4637        "{project_key}.g{}.{}{}{}.sqlite",
4638        now_nanos(),
4639        std::process::id(),
4640        MIGRATION_GENERATION_TAG,
4641        method
4642    )
4643}
4644
4645fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4646    callgraph_dir.join(format!(
4647        "{generation}.tmp.{}.{}",
4648        std::process::id(),
4649        now_nanos()
4650    ))
4651}
4652
4653fn write_migration_manifest(
4654    callgraph_dir: &Path,
4655    generation: &str,
4656    source: &LegacyCallgraphTarget,
4657    migrated_bytes: u64,
4658    method: &str,
4659) -> Result<()> {
4660    let manifest_path = migration_manifest_path(callgraph_dir, generation);
4661    let temp_path = manifest_path.with_extension(format!(
4662        "migration.json.tmp.{}.{}",
4663        std::process::id(),
4664        now_nanos()
4665    ));
4666    let manifest = serde_json::json!({
4667        "version": MIGRATION_MANIFEST_VERSION,
4668        "method": method,
4669        "target_generation": generation,
4670        "source_harness": source.partition.harness,
4671        "source_path": source.sqlite_path.display().to_string(),
4672        "source_generation": source.generation,
4673        "source_bytes": source.source_bytes,
4674        "source_blake3": source.source_blake3,
4675        "migrated_bytes": migrated_bytes,
4676    });
4677    {
4678        use std::io::Write as _;
4679        let mut file = std::fs::File::create(&temp_path)?;
4680        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
4681        file.write_all(b"\n")?;
4682        file.sync_all()?;
4683    }
4684    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
4685        let _ = std::fs::remove_file(&temp_path);
4686        return Err(error.into());
4687    }
4688    crate::fs_lock::sync_parent(&manifest_path);
4689    Ok(())
4690}
4691
4692fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4693    callgraph_dir.join(format!("{generation}.migration.json"))
4694}
4695
4696fn migration_generation_requires_manifest(generation: &str) -> bool {
4697    generation.contains(MIGRATION_GENERATION_TAG)
4698}
4699
4700fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
4701    if !migration_generation_requires_manifest(generation) {
4702        return true;
4703    }
4704    let path = migration_manifest_path(callgraph_dir, generation);
4705    let Ok(bytes) = std::fs::read(path) else {
4706        return false;
4707    };
4708    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
4709        return false;
4710    };
4711    value.get("version").and_then(serde_json::Value::as_u64)
4712        == Some(MIGRATION_MANIFEST_VERSION as u64)
4713        && value
4714            .get("target_generation")
4715            .and_then(serde_json::Value::as_str)
4716            == Some(generation)
4717        && value
4718            .get("source_bytes")
4719            .and_then(serde_json::Value::as_u64)
4720            .is_some_and(|bytes| bytes > 0)
4721        && value
4722            .get("source_blake3")
4723            .and_then(serde_json::Value::as_str)
4724            .is_some_and(|hash| hash.len() == 64)
4725}
4726
4727fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
4728    let pointer_generation = read_pointer(callgraph_dir, project_key);
4729    if let Some(generation) = pointer_generation.as_deref() {
4730        if migration_generation_requires_manifest(generation)
4731            && !migration_manifest_valid(callgraph_dir, generation)
4732        {
4733            let path = callgraph_dir.join(generation);
4734            remove_sqlite_file_set(&path);
4735            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
4736            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
4737        }
4738    }
4739
4740    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
4741        return;
4742    };
4743    for entry in entries.flatten() {
4744        let name = entry.file_name().to_string_lossy().to_string();
4745        let path = entry.path();
4746        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
4747            let _ = std::fs::remove_file(path);
4748            continue;
4749        }
4750        if name.starts_with(&format!("{project_key}.g"))
4751            && name.ends_with(".sqlite")
4752            && name.contains(MIGRATION_GENERATION_TAG)
4753            && pointer_generation.as_deref() != Some(&name)
4754            && !migration_manifest_valid(callgraph_dir, &name)
4755        {
4756            remove_sqlite_file_set(&path);
4757            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
4758        }
4759    }
4760    crate::fs_lock::sync_parent(callgraph_dir);
4761}
4762
4763fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
4764    let mut hasher = blake3::Hasher::new();
4765    hasher.update(path.to_string_lossy().as_bytes());
4766    if let Some(generation) = generation {
4767        hasher.update(generation.as_bytes());
4768    }
4769    let digest = hash_to_hex(hasher.finalize());
4770    format!("legacy-{}", &digest[..16])
4771}
4772
4773fn open_readonly_connection(path: &Path) -> Result<Connection> {
4774    let uri = sqlite_readonly_uri(path);
4775    let conn = Connection::open_with_flags(
4776        &uri,
4777        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
4778    )?;
4779    conn.busy_timeout(reader_busy_timeout())?;
4780    conn.execute_batch("PRAGMA query_only=ON;")?;
4781    Ok(conn)
4782}
4783
4784fn reader_busy_timeout() -> Duration {
4785    let jitter = (now_nanos() % 500) as u64;
4786    Duration::from_millis(250 + jitter)
4787}
4788
4789fn sqlite_readonly_uri(path: &Path) -> String {
4790    let raw = path.to_string_lossy().replace('\\', "/");
4791    let encoded = percent_encode_sqlite_uri_path(&raw);
4792    if raw.starts_with('/') {
4793        format!("file://{encoded}?mode=ro")
4794    } else if raw.as_bytes().get(1) == Some(&b':') {
4795        format!("file:///{encoded}?mode=ro")
4796    } else {
4797        format!("file:{encoded}?mode=ro")
4798    }
4799}
4800
4801fn percent_encode_sqlite_uri_path(path: &str) -> String {
4802    let mut encoded = String::with_capacity(path.len());
4803    for byte in path.bytes() {
4804        match byte {
4805            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
4806                encoded.push(byte as char)
4807            }
4808            _ => encoded.push_str(&format!("%{byte:02X}")),
4809        }
4810    }
4811    encoded
4812}
4813
4814fn configure_connection(conn: &Connection) -> Result<()> {
4815    conn.pragma_update(None, "journal_mode", "WAL")?;
4816    conn.pragma_update(None, "busy_timeout", 5_000)?;
4817    Ok(())
4818}
4819
4820fn configure_build_connection(conn: &Connection) -> Result<()> {
4821    conn.pragma_update(None, "journal_mode", "DELETE")?;
4822    conn.pragma_update(None, "busy_timeout", 5_000)?;
4823    Ok(())
4824}
4825
4826fn initialize_schema(conn: &Connection) -> Result<()> {
4827    conn.execute_batch(
4828        "CREATE TABLE IF NOT EXISTS files (
4829            path                TEXT PRIMARY KEY,
4830            content_hash        TEXT NOT NULL,
4831            mtime_ns            INTEGER NOT NULL,
4832            size                INTEGER NOT NULL,
4833            lang                TEXT NOT NULL,
4834            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
4835            is_public_api       INTEGER NOT NULL DEFAULT 0,
4836            surface_fingerprint TEXT NOT NULL,
4837            indexed_at          INTEGER NOT NULL
4838        );
4839
4840        CREATE TABLE IF NOT EXISTS nodes (
4841            id                         TEXT PRIMARY KEY,
4842            file_path                  TEXT NOT NULL,
4843            name                       TEXT NOT NULL,
4844            scoped_name                TEXT NOT NULL,
4845            kind                       TEXT NOT NULL,
4846            start_line                 INTEGER NOT NULL,
4847            start_col                  INTEGER NOT NULL,
4848            end_line                   INTEGER NOT NULL,
4849            end_col                    INTEGER NOT NULL,
4850            range_ordinal              INTEGER NOT NULL,
4851            signature                  TEXT,
4852            exported                   INTEGER NOT NULL,
4853            is_default_export          INTEGER NOT NULL,
4854            is_type_like               INTEGER NOT NULL,
4855            is_callgraph_entry_point   INTEGER NOT NULL,
4856            provenance                 TEXT NOT NULL,
4857            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
4858        );
4859        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
4860        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
4861        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
4862
4863        CREATE TABLE IF NOT EXISTS refs (
4864            ref_id          TEXT PRIMARY KEY,
4865            caller_node     TEXT,
4866            caller_file     TEXT NOT NULL,
4867            kind            TEXT NOT NULL,
4868            short_name      TEXT,
4869            full_ref        TEXT,
4870            module_path     TEXT,
4871            import_kind     TEXT,
4872            local_name      TEXT,
4873            requested_name  TEXT,
4874            namespace_alias TEXT,
4875            wildcard        INTEGER NOT NULL DEFAULT 0,
4876            line            INTEGER NOT NULL,
4877            byte_start      INTEGER NOT NULL,
4878            byte_end        INTEGER NOT NULL,
4879            status          TEXT NOT NULL,
4880            target_node     TEXT,
4881            target_file     TEXT,
4882            target_symbol   TEXT,
4883            provenance      TEXT NOT NULL
4884        );
4885        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
4886        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
4887        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
4888        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
4889        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
4890
4891        CREATE TABLE IF NOT EXISTS file_dependencies (
4892            file_path   TEXT NOT NULL,
4893            dep_file    TEXT NOT NULL,
4894            PRIMARY KEY(file_path, dep_file)
4895        );
4896        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
4897
4898        CREATE TABLE IF NOT EXISTS edges (
4899            edge_id       TEXT PRIMARY KEY,
4900            ref_id        TEXT NOT NULL,
4901            source_node   TEXT NOT NULL,
4902            target_node   TEXT,
4903            target_file   TEXT NOT NULL,
4904            target_symbol TEXT NOT NULL,
4905            kind          TEXT NOT NULL,
4906            line          INTEGER NOT NULL,
4907            provenance    TEXT NOT NULL
4908        );
4909        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
4910        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
4911        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
4912        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
4913
4914        CREATE TABLE IF NOT EXISTS dispatch_hints (
4915            id           TEXT PRIMARY KEY,
4916            method_name  TEXT NOT NULL,
4917            caller_node  TEXT NOT NULL,
4918            file         TEXT NOT NULL,
4919            line         INTEGER NOT NULL,
4920            byte_start   INTEGER NOT NULL,
4921            byte_end     INTEGER NOT NULL,
4922            provenance   TEXT NOT NULL
4923        );
4924        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
4925
4926        CREATE TABLE IF NOT EXISTS type_ref_names (
4927            name TEXT PRIMARY KEY
4928        );
4929
4930        CREATE TABLE IF NOT EXISTS backend_file_state (
4931            backend        TEXT NOT NULL,
4932            workspace_root TEXT NOT NULL,
4933            file_path      TEXT NOT NULL,
4934            content_hash   TEXT NOT NULL,
4935            status         TEXT NOT NULL,
4936            updated_at     INTEGER NOT NULL,
4937            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
4938        );
4939        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
4940
4941        CREATE TABLE IF NOT EXISTS meta (
4942            k TEXT PRIMARY KEY,
4943            v TEXT NOT NULL
4944        );",
4945    )?;
4946    insert_meta(conn)?;
4947    Ok(())
4948}
4949
4950fn insert_meta(conn: &Connection) -> Result<()> {
4951    conn.execute(
4952        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
4953        params![SCHEMA_VERSION.to_string()],
4954    )?;
4955    conn.execute(
4956        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
4957        params![schema_fingerprint()],
4958    )?;
4959    Ok(())
4960}
4961
4962fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
4963    conn.execute(
4964        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
4965        params![if ready { "1" } else { "0" }],
4966    )?;
4967    Ok(())
4968}
4969
4970fn database_ready(conn: &Connection) -> Result<bool> {
4971    let schema_version: Option<String> = conn
4972        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
4973            row.get(0)
4974        })
4975        .optional()?;
4976    let fingerprint: Option<String> = conn
4977        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
4978            row.get(0)
4979        })
4980        .optional()?;
4981    let ready: Option<String> = conn
4982        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
4983        .optional()?;
4984
4985    let expected_schema = SCHEMA_VERSION.to_string();
4986    let expected_fingerprint = schema_fingerprint();
4987    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
4988        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
4989        && ready.as_deref() == Some("1"))
4990}
4991
4992fn ensure_database_ready(conn: &Connection) -> Result<()> {
4993    if database_ready(conn)? {
4994        Ok(())
4995    } else {
4996        Err(CallGraphStoreError::Unavailable(
4997            "database is missing, stale, or mid-build".to_string(),
4998        ))
4999    }
5000}
5001
5002fn schema_fingerprint() -> String {
5003    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
5004    // edge sources, broader call extraction) even if the table SHAPE is
5005    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
5006    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
5007    let input =
5008        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
5009    hash_to_hex(blake3::hash(input.as_bytes()))
5010}
5011
5012fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
5013    tx.execute_batch(
5014        "DELETE FROM edges;
5015         DELETE FROM file_dependencies;
5016         DELETE FROM refs;
5017         DELETE FROM dispatch_hints;
5018         DELETE FROM type_ref_names;
5019         DELETE FROM backend_file_state;
5020         DELETE FROM nodes;
5021         DELETE FROM files;",
5022    )?;
5023    Ok(())
5024}
5025
5026fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5027    tx.execute_batch(
5028        "DROP INDEX IF EXISTS idx_nodes_file;
5029         DROP INDEX IF EXISTS idx_nodes_name;
5030         DROP INDEX IF EXISTS idx_nodes_scoped;
5031         DROP INDEX IF EXISTS idx_refs_short_name;
5032         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
5033         DROP INDEX IF EXISTS idx_refs_caller_file;
5034         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
5035         DROP INDEX IF EXISTS idx_refs_target_file;
5036         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
5037         DROP INDEX IF EXISTS idx_edges_source_kind;
5038         DROP INDEX IF EXISTS idx_edges_target_kind;
5039         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
5040         DROP INDEX IF EXISTS idx_edges_ref_id;
5041         DROP INDEX IF EXISTS idx_dispatch_hints_method;
5042         DROP INDEX IF EXISTS idx_backend_file_state_file;",
5043    )?;
5044    Ok(())
5045}
5046
5047fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5048    tx.execute_batch(
5049        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5050         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5051         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5052         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5053         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5054         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5055         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5056         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5057         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5058         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5059         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5060         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5061         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5062         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5063         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
5064    )?;
5065    Ok(())
5066}
5067
5068const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
5069    ("files", "path"),
5070    ("nodes", "file_path"),
5071    ("refs", "caller_file"),
5072    ("refs", "target_file"),
5073    ("file_dependencies", "file_path"),
5074    ("file_dependencies", "dep_file"),
5075    ("edges", "target_file"),
5076    ("dispatch_hints", "file"),
5077    ("backend_file_state", "file_path"),
5078];
5079
5080/// Reconcile `backend_file_state.workspace_root` when the opener's project root
5081/// differs from what is stored. The store key is the git-root commit hash, so
5082/// multiple live checkouts/clones share one on-disk generation.
5083///
5084/// Cheap in-place re-root is only safe when every previously stored root path is
5085/// gone from disk (true move/rename). If any stale root still exists, another
5086/// clone is still alive and rewriting metadata would ping-pong relative rows
5087/// between trees (possibly on different branches). We then return
5088/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
5089/// opener. That can make each clone rebuild on open when they alternate — bounded
5090/// by open frequency — but each rebuild is correct for its opener, unlike silent
5091/// cross-clone corruption.
5092fn reconcile_workspace_roots(
5093    conn: &mut Connection,
5094    project_root: &Path,
5095    allow_repair: bool,
5096) -> Result<OpenRootRepair> {
5097    let roots = stored_workspace_roots(conn)?;
5098    let current_root = project_root.display().to_string();
5099    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
5100        return Ok(OpenRootRepair::None);
5101    }
5102
5103    if let Some(sample) = sample_absolute_data_path(conn)? {
5104        return Ok(OpenRootRepair::NeedsRebuild {
5105            previous_roots: roots,
5106            current_root,
5107            reason: format!("absolute store data path row {sample}"),
5108        });
5109    }
5110
5111    for stored_root in roots.iter() {
5112        if stored_root == &current_root {
5113            continue;
5114        }
5115        if Path::new(stored_root).exists() {
5116            let reason = format!(
5117                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
5118            );
5119            return Ok(OpenRootRepair::NeedsRebuild {
5120                previous_roots: roots,
5121                current_root,
5122                reason,
5123            });
5124        }
5125    }
5126
5127    if !allow_repair {
5128        return Ok(OpenRootRepair::NeedsRebuild {
5129            previous_roots: roots,
5130            current_root,
5131            reason: "workspace root metadata requires deferred repair".to_string(),
5132        });
5133    }
5134
5135    publish_if_current(|| {
5136        let tx = conn.transaction()?;
5137        tx.execute(
5138            "UPDATE OR IGNORE backend_file_state
5139             SET workspace_root = ?1
5140             WHERE workspace_root <> ?1",
5141            params![&current_root],
5142        )?;
5143        tx.execute(
5144            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5145            params![&current_root],
5146        )?;
5147        tx.commit()?;
5148        Ok(())
5149    })?;
5150
5151    crate::slog_info!(
5152        "callgraph store re-rooted from {} to {}",
5153        roots.join(", "),
5154        current_root
5155    );
5156    Ok(OpenRootRepair::ReRooted)
5157}
5158
5159fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5160    let mut stmt = conn.prepare(
5161        "SELECT DISTINCT workspace_root
5162         FROM backend_file_state
5163         ORDER BY workspace_root",
5164    )?;
5165    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5166    rows.collect::<std::result::Result<Vec<_>, _>>()
5167        .map_err(Into::into)
5168}
5169
5170fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5171    for (table, column) in STORE_DATA_PATH_COLUMNS {
5172        let sql = format!(
5173            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5174        );
5175        let mut stmt = conn.prepare(&sql)?;
5176        let mut rows = stmt.query([])?;
5177        while let Some(row) = rows.next()? {
5178            let value: String = row.get(0)?;
5179            if stored_path_is_absolute(&value) {
5180                return Ok(Some(format!("{table}.{column}={value}")));
5181            }
5182        }
5183    }
5184    Ok(None)
5185}
5186
5187fn stored_path_is_absolute(value: &str) -> bool {
5188    if value.is_empty() {
5189        return false;
5190    }
5191    if Path::new(value).is_absolute() || value.starts_with('/') {
5192        return true;
5193    }
5194    let bytes = value.as_bytes();
5195    if bytes.len() >= 3
5196        && bytes[1] == b':'
5197        && (bytes[2] == b'/' || bytes[2] == b'\\')
5198        && bytes[0].is_ascii_alphabetic()
5199    {
5200        return true;
5201    }
5202    value.starts_with("\\\\") || value.starts_with("//")
5203}
5204
5205fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5206    if let OpenRootRepair::NeedsRebuild {
5207        previous_roots,
5208        current_root,
5209        reason,
5210    } = repair
5211    {
5212        crate::slog_info!(
5213            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5214            previous_roots.join(", "),
5215            current_root,
5216            reason
5217        );
5218    }
5219}
5220
5221/// Nanosecond clock used to make temp/generation file names unique.
5222fn now_nanos() -> u128 {
5223    SystemTime::now()
5224        .duration_since(UNIX_EPOCH)
5225        .unwrap_or(Duration::ZERO)
5226        .as_nanos()
5227}
5228
5229/// The pointer file `<dir>/<key>.current`. Its single line names the current
5230/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
5231/// can always be atomically replaced via rename even on Windows — Rust opens
5232/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
5233fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5234    callgraph_dir.join(format!("{project_key}.current"))
5235}
5236
5237/// The legacy single-file DB path used before the generation scheme. Still read
5238/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
5239/// build publishes a generation.
5240fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5241    callgraph_dir.join(format!("{project_key}.sqlite"))
5242}
5243
5244/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
5245/// cold build writes a brand-new generation file, so publishing NEVER replaces
5246/// a file another process holds open (the root Windows fix).
5247fn generation_file_name(project_key: &str) -> String {
5248    format!(
5249        "{project_key}.g{}.{}.sqlite",
5250        now_nanos(),
5251        std::process::id()
5252    )
5253}
5254
5255/// Read the pointer; returns the generation file name if present and non-empty.
5256fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5257    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5258    let name = text.trim();
5259    if name.is_empty() {
5260        None
5261    } else {
5262        Some(name.to_string())
5263    }
5264}
5265
5266/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
5267/// `ready` flag). Uses a throwaway read-only connection.
5268fn db_path_ready(path: &Path) -> bool {
5269    (|| -> Result<bool> {
5270        let conn = open_readonly_connection(path)?;
5271        database_ready(&conn)
5272    })()
5273    .unwrap_or(false)
5274}
5275
5276/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
5277/// where `generation` is `Some(name)` for a pointer-published generation or
5278/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
5279/// published (caller treats that as "needs cold build").
5280///
5281/// Handles the GC race (the pointer names a generation that was just deleted) by
5282/// re-reading the pointer and retrying a few times.
5283fn resolve_ready_target(
5284    callgraph_dir: &Path,
5285    project_key: &str,
5286) -> Option<(PathBuf, Option<String>)> {
5287    for _ in 0..5 {
5288        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5289            let gen_path = callgraph_dir.join(&generation);
5290            if gen_path.is_file() {
5291                return (migration_manifest_valid(callgraph_dir, &generation)
5292                    && db_path_ready(&gen_path))
5293                .then_some((gen_path, Some(generation)));
5294            }
5295            // Pointer names a missing generation (a GC/publish race): re-read the
5296            // pointer and retry rather than failing the reader.
5297            std::thread::sleep(Duration::from_millis(5));
5298            continue;
5299        }
5300        // No pointer: fall back to the legacy single-file DB if it is ready.
5301        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5302        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5303    }
5304    None
5305}
5306
5307/// Atomically publish `generation` as the current store by flipping the pointer
5308/// file. Writes a temp file, fsyncs, then renames over the pointer — never
5309/// replacing an open DB file, so it succeeds cross-platform.
5310fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5311    let pointer = pointer_path(callgraph_dir, project_key);
5312    let tmp = callgraph_dir.join(format!(
5313        "{project_key}.current.tmp.{}.{}",
5314        std::process::id(),
5315        now_nanos()
5316    ));
5317    {
5318        use std::io::Write as _;
5319        let mut file = std::fs::File::create(&tmp)?;
5320        file.write_all(generation.as_bytes())?;
5321        file.write_all(b"\n")?;
5322        file.sync_all()?;
5323    }
5324    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5325        let _ = std::fs::remove_file(&tmp);
5326        return Err(error.into());
5327    }
5328    crate::fs_lock::sync_parent(&pointer);
5329    Ok(())
5330}
5331
5332#[derive(Clone, Debug)]
5333struct GenerationGcCandidate {
5334    name: String,
5335    path: PathBuf,
5336    modified: SystemTime,
5337}
5338
5339/// Best-effort GC of superseded generation files. The current pointer target and
5340/// newest previous generation are always retained. Older generations are removed
5341/// when they have no protected read marker, or after the absolute retention TTL
5342/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
5343/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
5344fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5345    let temp_grace = Duration::from_secs(60);
5346    let now = SystemTime::now();
5347    let pointer_current =
5348        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5349    let gen_prefix = format!("{project_key}.g");
5350    let tmp_prefixes = [
5351        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
5352        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
5353        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
5354    ];
5355    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5356        return;
5357    };
5358    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5359    for entry in entries.flatten() {
5360        let name = entry.file_name();
5361        let name = name.to_string_lossy().to_string();
5362        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5363        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5364
5365        // Orphaned temp files from a crashed build/publish: remove once aged out.
5366        if name.contains(".tmp.") {
5367            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5368                let _ = std::fs::remove_file(entry.path());
5369            }
5370            continue;
5371        }
5372
5373        // Superseded legacy single-file DB: best-effort delete once a generation
5374        // is published (ignored if another process still holds it open).
5375        if name == format!("{project_key}.sqlite") {
5376            remove_sqlite_file_set(&entry.path());
5377            continue;
5378        }
5379
5380        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5381            gens.push(GenerationGcCandidate {
5382                name,
5383                path: entry.path(),
5384                modified: mtime,
5385            });
5386        }
5387    }
5388
5389    let mut superseded = gens
5390        .iter()
5391        .filter(|generation| generation.name != pointer_current)
5392        .collect::<Vec<_>>();
5393    superseded.sort_by(|left, right| {
5394        right
5395            .modified
5396            .cmp(&left.modified)
5397            .then_with(|| right.name.cmp(&left.name))
5398    });
5399    let previous = superseded.first().map(|generation| generation.name.clone());
5400
5401    for generation in gens {
5402        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5403        if generation.name == pointer_current
5404            || Some(generation.name.as_str()) == previous.as_deref()
5405        {
5406            continue;
5407        }
5408
5409        let age = now
5410            .duration_since(generation.modified)
5411            .unwrap_or(Duration::ZERO);
5412        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5413            continue;
5414        }
5415
5416        remove_sqlite_file_set(&generation.path);
5417        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5418        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5419            callgraph_dir,
5420            &generation.name,
5421        ));
5422    }
5423}
5424
5425fn remove_sqlite_file_set(path: &Path) {
5426    let _ = std::fs::remove_file(path);
5427    remove_sqlite_sidecars(path);
5428}
5429
5430fn remove_sqlite_sidecars(path: &Path) {
5431    let path_text = path.to_string_lossy();
5432    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5433    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5434    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5435}
5436
5437/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
5438/// the global all-cores rayon pool. The store cold-build is the heaviest
5439/// background pass (parse-dominated) and runs on a separate thread off the
5440/// single-threaded request loop; left unbounded it monopolizes every core and
5441/// starves the bridge so interactive tools time out (the same starvation the
5442/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
5443/// match the main thread, since the extract walks tree-sitter ASTs.
5444fn build_pool_size() -> usize {
5445    std::thread::available_parallelism()
5446        .map(|parallelism| parallelism.get())
5447        .unwrap_or(1)
5448        .div_ceil(2)
5449        .clamp(1, 8)
5450}
5451
5452fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
5453    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
5454        Ok(extract) => Ok(extract),
5455        Err(error) => {
5456            let abs_path =
5457                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
5458            let rel_path = relative_path(project_root, &abs_path);
5459            let freshness = cache_freshness::collect(&abs_path).ok();
5460            log::debug!(
5461                "callgraph store: skipping {} during cold build: {}",
5462                abs_path.display(),
5463                error
5464            );
5465            Err(ExtractFailure {
5466                rel_path,
5467                freshness,
5468            })
5469        }
5470    };
5471
5472    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
5473        files.par_iter().map(extract_one).collect()
5474    };
5475
5476    // Run inside a dedicated bounded pool when one builds; fall back to the
5477    // global pool only if the bounded pool can't be constructed.
5478    let results = match rayon::ThreadPoolBuilder::new()
5479        .num_threads(build_pool_size())
5480        .thread_name(|index| format!("aft-callgraph-build-{index}"))
5481        .stack_size(8 * 1024 * 1024)
5482        .build()
5483    {
5484        Ok(pool) => pool.install(run),
5485        Err(error) => {
5486            log::warn!(
5487                "callgraph store: bounded build pool unavailable ({error}); using global pool"
5488            );
5489            run()
5490        }
5491    };
5492
5493    let mut extracts = Vec::new();
5494    let mut failures = Vec::new();
5495    for result in results {
5496        match result {
5497            Ok(extract) => extracts.push(extract),
5498            Err(failure) => failures.push(failure),
5499        }
5500    }
5501    BuildExtractsResult { extracts, failures }
5502}
5503
5504fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
5505    let metadata = std::fs::metadata(path)?;
5506    let size = metadata.len();
5507    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
5508        cache_freshness::zero_hash()
5509    } else if source.len() as u64 == size {
5510        cache_freshness::hash_bytes(source.as_bytes())
5511    } else {
5512        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
5513    };
5514    Ok(FileFreshness {
5515        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
5516        size,
5517        content_hash,
5518    })
5519}
5520
5521fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
5522    let abs_path = normalize_file_path(project_root, path)?;
5523    let rel_path = relative_path(project_root, &abs_path);
5524    let source = std::fs::read_to_string(&abs_path)?;
5525    let freshness = collect_source_freshness(&abs_path, &source)?;
5526    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
5527    let lang = data.lang;
5528    if lang == LangId::Rust {
5529        extend_rust_imports_with_nested_uses(&source, &mut data);
5530    }
5531    let mut nodes = build_node_records(&rel_path, &source, &data)?;
5532    let node_by_scoped: HashMap<String, String> = nodes
5533        .iter()
5534        .map(|node| (node.scoped_name.clone(), node.id.clone()))
5535        .collect();
5536    let import_dependencies =
5537        import_dependencies(project_root, &abs_path, &data.import_block.imports);
5538    let line_index = LineIndex::new(&source);
5539    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
5540    let rust_reexports = if lang == LangId::Rust {
5541        collect_rust_pub_use_reexport_refs(
5542            project_root,
5543            &abs_path,
5544            &rel_path,
5545            &data.import_block.imports,
5546            &line_index,
5547        )
5548    } else {
5549        ReexportRefs {
5550            raw_refs: Vec::new(),
5551            surface_parts: Vec::new(),
5552        }
5553    };
5554    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
5555    let mut raw_refs = Vec::new();
5556    raw_refs.extend(build_call_refs(
5557        &rel_path,
5558        &data,
5559        &node_by_scoped,
5560        &import_dependencies,
5561    ));
5562    raw_refs.extend(build_import_refs(
5563        project_root,
5564        &abs_path,
5565        &rel_path,
5566        &data.import_block.imports,
5567        &line_index,
5568    ));
5569    let mut surface_parts = reexports.surface_parts;
5570    surface_parts.extend(rust_reexports.surface_parts);
5571    surface_parts.extend(source_less_exports.surface_parts);
5572    raw_refs.extend(reexports.raw_refs);
5573    raw_refs.extend(rust_reexports.raw_refs);
5574    raw_refs.extend(source_less_exports.raw_refs);
5575    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
5576    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
5577
5578    Ok(FileExtract {
5579        rel_path,
5580        freshness,
5581        lang,
5582        data,
5583        nodes,
5584        raw_refs,
5585        dispatch_hints,
5586        surface_fingerprint,
5587    })
5588}
5589
5590fn build_node_records(
5591    rel_path: &str,
5592    source: &str,
5593    data: &FileCallData,
5594) -> Result<Vec<NodeRecord>> {
5595    let mut records = Vec::new();
5596    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
5597    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
5598    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
5599
5600    for (scoped_name, meta) in metadata {
5601        let name = unqualified_name(scoped_name).to_string();
5602        let range = selection_range(source, scoped_name, &name, &meta.range);
5603        let range_key = (
5604            range.start_line,
5605            range.start_col,
5606            range.end_line,
5607            range.end_col,
5608        );
5609        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
5610        let range_ordinal = *ordinal;
5611        *ordinal += 1;
5612        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
5613        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
5614        let is_default_export = data
5615            .default_export_symbol
5616            .as_deref()
5617            .map(|default| default == scoped_name || default == name)
5618            .unwrap_or(false);
5619        records.push(NodeRecord {
5620            id,
5621            file_path: rel_path.to_string(),
5622            name: name.clone(),
5623            scoped_name: scoped_name.clone(),
5624            kind: symbol_kind_label(&meta.kind).to_string(),
5625            range,
5626            range_ordinal,
5627            signature: meta.signature.clone(),
5628            exported,
5629            is_default_export,
5630            is_type_like: is_type_like(&meta.kind),
5631            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
5632                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
5633        });
5634    }
5635
5636    Ok(records)
5637}
5638
5639fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
5640    if scoped_name == TOP_LEVEL_SYMBOL {
5641        return Range {
5642            start_line: 0,
5643            start_col: 0,
5644            end_line: 0,
5645            end_col: 0,
5646        };
5647    }
5648    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
5649        return fallback.clone();
5650    };
5651    let start_col = fallback.start_col as usize;
5652    let search_start = start_col.min(line.len());
5653    if let Some(offset) = line[search_start..].find(name) {
5654        let col = search_start + offset;
5655        return Range {
5656            start_line: fallback.start_line,
5657            start_col: col as u32,
5658            end_line: fallback.start_line,
5659            end_col: (col + name.len()) as u32,
5660        };
5661    }
5662    if let Some(offset) = line.find(name) {
5663        return Range {
5664            start_line: fallback.start_line,
5665            start_col: offset as u32,
5666            end_line: fallback.start_line,
5667            end_col: (offset + name.len()) as u32,
5668        };
5669    }
5670    Range {
5671        start_line: fallback.start_line,
5672        start_col: fallback.start_col,
5673        end_line: fallback.start_line,
5674        end_col: fallback.start_col.saturating_add(name.len() as u32),
5675    }
5676}
5677
5678fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
5679    if scoped_name == TOP_LEVEL_SYMBOL {
5680        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
5681    }
5682    let input = format!(
5683        "{rel_path}:{}:{}:{}:{}:{ordinal}",
5684        range.start_line, range.start_col, range.end_line, range.end_col
5685    );
5686    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
5687}
5688
5689fn build_call_refs(
5690    rel_path: &str,
5691    data: &FileCallData,
5692    node_by_scoped: &HashMap<String, String>,
5693    import_dependencies: &BTreeSet<String>,
5694) -> Vec<RawRef> {
5695    let mut refs = Vec::new();
5696    let mut ordinal = 0usize;
5697    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
5698    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
5699    for (caller_symbol, call_sites) in symbols {
5700        let caller_node = node_by_scoped.get(caller_symbol).cloned();
5701        for call_site in call_sites {
5702            ordinal += 1;
5703            let ref_id = ref_id(&[
5704                rel_path,
5705                "call",
5706                caller_symbol,
5707                &call_site.line.to_string(),
5708                &call_site.byte_start.to_string(),
5709                &call_site.byte_end.to_string(),
5710                &call_site.full_callee,
5711                &ordinal.to_string(),
5712            ]);
5713            refs.push(RawRef {
5714                ref_id,
5715                caller_node: caller_node.clone(),
5716                caller_symbol: Some(caller_symbol.clone()),
5717                caller_file: rel_path.to_string(),
5718                kind: "call".to_string(),
5719                short_name: Some(call_site.callee_name.clone()),
5720                full_ref: Some(call_site.full_callee.clone()),
5721                module_path: None,
5722                import_kind: None,
5723                local_name: Some(call_site.callee_name.clone()),
5724                requested_name: Some(call_site.callee_name.clone()),
5725                namespace_alias: namespace_alias(&call_site.full_callee),
5726                wildcard: false,
5727                line: call_site.line,
5728                byte_start: call_site.byte_start,
5729                byte_end: call_site.byte_end,
5730                dependencies: import_dependencies.clone(),
5731            });
5732        }
5733    }
5734    refs
5735}
5736
5737fn build_import_refs(
5738    project_root: &Path,
5739    abs_path: &Path,
5740    rel_path: &str,
5741    imports: &[ImportStatement],
5742    line_index: &LineIndex,
5743) -> Vec<RawRef> {
5744    let mut refs = Vec::new();
5745    for (index, import) in imports.iter().enumerate() {
5746        let import_kind = import_kind_label(import.kind).to_string();
5747        let local_name = import_local_names(import).join(",");
5748        let requested_name = import_requested_names(import).join(",");
5749        let ref_id = ref_id(&[
5750            rel_path,
5751            "import",
5752            &import.byte_range.start.to_string(),
5753            &import.byte_range.end.to_string(),
5754            &import.module_path,
5755            &index.to_string(),
5756        ]);
5757        refs.push(RawRef {
5758            ref_id,
5759            caller_node: None,
5760            caller_symbol: None,
5761            caller_file: rel_path.to_string(),
5762            kind: "import".to_string(),
5763            short_name: None,
5764            full_ref: Some(import.raw_text.clone()),
5765            module_path: Some(import.module_path.clone()),
5766            import_kind: Some(import_kind),
5767            local_name: empty_to_none(local_name),
5768            requested_name: empty_to_none(requested_name),
5769            namespace_alias: import.namespace_import.clone(),
5770            wildcard: import_is_wildcard(import),
5771            line: line_index.byte_to_line(import.byte_range.start),
5772            byte_start: import.byte_range.start,
5773            byte_end: import.byte_range.end,
5774            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
5775        });
5776    }
5777    refs
5778}
5779
5780fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
5781    let grammar = grammar_for(LangId::Rust);
5782    let mut parser = Parser::new();
5783    if parser.set_language(&grammar).is_err() {
5784        return;
5785    }
5786    let Some(tree) = parser.parse(source, None) else {
5787        return;
5788    };
5789
5790    let mut seen = data
5791        .import_block
5792        .imports
5793        .iter()
5794        .map(|import| (import.byte_range.start, import.byte_range.end))
5795        .collect::<HashSet<_>>();
5796    let mut nested_imports = Vec::new();
5797    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
5798    if nested_imports.is_empty() {
5799        return;
5800    }
5801
5802    data.import_block.imports.extend(nested_imports);
5803    data.import_block
5804        .imports
5805        .sort_by_key(|import| import.byte_range.start);
5806    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
5807}
5808
5809fn collect_rust_use_imports(
5810    source: &str,
5811    node: Node<'_>,
5812    seen: &mut HashSet<(usize, usize)>,
5813    imports: &mut Vec<ImportStatement>,
5814) {
5815    if node.kind() == "use_declaration" {
5816        let range = node.byte_range();
5817        if seen.insert((range.start, range.end)) {
5818            if let Some(import) = rust_import_from_use_node(source, node) {
5819                imports.push(import);
5820            }
5821        }
5822    }
5823
5824    let mut cursor = node.walk();
5825    if !cursor.goto_first_child() {
5826        return;
5827    }
5828    loop {
5829        collect_rust_use_imports(source, cursor.node(), seen, imports);
5830        if !cursor.goto_next_sibling() {
5831            break;
5832        }
5833    }
5834}
5835
5836fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
5837    let raw_text = source[node.byte_range()].to_string();
5838    let body = rust_use_body(&raw_text)?.to_string();
5839    let visibility = rust_use_visibility(&raw_text);
5840    let names = rust_use_list_names(&body);
5841    let group = classify_rust_import_group(&body);
5842    let byte_range = node.byte_range();
5843
5844    Some(ImportStatement {
5845        module_path: body,
5846        names: names.clone(),
5847        default_import: visibility.clone(),
5848        namespace_import: None,
5849        kind: ImportKind::Value,
5850        group,
5851        byte_range,
5852        raw_text,
5853        form: ImportForm::RustUse {
5854            visibility,
5855            named: names,
5856        },
5857    })
5858}
5859
5860fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
5861    let start = imports.iter().map(|import| import.byte_range.start).min()?;
5862    let end = imports.iter().map(|import| import.byte_range.end).max()?;
5863    Some(start..end)
5864}
5865
5866fn rust_use_visibility(raw_text: &str) -> Option<String> {
5867    let use_pos = raw_text.find("use ")?;
5868    let prefix = raw_text[..use_pos].trim();
5869    if prefix.is_empty() {
5870        None
5871    } else {
5872        Some(prefix.to_string())
5873    }
5874}
5875
5876fn rust_use_body(raw_text: &str) -> Option<&str> {
5877    let use_pos = raw_text.find("use ")?;
5878    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
5879}
5880
5881fn rust_use_list_names(body: &str) -> Vec<String> {
5882    let Some(open) = body.find("::{") else {
5883        return Vec::new();
5884    };
5885    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
5886        return Vec::new();
5887    };
5888    body[open + 3..close]
5889        .split(',')
5890        .filter_map(|spec| {
5891            let spec = spec.trim();
5892            if spec.is_empty() {
5893                None
5894            } else {
5895                Some(spec.to_string())
5896            }
5897        })
5898        .collect()
5899}
5900
5901fn classify_rust_import_group(body: &str) -> ImportGroup {
5902    let first = body
5903        .split("::")
5904        .next()
5905        .unwrap_or(body)
5906        .split_whitespace()
5907        .next()
5908        .unwrap_or(body);
5909    match first.trim() {
5910        "std" | "core" | "alloc" => ImportGroup::Stdlib,
5911        "crate" | "self" | "super" => ImportGroup::Internal,
5912        _ => ImportGroup::External,
5913    }
5914}
5915
5916#[derive(Debug, Clone)]
5917struct ReexportRefs {
5918    raw_refs: Vec<RawRef>,
5919    surface_parts: Vec<String>,
5920}
5921
5922fn collect_reexport_refs(
5923    project_root: &Path,
5924    abs_path: &Path,
5925    rel_path: &str,
5926    source: &str,
5927) -> ReexportRefs {
5928    let mut raw_refs = Vec::new();
5929    let mut surface_parts = Vec::new();
5930    let mut search_start = 0usize;
5931    let mut ordinal = 0usize;
5932    while let Some(export_offset) = source[search_start..].find("export") {
5933        let start = search_start + export_offset;
5934        let Some(statement_end_offset) = source[start..].find(';') else {
5935            break;
5936        };
5937        let end = start + statement_end_offset + 1;
5938        let statement = &source[start..end];
5939        search_start = end;
5940        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
5941            continue;
5942        }
5943        let Some(module_path) = quoted_module_path(statement) else {
5944            continue;
5945        };
5946        ordinal += 1;
5947        let wildcard = statement.contains('*');
5948        let line = source[..start]
5949            .bytes()
5950            .filter(|byte| *byte == b'\n')
5951            .count() as u32
5952            + 1;
5953        let ref_id = ref_id(&[
5954            rel_path,
5955            "reexport",
5956            &start.to_string(),
5957            &end.to_string(),
5958            &module_path,
5959            &ordinal.to_string(),
5960        ]);
5961        surface_parts.push(format!("reexport\t{statement}"));
5962        raw_refs.push(RawRef {
5963            ref_id,
5964            caller_node: None,
5965            caller_symbol: None,
5966            caller_file: rel_path.to_string(),
5967            kind: "reexport".to_string(),
5968            short_name: None,
5969            full_ref: Some(statement.to_string()),
5970            module_path: Some(module_path.clone()),
5971            import_kind: Some("reexport".to_string()),
5972            local_name: None,
5973            requested_name: None,
5974            namespace_alias: None,
5975            wildcard,
5976            line,
5977            byte_start: start,
5978            byte_end: end,
5979            dependencies: module_dependencies(project_root, abs_path, &module_path),
5980        });
5981    }
5982    ReexportRefs {
5983        raw_refs,
5984        surface_parts,
5985    }
5986}
5987
5988fn collect_rust_pub_use_reexport_refs(
5989    project_root: &Path,
5990    abs_path: &Path,
5991    rel_path: &str,
5992    imports: &[ImportStatement],
5993    line_index: &LineIndex,
5994) -> ReexportRefs {
5995    let mut raw_refs = Vec::new();
5996    let mut surface_parts = Vec::new();
5997    let mut ordinal = 0usize;
5998
5999    for import in imports {
6000        let Some(visibility) = &import.default_import else {
6001            continue;
6002        };
6003        if !visibility.starts_with("pub") {
6004            continue;
6005        }
6006        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
6007            continue;
6008        };
6009        ordinal += 1;
6010        let ref_id = ref_id(&[
6011            rel_path,
6012            "rust_reexport",
6013            &import.byte_range.start.to_string(),
6014            &import.byte_range.end.to_string(),
6015            &module_path,
6016            &ordinal.to_string(),
6017        ]);
6018        surface_parts.push(format!("reexport\t{}", import.raw_text));
6019        raw_refs.push(RawRef {
6020            ref_id,
6021            caller_node: None,
6022            caller_symbol: None,
6023            caller_file: rel_path.to_string(),
6024            kind: "reexport".to_string(),
6025            short_name: None,
6026            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
6027            module_path: Some(module_path.clone()),
6028            import_kind: Some("reexport".to_string()),
6029            local_name: None,
6030            requested_name: None,
6031            namespace_alias: None,
6032            wildcard,
6033            line: line_index.byte_to_line(import.byte_range.start),
6034            byte_start: import.byte_range.start,
6035            byte_end: import.byte_range.end,
6036            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
6037        });
6038    }
6039
6040    ReexportRefs {
6041        raw_refs,
6042        surface_parts,
6043    }
6044}
6045
6046fn rust_pub_use_reexport_parts(
6047    import: &ImportStatement,
6048) -> Option<(String, HashMap<String, String>, bool)> {
6049    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
6050    let body = body.trim();
6051    if let Some(module_path) = body.strip_suffix("::*") {
6052        return Some((module_path.trim().to_string(), HashMap::new(), true));
6053    }
6054
6055    if let Some(brace_start) = body.find("::{") {
6056        let module_path = body[..brace_start].trim().to_string();
6057        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
6058        if names.is_empty() {
6059            return None;
6060        }
6061        return Some((module_path, names, false));
6062    }
6063
6064    let (module_path, spec) = body.rsplit_once("::")?;
6065    let names = rust_reexport_names_from_specs(spec);
6066    if names.is_empty() {
6067        return None;
6068    }
6069    Some((module_path.trim().to_string(), names, false))
6070}
6071
6072fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
6073    let mut names = HashMap::new();
6074    for spec in specs.split(',') {
6075        let spec = spec.trim();
6076        if spec.is_empty() || spec == "self" {
6077            continue;
6078        }
6079        if let Some((source, local)) = spec.split_once(" as ") {
6080            let source = source.trim();
6081            let local = local.trim();
6082            if !source.is_empty() && !local.is_empty() && source != "self" {
6083                names.insert(local.to_string(), source.to_string());
6084            }
6085        } else {
6086            names.insert(spec.to_string(), spec.to_string());
6087        }
6088    }
6089    names
6090}
6091
6092fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
6093    if named.is_empty() {
6094        return fallback.to_string();
6095    }
6096    let mut specs = named
6097        .iter()
6098        .map(|(local, source)| {
6099            if local == source {
6100                source.clone()
6101            } else {
6102                format!("{source} as {local}")
6103            }
6104        })
6105        .collect::<Vec<_>>();
6106    specs.sort();
6107    format!("pub use {{{}}};", specs.join(", "))
6108}
6109
6110fn quoted_module_path(statement: &str) -> Option<String> {
6111    let quote = match (statement.find('\''), statement.find('"')) {
6112        (Some(single), Some(double)) if single < double => '\'',
6113        (Some(_), Some(_)) => '"',
6114        (Some(_), None) => '\'',
6115        (None, Some(_)) => '"',
6116        (None, None) => return None,
6117    };
6118    let start = statement.find(quote)? + 1;
6119    let end = statement[start..].find(quote)? + start;
6120    Some(statement[start..end].to_string())
6121}
6122
6123#[derive(Debug, Clone)]
6124struct SourceLessExportRefs {
6125    raw_refs: Vec<RawRef>,
6126    surface_parts: Vec<String>,
6127}
6128
6129fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
6130    let mut raw_refs = Vec::new();
6131    let mut surface_parts = Vec::new();
6132    let mut search_start = 0usize;
6133    let mut ordinal = 0usize;
6134    while let Some(export_offset) = source[search_start..].find("export") {
6135        let start = search_start + export_offset;
6136        let Some(statement_end_offset) = source[start..].find(';') else {
6137            break;
6138        };
6139        let end = start + statement_end_offset + 1;
6140        let statement = &source[start..end];
6141        search_start = end;
6142        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6143            continue;
6144        }
6145        let aliases = parse_reexport_names(statement);
6146        if aliases.is_empty() {
6147            continue;
6148        }
6149        let line = source[..start]
6150            .bytes()
6151            .filter(|byte| *byte == b'\n')
6152            .count() as u32
6153            + 1;
6154        for (exported, source_symbol) in aliases {
6155            ordinal += 1;
6156            let ref_id = ref_id(&[
6157                rel_path,
6158                "export_alias",
6159                &start.to_string(),
6160                &end.to_string(),
6161                &exported,
6162                &source_symbol,
6163                &ordinal.to_string(),
6164            ]);
6165            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6166            raw_refs.push(RawRef {
6167                ref_id,
6168                caller_node: None,
6169                caller_symbol: None,
6170                caller_file: rel_path.to_string(),
6171                kind: "export_alias".to_string(),
6172                short_name: None,
6173                full_ref: Some(statement.to_string()),
6174                module_path: None,
6175                import_kind: Some("export_alias".to_string()),
6176                local_name: Some(exported),
6177                requested_name: Some(source_symbol),
6178                namespace_alias: None,
6179                wildcard: false,
6180                line,
6181                byte_start: start,
6182                byte_end: end,
6183                dependencies: BTreeSet::new(),
6184            });
6185        }
6186    }
6187    SourceLessExportRefs {
6188        raw_refs,
6189        surface_parts,
6190    }
6191}
6192
6193fn build_dispatch_hints(
6194    rel_path: &str,
6195    data: &FileCallData,
6196    node_by_scoped: &HashMap<String, String>,
6197) -> Vec<DispatchHint> {
6198    let mut hints = Vec::new();
6199    let mut ordinal = 0usize;
6200    for (caller_symbol, call_sites) in &data.calls_by_symbol {
6201        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6202            continue;
6203        };
6204        for call_site in call_sites {
6205            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6206                continue;
6207            }
6208            ordinal += 1;
6209            hints.push(DispatchHint {
6210                id: ref_id(&[
6211                    rel_path,
6212                    "dispatch",
6213                    caller_symbol,
6214                    &call_site.line.to_string(),
6215                    &call_site.byte_start.to_string(),
6216                    &call_site.byte_end.to_string(),
6217                    &ordinal.to_string(),
6218                ]),
6219                method_name: call_site.callee_name.clone(),
6220                caller_node: caller_node.clone(),
6221                file: rel_path.to_string(),
6222                line: call_site.line,
6223                byte_start: call_site.byte_start,
6224                byte_end: call_site.byte_end,
6225            });
6226        }
6227    }
6228    hints
6229}
6230
6231fn surface_fingerprint(
6232    nodes: &mut [NodeRecord],
6233    data: &FileCallData,
6234    reexport_parts: &[String],
6235) -> String {
6236    nodes.sort_by(|left, right| {
6237        (left.file_path.as_str(), left.scoped_name.as_str())
6238            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6239    });
6240    let mut parts = Vec::new();
6241    for node in nodes.iter() {
6242        parts.push(format!(
6243            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6244            node.scoped_name,
6245            node.name,
6246            node.kind,
6247            node.exported,
6248            node.range.start_line,
6249            node.range.start_col,
6250            node.range.end_line,
6251            node.range.end_col,
6252            node.range_ordinal,
6253            node.signature.as_deref().unwrap_or("")
6254        ));
6255    }
6256    let mut exports = data.exported_symbols.clone();
6257    exports.sort();
6258    for export in exports {
6259        parts.push(format!("export\t{export}"));
6260    }
6261    if let Some(default_export) = &data.default_export_symbol {
6262        parts.push(format!("default\t{default_export}"));
6263    }
6264    let mut imports: Vec<String> = data
6265        .import_block
6266        .imports
6267        .iter()
6268        .map(|import| {
6269            format!(
6270                "import\t{}\t{:?}\t{}",
6271                import.module_path, import.form, import.raw_text
6272            )
6273        })
6274        .collect();
6275    imports.sort();
6276    parts.extend(imports);
6277    parts.extend(reexport_parts.iter().cloned());
6278    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6279}
6280
6281fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6282    if raw.kind != "call" {
6283        return Ok(ResolvedRef {
6284            dependencies: raw.dependencies.clone(),
6285            raw,
6286            status: "unresolved".to_string(),
6287            target_node: None,
6288            target_file: None,
6289            target_symbol: None,
6290            edge: None,
6291        });
6292    }
6293
6294    let caller_file = raw.caller_file.clone();
6295    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6296        CallGraphStoreError::MissingCallerData {
6297            file: caller_file.clone(),
6298        }
6299    })?;
6300    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6301    let short_name = raw.short_name.as_deref().unwrap_or_default();
6302    let mut dependencies = raw.dependencies.clone();
6303
6304    let resolved = match index.lang_for(&caller_file) {
6305        Some(LangId::Rust) => {
6306            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6307        }
6308        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6309            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6310        }
6311        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6312    };
6313
6314    let Some((status, target_file, target_symbol)) = resolved else {
6315        return Ok(ResolvedRef {
6316            raw,
6317            status: "unresolved".to_string(),
6318            target_node: None,
6319            target_file: None,
6320            target_symbol: None,
6321            dependencies,
6322            edge: None,
6323        });
6324    };
6325
6326    dependencies.insert(target_file.clone());
6327    let target_node = index.node_for_symbol(&target_file, &target_symbol);
6328    let source_node = raw.caller_node.clone();
6329    let edge = if let Some(source_node) = source_node {
6330        if target_file == caller_file
6331            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6332        {
6333            None
6334        } else {
6335            Some(EdgeRecord {
6336                edge_id: ref_id(&[&raw.ref_id, "edge"]),
6337                source_node,
6338                target_node: target_node.clone(),
6339                target_file: target_file.clone(),
6340                target_symbol: target_symbol.clone(),
6341                kind: "call".to_string(),
6342                line: raw.line,
6343            })
6344        }
6345    } else {
6346        None
6347    };
6348
6349    Ok(ResolvedRef {
6350        raw,
6351        status,
6352        target_node,
6353        target_file: Some(target_file),
6354        target_symbol: Some(target_symbol),
6355        dependencies,
6356        edge,
6357    })
6358}
6359
6360fn resolve_js_ts_target(
6361    index: &ProjectIndex<'_>,
6362    caller_file: &str,
6363    full_ref: &str,
6364    short_name: &str,
6365    caller_data: &FileCallData,
6366) -> Option<(String, String, String)> {
6367    if let Some((namespace, member)) = full_ref.split_once('.') {
6368        for import in &caller_data.import_block.imports {
6369            if import.namespace_import.as_deref() == Some(namespace) {
6370                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6371                    if let Some((file, symbol)) =
6372                        resolve_exported_symbol(index, &target_file, member, 0)
6373                    {
6374                        return Some(("resolved".to_string(), file, symbol));
6375                    }
6376                }
6377            }
6378        }
6379    }
6380
6381    for import in &caller_data.import_block.imports {
6382        for spec in &import.names {
6383            if crate::imports::specifier_local_name(spec) == short_name {
6384                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6385                    let requested = crate::imports::specifier_imported_name(spec);
6386                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6387                        .unwrap_or_else(|| (target_file, requested.to_string()));
6388                    return Some(("resolved".to_string(), file, symbol));
6389                }
6390            }
6391        }
6392
6393        if import.default_import.as_deref() == Some(short_name) {
6394            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6395                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6396                    .or_else(|| {
6397                        index
6398                            .files
6399                            .get(&target_file)
6400                            .and_then(|file| file.default_export.clone())
6401                            .map(|symbol| (target_file.clone(), symbol))
6402                    })
6403                    .unwrap_or_else(|| {
6404                        let file_name = Path::new(&target_file)
6405                            .file_name()
6406                            .and_then(|name| name.to_str())
6407                            .unwrap_or("unknown")
6408                            .to_string();
6409                        (target_file, format!("<default:{file_name}>"))
6410                    });
6411                return Some(("resolved".to_string(), file, symbol));
6412            }
6413        }
6414    }
6415
6416    for import in &caller_data.import_block.imports {
6417        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6418            if index
6419                .files
6420                .get(&target_file)
6421                .map(|file| file.exports.contains(short_name))
6422                .unwrap_or(false)
6423            {
6424                return Some(("resolved".to_string(), target_file, short_name.to_string()));
6425            }
6426        }
6427    }
6428
6429    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6430}
6431
6432fn resolve_exported_symbol(
6433    index: &ProjectIndex<'_>,
6434    file: &str,
6435    requested: &str,
6436    depth: usize,
6437) -> Option<(String, String)> {
6438    let mut visited = std::collections::HashMap::new();
6439    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
6440}
6441
6442/// Re-export graphs are frequently cyclic (barrel files re-exporting each
6443/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
6444/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
6445/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
6446/// (file, symbol) pair — but only when the earlier visit had at least as much
6447/// remaining depth budget (a shallower re-visit can reach leaves the deeper
6448/// first visit had to cut off at the cap, so plain visited-set pruning would
6449/// lose resolutions the capped walk finds).
6450fn resolve_exported_symbol_inner(
6451    index: &ProjectIndex<'_>,
6452    file: &str,
6453    requested: &str,
6454    depth: usize,
6455    visited: &mut std::collections::HashMap<(String, String), usize>,
6456) -> Option<(String, String)> {
6457    if depth > 16 {
6458        return None;
6459    }
6460    if requested != "default" {
6461        if let Some(source_symbol) = index
6462            .files
6463            .get(file)
6464            .and_then(|item| item.export_aliases.get(requested))
6465        {
6466            return Some((file.to_string(), source_symbol.clone()));
6467        }
6468        if index
6469            .files
6470            .get(file)
6471            .map(|item| item.exports.contains(requested))
6472            .unwrap_or(false)
6473        {
6474            return Some((file.to_string(), requested.to_string()));
6475        }
6476    } else if let Some(default) = index
6477        .files
6478        .get(file)
6479        .and_then(|item| item.default_export.clone())
6480    {
6481        return Some((file.to_string(), default));
6482    }
6483
6484    // Memo check sits after the local-export fast paths: the common direct
6485    // hit never allocates the key, and a hit through the memo would have
6486    // returned above anyway.
6487    match visited.entry((file.to_string(), requested.to_string())) {
6488        std::collections::hash_map::Entry::Occupied(mut seen) => {
6489            if *seen.get() <= depth {
6490                return None;
6491            }
6492            seen.insert(depth);
6493        }
6494        std::collections::hash_map::Entry::Vacant(slot) => {
6495            slot.insert(depth);
6496        }
6497    }
6498
6499    for reexport in index.reexports_for(file) {
6500        let mut next_requested = requested.to_string();
6501        let matches = if reexport.wildcard {
6502            true
6503        } else if let Some(source_name) = reexport.named.get(requested) {
6504            next_requested = source_name.clone();
6505            true
6506        } else {
6507            false
6508        };
6509        if !matches {
6510            continue;
6511        }
6512        if let Some(target_file) = &reexport.target_file {
6513            if let Some(target) = resolve_exported_symbol_inner(
6514                index,
6515                target_file,
6516                &next_requested,
6517                depth + 1,
6518                visited,
6519            ) {
6520                return Some(target);
6521            }
6522        }
6523    }
6524    None
6525}
6526
6527fn resolve_rust_target(
6528    index: &ProjectIndex<'_>,
6529    caller_file: &str,
6530    full_ref: &str,
6531    short_name: &str,
6532    caller_data: &FileCallData,
6533    raw: &RawRef,
6534) -> Option<(String, String, String)> {
6535    if full_ref.contains("::") {
6536        if let Some((target_file, target_symbol)) =
6537            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
6538        {
6539            return Some(("resolved".to_string(), target_file, target_symbol));
6540        }
6541    }
6542
6543    for import in &caller_data.import_block.imports {
6544        if let Some((target_file, target_symbol)) =
6545            rust_target_for_use(index, caller_file, import, short_name)
6546        {
6547            return Some(("resolved".to_string(), target_file, target_symbol));
6548        }
6549    }
6550
6551    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6552}
6553
6554fn rust_target_for_qualified(
6555    index: &ProjectIndex<'_>,
6556    caller_file: &str,
6557    full_ref: &str,
6558    short_name: &str,
6559    caller_data: &FileCallData,
6560    raw: &RawRef,
6561) -> Option<(String, String)> {
6562    let mut segments: Vec<&str> = full_ref.split("::").collect();
6563    if segments.len() < 2 {
6564        return None;
6565    }
6566    segments.pop();
6567    let requested_symbol = rust_target_symbol(full_ref, short_name);
6568
6569    for path in rust_module_path_candidates(&segments, caller_data, raw) {
6570        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
6571        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
6572            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
6573                return Some(rust_resolve_reexport_if_symbol_missing(
6574                    index,
6575                    target_file,
6576                    requested_symbol.clone(),
6577                ));
6578            }
6579        }
6580
6581        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
6582        if let Some(target) =
6583            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
6584        {
6585            return Some(target);
6586        }
6587        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
6588            return Some(rust_resolve_reexport_if_symbol_missing(
6589                index,
6590                target_file,
6591                requested_symbol.clone(),
6592            ));
6593        }
6594    }
6595    None
6596}
6597
6598fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
6599    full_ref
6600        .rsplit("::")
6601        .next()
6602        .filter(|name| !name.is_empty())
6603        .unwrap_or(short_name)
6604        .to_string()
6605}
6606
6607fn rust_resolve_reexport_if_symbol_missing(
6608    index: &ProjectIndex<'_>,
6609    target_file: String,
6610    target_symbol: String,
6611) -> (String, String) {
6612    if index
6613        .node_for_symbol(&target_file, &target_symbol)
6614        .is_some()
6615    {
6616        return (target_file, target_symbol);
6617    }
6618    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
6619        resolved
6620    } else {
6621        (target_file, target_symbol)
6622    }
6623}
6624
6625fn rust_module_path_candidates(
6626    segments: &[&str],
6627    caller_data: &FileCallData,
6628    raw: &RawRef,
6629) -> Vec<Vec<String>> {
6630    let mut candidates = Vec::new();
6631    if let Some(first) = segments.first().copied() {
6632        for import in &caller_data.import_block.imports {
6633            if !rust_import_is_visible_to_call(import, raw) {
6634                continue;
6635            }
6636            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
6637                continue;
6638            };
6639            if local_name == first {
6640                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
6641                rust_push_unique_path_candidate(&mut candidates, path_segments);
6642            }
6643        }
6644    }
6645    rust_push_unique_path_candidate(
6646        &mut candidates,
6647        segments
6648            .iter()
6649            .map(|segment| (*segment).to_string())
6650            .collect(),
6651    );
6652    candidates
6653}
6654
6655fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
6656    if !candidates.iter().any(|existing| existing == &candidate) {
6657        candidates.push(candidate);
6658    }
6659}
6660
6661fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
6662    import.byte_range.start <= raw.byte_start
6663}
6664
6665fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
6666    let path = import.module_path.trim().trim_end_matches(';').trim();
6667    if path.contains("::{") || path.contains('{') || path.contains('*') {
6668        return None;
6669    }
6670    let (path_without_alias, alias) = path
6671        .split_once(" as ")
6672        .map(|(left, right)| (left.trim(), Some(right.trim())))
6673        .unwrap_or((path, None));
6674    let segments = path_without_alias
6675        .split("::")
6676        .map(str::trim)
6677        .filter(|segment| !segment.is_empty())
6678        .collect::<Vec<_>>();
6679    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
6680    if local_name.chars().next().is_some_and(char::is_uppercase) {
6681        return None;
6682    }
6683    Some((
6684        local_name,
6685        segments
6686            .into_iter()
6687            .map(|segment| segment.to_string())
6688            .collect(),
6689    ))
6690}
6691
6692fn rust_inline_scoped_target(
6693    index: &ProjectIndex<'_>,
6694    caller_file: &str,
6695    module_segments: &[String],
6696    short_name: &str,
6697) -> Option<(String, String)> {
6698    let src_prefix = rust_src_prefix(caller_file);
6699    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
6700    file_paths.sort();
6701    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
6702        let caller = file_paths.remove(position);
6703        file_paths.insert(0, caller);
6704    }
6705
6706    for file_path in file_paths {
6707        if index.lang_for(&file_path) != Some(LangId::Rust)
6708            || rust_src_prefix(&file_path) != src_prefix
6709        {
6710            continue;
6711        }
6712        let file_module_segments = rust_module_segments_for_rel(&file_path);
6713        if !module_segments.starts_with(&file_module_segments) {
6714            continue;
6715        }
6716        let scoped_segments = &module_segments[file_module_segments.len()..];
6717        if scoped_segments.is_empty() {
6718            continue;
6719        }
6720        let mut scoped_symbol = scoped_segments.join("::");
6721        scoped_symbol.push_str("::");
6722        scoped_symbol.push_str(short_name);
6723        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
6724            return Some((file_path, scoped_symbol));
6725        }
6726    }
6727    None
6728}
6729
6730fn rust_target_for_use(
6731    index: &ProjectIndex<'_>,
6732    caller_file: &str,
6733    import: &ImportStatement,
6734    short_name: &str,
6735) -> Option<(String, String)> {
6736    let path = import.module_path.trim().trim_end_matches(';');
6737    if let Some(brace_start) = path.find("::{") {
6738        let prefix = &path[..brace_start];
6739        if import.names.iter().any(|name| name == short_name) {
6740            let prefix_segments: Vec<&str> = prefix.split("::").collect();
6741            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
6742            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6743            return Some((file, short_name.to_string()));
6744        }
6745        return None;
6746    }
6747
6748    let (path_without_alias, alias) = path
6749        .split_once(" as ")
6750        .map(|(left, right)| (left.trim(), Some(right.trim())))
6751        .unwrap_or((path, None));
6752    let segments: Vec<&str> = path_without_alias.split("::").collect();
6753    let imported = alias.or_else(|| segments.last().copied())?;
6754    if imported != short_name {
6755        return None;
6756    }
6757    if segments.len() < 2 {
6758        return None;
6759    }
6760    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
6761    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
6762    Some((file, segments.last().unwrap_or(&short_name).to_string()))
6763}
6764
6765fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
6766    let crate_name = segments.first().copied()?;
6767    let src_prefix = index.crate_src_prefix(crate_name)?;
6768    let module_segments = segments[1..]
6769        .iter()
6770        .map(|segment| segment.to_string())
6771        .collect::<Vec<_>>();
6772    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
6773}
6774
6775/// Walk the project tree once and map every Rust crate name (package name with
6776/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
6777/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
6778/// longer re-walks the filesystem once per ref.
6779fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
6780    let mut prefixes = HashMap::new();
6781    let mut stack = vec![project_root.to_path_buf()];
6782    while let Some(dir) = stack.pop() {
6783        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
6784        if matches!(name, "target" | "node_modules" | ".git") {
6785            continue;
6786        }
6787        let manifest = dir.join("Cargo.toml");
6788        if manifest.is_file() {
6789            let crate_names = rust_manifest_crate_names(&manifest);
6790            if !crate_names.is_empty() {
6791                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
6792                for crate_name in crate_names {
6793                    prefixes
6794                        .entry(crate_name)
6795                        .or_insert_with(|| src_prefix.clone());
6796                }
6797            }
6798        }
6799        let Ok(entries) = std::fs::read_dir(&dir) else {
6800            continue;
6801        };
6802        for entry in entries.flatten() {
6803            let path = entry.path();
6804            if path.is_dir() {
6805                stack.push(path);
6806            }
6807        }
6808    }
6809    prefixes
6810}
6811
6812/// Extract the crate names a manifest defines: the normalized package name
6813/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
6814/// reachable by either spelling, matching the previous match semantics.
6815fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
6816    let Ok(source) = std::fs::read_to_string(manifest) else {
6817        return Vec::new();
6818    };
6819    let mut in_lib = false;
6820    let mut package_name = None;
6821    let mut lib_name = None;
6822    for line in source.lines() {
6823        let trimmed = line.trim();
6824        if trimmed.starts_with('[') {
6825            in_lib = trimmed == "[lib]";
6826            continue;
6827        }
6828        let Some((key, value)) = trimmed.split_once('=') else {
6829            continue;
6830        };
6831        let key = key.trim();
6832        let value = value.trim().trim_matches('"');
6833        if in_lib && key == "name" {
6834            lib_name = Some(value.to_string());
6835        } else if !in_lib && key == "name" && package_name.is_none() {
6836            package_name = Some(value.to_string());
6837        }
6838    }
6839    let mut names = Vec::new();
6840    if let Some(lib) = lib_name {
6841        names.push(lib);
6842    }
6843    if let Some(package) = package_name {
6844        let normalized = package.replace('-', "_");
6845        if !names.contains(&normalized) {
6846            names.push(normalized);
6847        }
6848    }
6849    names
6850}
6851
6852fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
6853    if segments.is_empty() {
6854        return Some(Vec::new());
6855    }
6856    let caller_segments = rust_module_segments_for_rel(caller_file);
6857    match segments[0] {
6858        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
6859        "self" => {
6860            let mut resolved = caller_segments;
6861            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6862            Some(resolved)
6863        }
6864        "super" => {
6865            let mut resolved = caller_segments;
6866            resolved.pop();
6867            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
6868            Some(resolved)
6869        }
6870        _ => {
6871            let mut resolved = caller_segments;
6872            resolved.pop();
6873            resolved.extend(segments.iter().map(|item| item.to_string()));
6874            Some(resolved)
6875        }
6876    }
6877}
6878
6879fn rust_file_for_segments(
6880    index: &ProjectIndex<'_>,
6881    caller_file: &str,
6882    segments: &[String],
6883) -> Option<String> {
6884    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
6885}
6886
6887fn rust_file_for_src_prefix(
6888    index: &ProjectIndex<'_>,
6889    src_prefix: &str,
6890    segments: &[String],
6891) -> Option<String> {
6892    let candidate = if segments.is_empty() {
6893        [src_prefix, "lib.rs"].join("/")
6894    } else {
6895        format!("{}/{}.rs", src_prefix, segments.join("/"))
6896    };
6897    if index.files.contains_key(&candidate) {
6898        return Some(candidate);
6899    }
6900    if !segments.is_empty() {
6901        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
6902        if index.files.contains_key(&mod_candidate) {
6903            return Some(mod_candidate);
6904        }
6905    }
6906    None
6907}
6908
6909fn rust_src_prefix(rel_path: &str) -> String {
6910    rel_path
6911        .split_once("/src/")
6912        .map(|(prefix, _)| format!("{prefix}/src"))
6913        .unwrap_or_else(|| "src".to_string())
6914}
6915
6916fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
6917    let after_src = rel_path
6918        .split_once("/src/")
6919        .map(|(_, rest)| rest)
6920        .or_else(|| rel_path.strip_prefix("src/"))
6921        .unwrap_or(rel_path);
6922    if matches!(after_src, "lib.rs" | "main.rs") {
6923        return Vec::new();
6924    }
6925    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
6926        return prefix.split('/').map(|item| item.to_string()).collect();
6927    }
6928    after_src
6929        .strip_suffix(".rs")
6930        .unwrap_or(after_src)
6931        .split('/')
6932        .map(|item| item.to_string())
6933        .collect()
6934}
6935
6936fn resolve_local_target(
6937    _index: &ProjectIndex<'_>,
6938    caller_file: &str,
6939    full_ref: &str,
6940    short_name: &str,
6941    caller_data: &FileCallData,
6942) -> Option<(String, String, String)> {
6943    if !callgraph::is_bare_callee(full_ref, short_name) {
6944        return None;
6945    }
6946    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
6947        .ok()
6948        .map(|symbol| {
6949            (
6950                "resolved_local".to_string(),
6951                caller_file.to_string(),
6952                symbol,
6953            )
6954        })
6955}
6956
6957impl<'a> ProjectIndex<'a> {
6958    fn from_parts(
6959        project_root: &Path,
6960        files: HashMap<String, DbFileIndex>,
6961        caller_data: HashMap<String, &'a FileCallData>,
6962    ) -> Self {
6963        Self {
6964            project_root: project_root.to_path_buf(),
6965            files,
6966            caller_data,
6967            workspace_crate_prefixes: std::sync::OnceLock::new(),
6968        }
6969    }
6970
6971    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
6972        let mut files = HashMap::new();
6973        let mut caller_data = HashMap::new();
6974        for extract in extracts {
6975            let index = DbFileIndex::from_extract(project_root, extract);
6976            caller_data.insert(extract.rel_path.clone(), &extract.data);
6977            files.insert(extract.rel_path.clone(), index);
6978        }
6979        Self::from_parts(project_root, files, caller_data)
6980    }
6981
6982    fn from_db_and_callers(
6983        tx: &Transaction<'_>,
6984        project_root: &Path,
6985        caller_extracts: &'a HashMap<String, FileExtract>,
6986    ) -> Result<Self> {
6987        let mut files = load_db_file_indexes(tx, project_root)?;
6988        let mut caller_data = HashMap::new();
6989        for (rel_path, extract) in caller_extracts {
6990            files.insert(
6991                rel_path.clone(),
6992                DbFileIndex::from_extract(project_root, extract),
6993            );
6994            caller_data.insert(rel_path.clone(), &extract.data);
6995        }
6996        Ok(Self::from_parts(project_root, files, caller_data))
6997    }
6998
6999    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
7000        self.files.get(rel_path).and_then(|file| file.lang)
7001    }
7002
7003    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
7004        self.files
7005            .get(caller_file)
7006            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
7007    }
7008
7009    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
7010        self.files
7011            .get(rel_path)
7012            .map(|file| file.reexports.as_slice())
7013            .unwrap_or(&[])
7014    }
7015
7016    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
7017        self.files.get(rel_path).and_then(|file| {
7018            file.node_by_scoped
7019                .get(symbol)
7020                .cloned()
7021                .or_else(|| file.node_by_bare.get(symbol).cloned())
7022        })
7023    }
7024}
7025
7026impl DbFileIndex {
7027    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
7028        let mut node_by_scoped = HashMap::new();
7029        let mut node_by_bare = HashMap::new();
7030        for node in &extract.nodes {
7031            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
7032            node_by_bare
7033                .entry(node.name.clone())
7034                .or_insert(node.id.clone());
7035        }
7036        let mut export_aliases = HashMap::new();
7037        for raw_ref in &extract.raw_refs {
7038            if raw_ref.kind == "export_alias" {
7039                if let (Some(exported), Some(source_symbol)) =
7040                    (&raw_ref.local_name, &raw_ref.requested_name)
7041                {
7042                    export_aliases.insert(exported.clone(), source_symbol.clone());
7043                }
7044            }
7045        }
7046        let mut module_targets = HashMap::new();
7047        let mut reexports = Vec::new();
7048        for raw_ref in &extract.raw_refs {
7049            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
7050                continue;
7051            }
7052            let Some(module_path) = &raw_ref.module_path else {
7053                continue;
7054            };
7055            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
7056            module_targets
7057                .entry(module_path.clone())
7058                .or_insert_with(|| target_file.clone());
7059            if raw_ref.kind == "reexport" {
7060                reexports.push(reexport_index_from_raw(raw_ref, target_file));
7061            }
7062        }
7063        Self {
7064            lang: Some(extract.lang),
7065            exports: extract.data.exported_symbols.iter().cloned().collect(),
7066            default_export: extract.data.default_export_symbol.clone(),
7067            export_aliases,
7068            node_by_scoped,
7069            node_by_bare,
7070            module_targets,
7071            reexports,
7072        }
7073    }
7074}
7075
7076fn load_db_file_indexes(
7077    tx: &Transaction<'_>,
7078    project_root: &Path,
7079) -> Result<HashMap<String, DbFileIndex>> {
7080    let mut files = HashMap::new();
7081    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
7082    let rows = stmt.query_map([], |row| {
7083        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7084    })?;
7085    for row in rows {
7086        let (rel_path, lang) = row?;
7087        files.insert(
7088            rel_path.clone(),
7089            DbFileIndex {
7090                lang: lang_from_label(&lang),
7091                exports: HashSet::new(),
7092                default_export: None,
7093                export_aliases: HashMap::new(),
7094                node_by_scoped: HashMap::new(),
7095                node_by_bare: HashMap::new(),
7096                module_targets: HashMap::new(),
7097                reexports: Vec::new(),
7098            },
7099        );
7100    }
7101
7102    let mut node_stmt = tx.prepare(
7103        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
7104    )?;
7105    let nodes = node_stmt.query_map([], |row| {
7106        Ok((
7107            row.get::<_, String>(0)?,
7108            row.get::<_, String>(1)?,
7109            row.get::<_, String>(2)?,
7110            row.get::<_, String>(3)?,
7111            row.get::<_, i64>(4)? != 0,
7112            row.get::<_, i64>(5)? != 0,
7113        ))
7114    })?;
7115    for row in nodes {
7116        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
7117        let file = files
7118            .entry(file_path.clone())
7119            .or_insert_with(|| DbFileIndex {
7120                lang: None,
7121                exports: HashSet::new(),
7122                default_export: None,
7123                export_aliases: HashMap::new(),
7124                node_by_scoped: HashMap::new(),
7125                node_by_bare: HashMap::new(),
7126                module_targets: HashMap::new(),
7127                reexports: Vec::new(),
7128            });
7129        if exported {
7130            file.exports.insert(name.clone());
7131            file.exports.insert(scoped_name.clone());
7132        }
7133        if is_default_export {
7134            file.default_export = Some(scoped_name.clone());
7135        }
7136        file.node_by_scoped.insert(scoped_name, id.clone());
7137        file.node_by_bare.entry(name).or_insert(id);
7138    }
7139    let file_keys: HashSet<String> = files.keys().cloned().collect();
7140    // Persisted caller extracts supply import targets. Only reexports from other
7141    // files need dependency reconstruction, and their caller dependencies are
7142    // loaded once instead of issuing repeated SQLite queries per reference.
7143    let dependencies_by_file = load_file_dependencies_index(tx)?;
7144    let mut ref_stmt = tx.prepare(
7145        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7146         FROM refs WHERE kind IN ('reexport', 'export_alias')",
7147    )?;
7148    let ref_rows = ref_stmt.query_map([], |row| {
7149        Ok((
7150            row.get::<_, String>(0)?,
7151            row.get::<_, String>(1)?,
7152            row.get::<_, String>(2)?,
7153            row.get::<_, Option<String>>(3)?,
7154            row.get::<_, Option<String>>(4)?,
7155            row.get::<_, i64>(5)? != 0,
7156            row.get::<_, Option<String>>(6)?,
7157            row.get::<_, Option<String>>(7)?,
7158        ))
7159    })?;
7160    for row in ref_rows {
7161        let (
7162            ref_id,
7163            caller_file,
7164            kind,
7165            module_path,
7166            full_ref,
7167            wildcard,
7168            local_name,
7169            requested_name,
7170        ) = row?;
7171        if kind == "export_alias" {
7172            if let (Some(exported), Some(source_symbol), Some(file)) =
7173                (local_name, requested_name, files.get_mut(&caller_file))
7174            {
7175                file.export_aliases.insert(exported, source_symbol);
7176            }
7177            continue;
7178        }
7179        let Some(module_path) = module_path else {
7180            continue;
7181        };
7182        let file_deps = dependencies_by_file
7183            .get(&caller_file)
7184            .cloned()
7185            .unwrap_or_default();
7186        let deps = stored_dependencies_for_module(
7187            project_root,
7188            &caller_file,
7189            &module_path,
7190            &file_deps,
7191            &file_keys,
7192        );
7193        let target_file = deps
7194            .iter()
7195            .find(|dep| file_keys.contains(*dep))
7196            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7197        if let Some(file) = files.get_mut(&caller_file) {
7198            file.module_targets
7199                .entry(module_path.clone())
7200                .or_insert_with(|| target_file.clone());
7201            if kind == "reexport" {
7202                let raw = RawRef {
7203                    ref_id,
7204                    caller_node: None,
7205                    caller_symbol: None,
7206                    caller_file,
7207                    kind,
7208                    short_name: None,
7209                    full_ref,
7210                    module_path: Some(module_path),
7211                    import_kind: Some("reexport".to_string()),
7212                    local_name: None,
7213                    requested_name: None,
7214                    namespace_alias: None,
7215                    wildcard,
7216                    line: 0,
7217                    byte_start: 0,
7218                    byte_end: 0,
7219                    dependencies: deps,
7220                };
7221                file.reexports
7222                    .push(reexport_index_from_raw(&raw, target_file));
7223            }
7224        }
7225    }
7226
7227    Ok(files)
7228}
7229
7230fn stored_dependencies_for_module(
7231    project_root: &Path,
7232    caller_file: &str,
7233    module_path: &str,
7234    caller_dependencies: &BTreeSet<String>,
7235    indexed_files: &HashSet<String>,
7236) -> BTreeSet<String> {
7237    let caller_path = project_root.join(caller_file);
7238    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7239    if module_path.starts_with('.') {
7240        let caller_dir = caller_path.parent().unwrap_or(project_root);
7241        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7242            let normalized = if candidate.is_file() {
7243                canonicalize_path(&candidate)
7244            } else {
7245                candidate
7246            };
7247            candidates.insert(relative_path(project_root, &normalized));
7248        }
7249    }
7250    let exact = candidates
7251        .intersection(caller_dependencies)
7252        .filter(|dependency| indexed_files.contains(*dependency))
7253        .cloned()
7254        .collect::<BTreeSet<_>>();
7255    if !exact.is_empty() || module_path.starts_with('.') {
7256        return exact;
7257    }
7258
7259    let module_path = rust_module_path_without_alias_or_use_list(module_path)
7260        .trim_matches(|character| matches!(character, '\'' | '"'));
7261    let package_name = module_path
7262        .split('/')
7263        .next_back()
7264        .unwrap_or(module_path)
7265        .replace('_', "-");
7266    let matched = caller_dependencies
7267        .iter()
7268        .filter(|dependency| indexed_files.contains(*dependency))
7269        .filter(|dependency| {
7270            dependency.as_str() == module_path
7271                || dependency.ends_with(&format!("/{module_path}"))
7272                || Path::new(dependency).components().any(|component| {
7273                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7274                })
7275        })
7276        .cloned()
7277        .collect::<BTreeSet<_>>();
7278    if matched.len() == 1 {
7279        matched
7280    } else {
7281        BTreeSet::new()
7282    }
7283}
7284
7285fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7286    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7287    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7288    let rows = stmt.query_map([], |row| {
7289        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7290    })?;
7291    for row in rows {
7292        let (file_path, dependency) = row?;
7293        by_file.entry(file_path).or_default().insert(dependency);
7294    }
7295    Ok(by_file)
7296}
7297
7298struct ColdBuildInsertStatements<'stmt> {
7299    file: Statement<'stmt>,
7300    node: Statement<'stmt>,
7301    file_dependency: Statement<'stmt>,
7302    dispatch_hint: Statement<'stmt>,
7303    backend_state: Statement<'stmt>,
7304    reference: Statement<'stmt>,
7305    edge: Statement<'stmt>,
7306}
7307
7308impl<'stmt> ColdBuildInsertStatements<'stmt> {
7309    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7310        Ok(Self {
7311            file: tx.prepare(
7312                "INSERT OR REPLACE INTO files(
7313                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7314                    is_public_api, surface_fingerprint, indexed_at
7315                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7316            )?,
7317            node: tx.prepare(
7318                "INSERT OR REPLACE INTO nodes(
7319                    id, file_path, name, scoped_name, kind, start_line, start_col,
7320                    end_line, end_col, range_ordinal, signature, exported,
7321                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
7322                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7323            )?,
7324            file_dependency: tx.prepare(
7325                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7326            )?,
7327            dispatch_hint: tx.prepare(
7328                "INSERT OR REPLACE INTO dispatch_hints(
7329                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7330                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7331            )?,
7332            backend_state: tx.prepare(
7333                "INSERT OR REPLACE INTO backend_file_state(
7334                    backend, workspace_root, file_path, content_hash, status, updated_at
7335                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7336            )?,
7337            reference: tx.prepare(
7338                "INSERT OR REPLACE INTO refs(
7339                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7340                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7341                    byte_start, byte_end, status, target_node, target_file, target_symbol,
7342                    provenance
7343                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7344            )?,
7345            edge: tx.prepare(
7346                "INSERT OR REPLACE INTO edges(
7347                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7348                    kind, line, provenance
7349                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7350            )?,
7351        })
7352    }
7353}
7354
7355fn insert_file_extract_prepared(
7356    statements: &mut ColdBuildInsertStatements<'_>,
7357    workspace_root: &str,
7358    extract: &FileExtract,
7359) -> Result<()> {
7360    statements.file.execute(params![
7361        extract.rel_path,
7362        hash_to_hex(extract.freshness.content_hash),
7363        system_time_to_ns(extract.freshness.mtime),
7364        extract.freshness.size as i64,
7365        lang_label(extract.lang),
7366        extract.surface_fingerprint,
7367        unix_seconds_now(),
7368    ])?;
7369    for node in &extract.nodes {
7370        statements.node.execute(params![
7371            node.id,
7372            node.file_path,
7373            node.name,
7374            node.scoped_name,
7375            node.kind,
7376            node.range.start_line as i64,
7377            node.range.start_col as i64,
7378            node.range.end_line as i64,
7379            node.range.end_col as i64,
7380            node.range_ordinal as i64,
7381            node.signature,
7382            bool_int(node.exported),
7383            bool_int(node.is_default_export),
7384            bool_int(node.is_type_like),
7385            bool_int(node.is_callgraph_entry_point),
7386            PROVENANCE_TREESITTER,
7387        ])?;
7388    }
7389
7390    let mut dependencies = BTreeSet::new();
7391    for raw_ref in &extract.raw_refs {
7392        dependencies.extend(raw_ref.dependencies.iter().cloned());
7393    }
7394    for dep_file in &dependencies {
7395        statements
7396            .file_dependency
7397            .execute(params![extract.rel_path, dep_file])?;
7398    }
7399
7400    for hint in &extract.dispatch_hints {
7401        statements.dispatch_hint.execute(params![
7402            hint.id,
7403            hint.method_name,
7404            hint.caller_node,
7405            hint.file,
7406            hint.line as i64,
7407            hint.byte_start as i64,
7408            hint.byte_end as i64,
7409            PROVENANCE_TREESITTER,
7410        ])?;
7411    }
7412    insert_backend_state_prepared(
7413        &mut statements.backend_state,
7414        workspace_root,
7415        &extract.rel_path,
7416        Some(&extract.freshness.content_hash),
7417        "fresh",
7418    )?;
7419    Ok(())
7420}
7421
7422fn insert_backend_state_prepared(
7423    stmt: &mut Statement<'_>,
7424    workspace_root: &str,
7425    rel_path: &str,
7426    content_hash: Option<&blake3::Hash>,
7427    status: &str,
7428) -> Result<()> {
7429    let hash = content_hash
7430        .map(|hash| hash_to_hex(*hash))
7431        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
7432    stmt.execute(params![
7433        BACKEND_TREESITTER,
7434        workspace_root,
7435        rel_path,
7436        hash,
7437        status,
7438        unix_seconds_now(),
7439    ])?;
7440    Ok(())
7441}
7442
7443fn insert_resolved_ref_prepared(
7444    statements: &mut ColdBuildInsertStatements<'_>,
7445    resolved: &ResolvedRef,
7446) -> Result<()> {
7447    let raw = &resolved.raw;
7448    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7449    statements.reference.execute(params![
7450        raw.ref_id,
7451        raw.caller_node,
7452        raw.caller_file,
7453        raw.kind,
7454        raw.short_name,
7455        raw.full_ref,
7456        raw.module_path,
7457        raw.import_kind,
7458        raw.local_name,
7459        raw.requested_name,
7460        raw.namespace_alias,
7461        bool_int(raw.wildcard),
7462        raw.line as i64,
7463        raw.byte_start as i64,
7464        raw.byte_end as i64,
7465        resolved.status,
7466        resolved.target_node,
7467        resolved.target_file,
7468        resolved.target_symbol,
7469        PROVENANCE_TREESITTER,
7470    ])?;
7471    if let Some(edge) = &resolved.edge {
7472        statements.edge.execute(params![
7473            edge.edge_id,
7474            raw.ref_id,
7475            edge.source_node,
7476            edge.target_node,
7477            edge.target_file,
7478            edge.target_symbol,
7479            edge.kind,
7480            edge.line as i64,
7481            PROVENANCE_TREESITTER,
7482        ])?;
7483    }
7484    Ok(())
7485}
7486
7487fn insert_file_extract(
7488    tx: &Transaction<'_>,
7489    project_root: &Path,
7490    extract: &FileExtract,
7491) -> Result<()> {
7492    tx.execute(
7493        "INSERT OR REPLACE INTO files(
7494            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7495            is_public_api, surface_fingerprint, indexed_at
7496        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7497        params![
7498            extract.rel_path,
7499            hash_to_hex(extract.freshness.content_hash),
7500            system_time_to_ns(extract.freshness.mtime),
7501            extract.freshness.size as i64,
7502            lang_label(extract.lang),
7503            extract.surface_fingerprint,
7504            unix_seconds_now(),
7505        ],
7506    )?;
7507    for node in &extract.nodes {
7508        tx.execute(
7509            "INSERT OR REPLACE INTO nodes(
7510                id, file_path, name, scoped_name, kind, start_line, start_col,
7511                end_line, end_col, range_ordinal, signature, exported,
7512                is_default_export, is_type_like, is_callgraph_entry_point, provenance
7513            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7514            params![
7515                node.id,
7516                node.file_path,
7517                node.name,
7518                node.scoped_name,
7519                node.kind,
7520                node.range.start_line as i64,
7521                node.range.start_col as i64,
7522                node.range.end_line as i64,
7523                node.range.end_col as i64,
7524                node.range_ordinal as i64,
7525                node.signature,
7526                bool_int(node.exported),
7527                bool_int(node.is_default_export),
7528                bool_int(node.is_type_like),
7529                bool_int(node.is_callgraph_entry_point),
7530                PROVENANCE_TREESITTER,
7531            ],
7532        )?;
7533    }
7534    let mut dependencies = BTreeSet::new();
7535    for raw_ref in &extract.raw_refs {
7536        dependencies.extend(raw_ref.dependencies.iter().cloned());
7537    }
7538    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
7539
7540    for hint in &extract.dispatch_hints {
7541        tx.execute(
7542            "INSERT OR REPLACE INTO dispatch_hints(
7543                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7544            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7545            params![
7546                hint.id,
7547                hint.method_name,
7548                hint.caller_node,
7549                hint.file,
7550                hint.line as i64,
7551                hint.byte_start as i64,
7552                hint.byte_end as i64,
7553                PROVENANCE_TREESITTER,
7554            ],
7555        )?;
7556    }
7557    mark_backend_state(
7558        tx,
7559        project_root,
7560        &extract.rel_path,
7561        Some(&extract.freshness.content_hash),
7562        "fresh",
7563    )?;
7564    Ok(())
7565}
7566
7567fn insert_file_dependencies(
7568    tx: &Transaction<'_>,
7569    file_path: &str,
7570    dependencies: &BTreeSet<String>,
7571) -> Result<()> {
7572    for dep_file in dependencies {
7573        tx.execute(
7574            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7575            params![file_path, dep_file],
7576        )?;
7577    }
7578    Ok(())
7579}
7580
7581fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
7582    let raw = &resolved.raw;
7583    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7584    tx.execute(
7585        "INSERT OR REPLACE INTO refs(
7586            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7587            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7588            byte_start, byte_end, status, target_node, target_file, target_symbol,
7589            provenance
7590        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7591        params![
7592            raw.ref_id,
7593            raw.caller_node,
7594            raw.caller_file,
7595            raw.kind,
7596            raw.short_name,
7597            raw.full_ref,
7598            raw.module_path,
7599            raw.import_kind,
7600            raw.local_name,
7601            raw.requested_name,
7602            raw.namespace_alias,
7603            bool_int(raw.wildcard),
7604            raw.line as i64,
7605            raw.byte_start as i64,
7606            raw.byte_end as i64,
7607            resolved.status,
7608            resolved.target_node,
7609            resolved.target_file,
7610            resolved.target_symbol,
7611            PROVENANCE_TREESITTER,
7612        ],
7613    )?;
7614    if let Some(edge) = &resolved.edge {
7615        tx.execute(
7616            "INSERT OR REPLACE INTO edges(
7617                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7618                kind, line, provenance
7619            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7620            params![
7621                edge.edge_id,
7622                raw.ref_id,
7623                edge.source_node,
7624                edge.target_node,
7625                edge.target_file,
7626                edge.target_symbol,
7627                edge.kind,
7628                edge.line as i64,
7629                PROVENANCE_TREESITTER,
7630            ],
7631        )?;
7632    }
7633    Ok(())
7634}
7635
7636fn insert_method_dispatch_edges(
7637    tx: &Transaction<'_>,
7638    project_root: &Path,
7639    caller_files: Option<&BTreeSet<String>>,
7640) -> Result<usize> {
7641    let references = load_name_match_refs(tx, caller_files)?;
7642    if references.is_empty() {
7643        return Ok(0);
7644    }
7645
7646    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
7647    let mut source_cache: DispatchSourceCache = HashMap::new();
7648    let mut inserted = 0usize;
7649    for reference in references {
7650        let key = (reference.method_name.clone(), reference.lang.clone());
7651        let candidates = match candidates_by_name.entry(key) {
7652            Entry::Occupied(entry) => entry.into_mut(),
7653            Entry::Vacant(entry) => {
7654                let candidates =
7655                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
7656                entry.insert(candidates)
7657            }
7658        };
7659
7660        if let Some(receiver_type) =
7661            infer_receiver_type(project_root, &reference, &mut source_cache)
7662        {
7663            let Some(candidate) =
7664                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
7665            else {
7666                continue;
7667            };
7668            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
7669            inserted += 1;
7670            continue;
7671        }
7672
7673        if method_name_match_denylisted(&reference.method_name) {
7674            continue;
7675        }
7676
7677        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
7678            continue;
7679        };
7680        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
7681        inserted += 1;
7682    }
7683    Ok(inserted)
7684}
7685
7686fn insert_method_dispatch_edges_chunked(
7687    tx: &Transaction<'_>,
7688    project_root: &Path,
7689    caller_files: &BTreeSet<String>,
7690    chunk_size: usize,
7691) -> Result<usize> {
7692    if caller_files.is_empty() {
7693        return Ok(0);
7694    }
7695    if chunk_size == 0 || caller_files.len() <= chunk_size {
7696        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
7697    }
7698
7699    let mut inserted = 0usize;
7700    let mut batch = BTreeSet::new();
7701    for caller_file in caller_files {
7702        batch.insert(caller_file.clone());
7703        if batch.len() == chunk_size {
7704            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7705            batch.clear();
7706        }
7707    }
7708    if !batch.is_empty() {
7709        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
7710    }
7711    Ok(inserted)
7712}
7713
7714fn insert_method_dispatch_edge(
7715    tx: &Transaction<'_>,
7716    reference: &NameMatchRef,
7717    candidate: &NameMatchCandidate,
7718    provenance: &str,
7719) -> Result<()> {
7720    tx.execute(
7721        "INSERT OR REPLACE INTO edges(
7722            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7723            kind, line, provenance
7724        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
7725        params![
7726            ref_id(&[&reference.ref_id, provenance, "edge"]),
7727            &reference.ref_id,
7728            &reference.caller_node,
7729            &candidate.node_id,
7730            &candidate.file_path,
7731            &candidate.scoped_name,
7732            reference.line as i64,
7733            provenance,
7734        ],
7735    )?;
7736    Ok(())
7737}
7738
7739fn delete_method_dispatch_edges_for_callers(
7740    tx: &Transaction<'_>,
7741    caller_files: &BTreeSet<String>,
7742) -> Result<()> {
7743    if caller_files.is_empty() {
7744        return Ok(());
7745    }
7746
7747    let mut stmt = tx.prepare(
7748        "DELETE FROM edges
7749         WHERE provenance IN (?1, ?2)
7750           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
7751    )?;
7752    for caller_file in caller_files {
7753        stmt.execute(params![
7754            PROVENANCE_NAME_MATCH,
7755            PROVENANCE_TYPE_MATCH,
7756            caller_file
7757        ])?;
7758    }
7759    Ok(())
7760}
7761
7762fn load_name_match_refs(
7763    tx: &Transaction<'_>,
7764    caller_files: Option<&BTreeSet<String>>,
7765) -> Result<Vec<NameMatchRef>> {
7766    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
7767                           n.signature, r.short_name, r.full_ref, r.line, f.lang
7768                    FROM refs r
7769                    JOIN files f ON f.path = r.caller_file
7770                    JOIN nodes n ON n.id = r.caller_node
7771                    WHERE r.kind = 'call'
7772                      AND r.status = 'unresolved'
7773                      AND r.caller_node IS NOT NULL
7774                      AND r.full_ref IS NOT NULL
7775                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
7776                      AND NOT EXISTS (
7777                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
7778                      )";
7779    let mut references = Vec::new();
7780
7781    if let Some(caller_files) = caller_files {
7782        if caller_files.is_empty() {
7783            return Ok(references);
7784        }
7785        let sql = format!(
7786            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
7787        );
7788        let mut stmt = tx.prepare(&sql)?;
7789        for caller_file in caller_files {
7790            let rows = stmt.query_map(params![caller_file], |row| {
7791                Ok((
7792                    row.get::<_, String>(0)?,
7793                    row.get::<_, Option<String>>(1)?,
7794                    row.get::<_, String>(2)?,
7795                    row.get::<_, String>(3)?,
7796                    row.get::<_, Option<String>>(4)?,
7797                    row.get::<_, Option<String>>(5)?,
7798                    row.get::<_, Option<String>>(6)?,
7799                    row.get::<_, i64>(7)?,
7800                    row.get::<_, String>(8)?,
7801                ))
7802            })?;
7803            for row in rows {
7804                let (
7805                    ref_id,
7806                    caller_node,
7807                    caller_file,
7808                    caller_symbol,
7809                    caller_signature,
7810                    short_name,
7811                    full_ref,
7812                    line,
7813                    lang,
7814                ) = row?;
7815                if let Some(reference) = name_match_ref_from_parts(
7816                    ref_id,
7817                    caller_node,
7818                    caller_file,
7819                    caller_symbol,
7820                    caller_signature,
7821                    short_name,
7822                    full_ref,
7823                    line,
7824                    lang,
7825                ) {
7826                    references.push(reference);
7827                }
7828            }
7829        }
7830        return Ok(references);
7831    }
7832
7833    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
7834    let mut stmt = tx.prepare(&sql)?;
7835    let rows = stmt.query_map([], |row| {
7836        Ok((
7837            row.get::<_, String>(0)?,
7838            row.get::<_, Option<String>>(1)?,
7839            row.get::<_, String>(2)?,
7840            row.get::<_, String>(3)?,
7841            row.get::<_, Option<String>>(4)?,
7842            row.get::<_, Option<String>>(5)?,
7843            row.get::<_, Option<String>>(6)?,
7844            row.get::<_, i64>(7)?,
7845            row.get::<_, String>(8)?,
7846        ))
7847    })?;
7848    for row in rows {
7849        let (
7850            ref_id,
7851            caller_node,
7852            caller_file,
7853            caller_symbol,
7854            caller_signature,
7855            short_name,
7856            full_ref,
7857            line,
7858            lang,
7859        ) = row?;
7860        if let Some(reference) = name_match_ref_from_parts(
7861            ref_id,
7862            caller_node,
7863            caller_file,
7864            caller_symbol,
7865            caller_signature,
7866            short_name,
7867            full_ref,
7868            line,
7869            lang,
7870        ) {
7871            references.push(reference);
7872        }
7873    }
7874    Ok(references)
7875}
7876
7877#[allow(clippy::too_many_arguments)]
7878fn name_match_ref_from_parts(
7879    ref_id: String,
7880    caller_node: Option<String>,
7881    caller_file: String,
7882    caller_symbol: String,
7883    caller_signature: Option<String>,
7884    short_name: Option<String>,
7885    full_ref: Option<String>,
7886    line: i64,
7887    lang: String,
7888) -> Option<NameMatchRef> {
7889    let caller_node = caller_node?;
7890    let full_ref = full_ref?;
7891    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
7892    let method_name = if member.is_empty() {
7893        short_name.as_deref()?.to_string()
7894    } else {
7895        member
7896    };
7897    Some(NameMatchRef {
7898        ref_id,
7899        caller_node,
7900        caller_file,
7901        caller_symbol,
7902        caller_signature,
7903        receiver,
7904        method_name,
7905        colon_dispatch,
7906        line: line.max(0) as u32,
7907        lang,
7908    })
7909}
7910
7911fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
7912    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
7913    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
7914    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
7915    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
7916        .into_iter()
7917        .flatten()
7918        .max_by_key(|(index, _, _)| *index)?;
7919    if delimiter == 0 {
7920        return None;
7921    }
7922    let member_start = delimiter + delimiter_len;
7923    if member_start >= full_ref.len() {
7924        return None;
7925    }
7926    let receiver = last_name_segment(&full_ref[..delimiter]);
7927    let member = &full_ref[member_start..];
7928    if receiver.is_empty() || member.is_empty() {
7929        return None;
7930    }
7931    Some((receiver.to_string(), member.to_string(), colon_dispatch))
7932}
7933
7934fn last_name_segment(value: &str) -> &str {
7935    value
7936        .rsplit(['.', ':', '/', '\\', '-', '>'])
7937        .find(|segment| !segment.is_empty())
7938        .unwrap_or(value)
7939}
7940
7941fn load_name_match_candidates(
7942    tx: &Transaction<'_>,
7943    method_name: &str,
7944    lang: &str,
7945) -> Result<Vec<NameMatchCandidate>> {
7946    let mut stmt = tx.prepare(
7947        "SELECT n.id, n.file_path, n.scoped_name, n.kind
7948         FROM nodes n JOIN files f ON f.path = n.file_path
7949         WHERE n.name = ?1
7950           AND f.lang = ?2
7951           AND n.kind IN ('method', 'function')
7952         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
7953    )?;
7954    let rows = stmt.query_map(params![method_name, lang], |row| {
7955        Ok(NameMatchCandidate {
7956            node_id: row.get(0)?,
7957            file_path: row.get(1)?,
7958            scoped_name: row.get(2)?,
7959            kind: row.get(3)?,
7960        })
7961    })?;
7962    rows.collect::<std::result::Result<Vec<_>, _>>()
7963        .map_err(Into::into)
7964}
7965
7966struct ParsedDispatchSource {
7967    source: String,
7968    tree: tree_sitter::Tree,
7969}
7970
7971type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
7972
7973fn infer_receiver_type(
7974    project_root: &Path,
7975    reference: &NameMatchRef,
7976    source_cache: &mut DispatchSourceCache,
7977) -> Option<String> {
7978    match reference.lang.as_str() {
7979        "rust" => infer_rust_receiver_type(reference),
7980        "java" => {
7981            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
7982        }
7983        "kotlin" => {
7984            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
7985        }
7986        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
7987        _ => None,
7988    }
7989}
7990
7991fn parse_dispatch_source(
7992    project_root: &Path,
7993    caller_file: &str,
7994    lang: LangId,
7995) -> Option<ParsedDispatchSource> {
7996    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
7997    let grammar = crate::parser::grammar_for(lang);
7998    let mut parser = tree_sitter::Parser::new();
7999    parser.set_language(&grammar).ok()?;
8000    let tree = parser.parse(&source, None)?;
8001    Some(ParsedDispatchSource { source, tree })
8002}
8003
8004fn parsed_dispatch_source<'a>(
8005    project_root: &Path,
8006    reference: &NameMatchRef,
8007    lang: LangId,
8008    source_cache: &'a mut DispatchSourceCache,
8009) -> Option<&'a ParsedDispatchSource> {
8010    let key = (reference.caller_file.clone(), reference.lang.clone());
8011    source_cache
8012        .entry(key)
8013        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
8014        .as_ref()
8015}
8016
8017fn infer_java_like_receiver_type(
8018    project_root: &Path,
8019    reference: &NameMatchRef,
8020    lang: LangId,
8021    source_cache: &mut DispatchSourceCache,
8022) -> Option<String> {
8023    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8024        return None;
8025    }
8026
8027    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
8028    let root = parsed.tree.root_node();
8029    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
8030
8031    let callable_scope = type_node
8032        .and_then(|node| {
8033            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
8034        })
8035        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
8036
8037    if let Some(callable_scope) = callable_scope {
8038        if let Some(receiver_type) = infer_java_like_local_receiver_type(
8039            callable_scope,
8040            &parsed.source,
8041            &reference.receiver,
8042            reference.line.max(1),
8043            lang,
8044        ) {
8045            return Some(receiver_type);
8046        }
8047    }
8048
8049    type_node.and_then(|node| {
8050        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
8051    })
8052}
8053
8054fn infer_cpp_receiver_type(
8055    project_root: &Path,
8056    reference: &NameMatchRef,
8057    source_cache: &mut DispatchSourceCache,
8058) -> Option<String> {
8059    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8060        return None;
8061    }
8062
8063    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
8064    let root = parsed.tree.root_node();
8065    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
8066    infer_cpp_receiver_type_from_scope(
8067        scope,
8068        &parsed.source,
8069        &reference.receiver,
8070        reference.line.max(1),
8071    )
8072}
8073
8074fn find_enclosing_java_like_type_node<'tree>(
8075    root: tree_sitter::Node<'tree>,
8076    source: &str,
8077    reference: &NameMatchRef,
8078    lang: LangId,
8079) -> Option<tree_sitter::Node<'tree>> {
8080    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
8081        .and_then(|name| simple_type_name(&name));
8082    let line = reference.line.max(1);
8083    let mut best = None;
8084    let mut stack = vec![root];
8085    while let Some(node) = stack.pop() {
8086        if !node_contains_line(node, line) {
8087            continue;
8088        }
8089        if is_java_like_type_kind(node.kind(), lang) {
8090            let name = declaration_name(node, source);
8091            if expected_type
8092                .as_deref()
8093                .is_none_or(|expected| name == Some(expected))
8094            {
8095                best = tighter_node(best, node);
8096            }
8097        }
8098        push_named_children(node, &mut stack);
8099    }
8100    best
8101}
8102
8103fn find_enclosing_java_like_callable_node<'tree>(
8104    root: tree_sitter::Node<'tree>,
8105    source: &str,
8106    reference: &NameMatchRef,
8107    lang: LangId,
8108) -> Option<tree_sitter::Node<'tree>> {
8109    let expected_name = reference.caller_symbol.rsplit("::").next();
8110    let line = reference.line.max(1);
8111    let mut best = None;
8112    let mut stack = vec![root];
8113    while let Some(node) = stack.pop() {
8114        if !node_contains_line(node, line) {
8115            continue;
8116        }
8117        if is_java_like_callable_kind(node.kind(), lang) {
8118            let name = declaration_name(node, source);
8119            if expected_name.is_none_or(|expected| name == Some(expected)) {
8120                best = tighter_node(best, node);
8121            }
8122        }
8123        push_named_children(node, &mut stack);
8124    }
8125    best
8126}
8127
8128fn find_enclosing_cpp_callable_node<'tree>(
8129    root: tree_sitter::Node<'tree>,
8130    _source: &str,
8131    reference: &NameMatchRef,
8132) -> Option<tree_sitter::Node<'tree>> {
8133    let line = reference.line.max(1);
8134    let mut best = None;
8135    let mut stack = vec![root];
8136    while let Some(node) = stack.pop() {
8137        if !node_contains_line(node, line) {
8138            continue;
8139        }
8140        if node.kind() == "function_definition" {
8141            best = tighter_node(best, node);
8142        }
8143        push_named_children(node, &mut stack);
8144    }
8145    best
8146}
8147
8148fn tighter_node<'tree>(
8149    current: Option<tree_sitter::Node<'tree>>,
8150    candidate: tree_sitter::Node<'tree>,
8151) -> Option<tree_sitter::Node<'tree>> {
8152    match current {
8153        Some(current)
8154            if current.start_byte() > candidate.start_byte()
8155                || (current.start_byte() == candidate.start_byte()
8156                    && current.end_byte() <= candidate.end_byte()) =>
8157        {
8158            Some(current)
8159        }
8160        _ => Some(candidate),
8161    }
8162}
8163
8164fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8165    let start = node.start_position().row as u32 + 1;
8166    let end = node.end_position().row as u32 + 1;
8167    start <= line && line <= end
8168}
8169
8170fn push_named_children<'tree>(
8171    node: tree_sitter::Node<'tree>,
8172    stack: &mut Vec<tree_sitter::Node<'tree>>,
8173) {
8174    for index in 0..node.named_child_count() {
8175        if let Some(child) = node.named_child(index as u32) {
8176            stack.push(child);
8177        }
8178    }
8179}
8180
8181fn declaration_name<'source>(
8182    node: tree_sitter::Node<'_>,
8183    source: &'source str,
8184) -> Option<&'source str> {
8185    node.child_by_field_name("name")
8186        .map(|name| node_text(name, source))
8187        .or_else(|| {
8188            first_named_child_text(
8189                node,
8190                source,
8191                &["identifier", "type_identifier", "simple_identifier"],
8192            )
8193        })
8194}
8195
8196fn first_named_child_text<'source>(
8197    node: tree_sitter::Node<'_>,
8198    source: &'source str,
8199    kinds: &[&str],
8200) -> Option<&'source str> {
8201    for index in 0..node.named_child_count() {
8202        let child = node.named_child(index as u32)?;
8203        if kinds.contains(&child.kind()) {
8204            return Some(node_text(child, source));
8205        }
8206    }
8207    None
8208}
8209
8210fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8211    &source[node.byte_range()]
8212}
8213
8214fn infer_java_like_field_receiver_type(
8215    type_node: tree_sitter::Node<'_>,
8216    source: &str,
8217    receiver: &str,
8218    lang: LangId,
8219) -> Option<String> {
8220    let mut stack = Vec::new();
8221    push_named_children(type_node, &mut stack);
8222    while let Some(node) = stack.pop() {
8223        if is_java_like_field_kind(node.kind(), lang) {
8224            if let Some(receiver_type) =
8225                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8226            {
8227                return Some(receiver_type);
8228            }
8229        }
8230        if is_java_like_type_kind(node.kind(), lang)
8231            || is_java_like_callable_kind(node.kind(), lang)
8232        {
8233            continue;
8234        }
8235        push_named_children(node, &mut stack);
8236    }
8237    None
8238}
8239
8240fn infer_java_like_local_receiver_type(
8241    callable_node: tree_sitter::Node<'_>,
8242    source: &str,
8243    receiver: &str,
8244    call_line: u32,
8245    lang: LangId,
8246) -> Option<String> {
8247    let mut best: Option<(u32, String)> = None;
8248    let mut stack = Vec::new();
8249    push_named_children(callable_node, &mut stack);
8250    while let Some(node) = stack.pop() {
8251        let start_line = node.start_position().row as u32 + 1;
8252        if start_line > call_line {
8253            continue;
8254        }
8255        if is_java_like_local_kind(node.kind(), lang) {
8256            if let Some(receiver_type) =
8257                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8258            {
8259                if best
8260                    .as_ref()
8261                    .is_none_or(|(best_line, _)| start_line >= *best_line)
8262                {
8263                    best = Some((start_line, receiver_type));
8264                }
8265            }
8266        }
8267        if is_java_like_type_kind(node.kind(), lang)
8268            || is_java_like_callable_kind(node.kind(), lang)
8269        {
8270            continue;
8271        }
8272        push_named_children(node, &mut stack);
8273    }
8274    best.map(|(_, receiver_type)| receiver_type)
8275}
8276
8277fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8278    match lang {
8279        LangId::Java => matches!(
8280            kind,
8281            "class_declaration"
8282                | "interface_declaration"
8283                | "enum_declaration"
8284                | "record_declaration"
8285                | "annotation_type_declaration"
8286        ),
8287        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8288        _ => false,
8289    }
8290}
8291
8292fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8293    match lang {
8294        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8295        LangId::Kotlin => kind == "function_declaration",
8296        _ => false,
8297    }
8298}
8299
8300fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8301    match lang {
8302        LangId::Java => kind == "field_declaration",
8303        LangId::Kotlin => kind == "property_declaration",
8304        _ => false,
8305    }
8306}
8307
8308fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8309    match lang {
8310        LangId::Java => kind == "local_variable_declaration",
8311        LangId::Kotlin => kind == "property_declaration",
8312        _ => false,
8313    }
8314}
8315
8316fn extract_java_like_declared_type(
8317    declaration: &str,
8318    receiver: &str,
8319    lang: LangId,
8320) -> Option<String> {
8321    match lang {
8322        LangId::Java => extract_java_declared_type(declaration, receiver),
8323        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8324        _ => None,
8325    }
8326}
8327
8328fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8329    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8330    let after = declaration[receiver_start + receiver.len()..].trim_start();
8331    if after
8332        .chars()
8333        .next()
8334        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8335    {
8336        return None;
8337    }
8338
8339    let before = declaration[..receiver_start].trim_end();
8340    if before.contains(',') {
8341        return None;
8342    }
8343    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8344}
8345
8346fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8347    loop {
8348        value = value.trim_start();
8349        if let Some(stripped) = strip_leading_java_annotation(value) {
8350            value = stripped;
8351            continue;
8352        }
8353        if let Some(stripped) = strip_leading_java_modifier(value) {
8354            value = stripped;
8355            continue;
8356        }
8357        return value.trim();
8358    }
8359}
8360
8361fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8362    let value = value.trim_start();
8363    let mut chars = value.char_indices();
8364    let (_, first) = chars.next()?;
8365    if first != '@' {
8366        return None;
8367    }
8368    let mut end = first.len_utf8();
8369    for (index, ch) in chars {
8370        if !(is_code_ident_char(ch) || ch == '.') {
8371            end = index;
8372            break;
8373        }
8374        end = index + ch.len_utf8();
8375    }
8376    let rest = value[end..].trim_start();
8377    if let Some(stripped) = rest.strip_prefix('(') {
8378        let mut depth = 1usize;
8379        for (index, ch) in stripped.char_indices() {
8380            match ch {
8381                '(' => depth += 1,
8382                ')' => {
8383                    depth = depth.saturating_sub(1);
8384                    if depth == 0 {
8385                        return Some(stripped[index + ch.len_utf8()..].trim_start());
8386                    }
8387                }
8388                _ => {}
8389            }
8390        }
8391        return Some("");
8392    }
8393    Some(rest)
8394}
8395
8396fn strip_leading_java_modifier(value: &str) -> Option<&str> {
8397    const MODIFIERS: &[&str] = &[
8398        "public",
8399        "protected",
8400        "private",
8401        "abstract",
8402        "static",
8403        "final",
8404        "transient",
8405        "volatile",
8406        "synchronized",
8407        "native",
8408        "strictfp",
8409    ];
8410    MODIFIERS
8411        .iter()
8412        .find_map(|modifier| strip_leading_word(value, modifier))
8413}
8414
8415fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8416    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8417    let before = &declaration[..receiver_start];
8418    if find_identifier_occurrence(before, "val").is_none()
8419        && find_identifier_occurrence(before, "var").is_none()
8420    {
8421        return None;
8422    }
8423
8424    let after = declaration[receiver_start + receiver.len()..].trim_start();
8425    if let Some(type_text) = after.strip_prefix(':') {
8426        return normalize_receiver_type_name(read_type_prefix(type_text));
8427    }
8428    after
8429        .strip_prefix('=')
8430        .and_then(infer_kotlin_constructor_type)
8431}
8432
8433fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
8434    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
8435    if rest.trim_start().starts_with('(') {
8436        normalize_receiver_type_name(head)
8437    } else {
8438        None
8439    }
8440}
8441
8442fn read_type_prefix(value: &str) -> &str {
8443    let mut angle_depth = 0usize;
8444    for (index, ch) in value.char_indices() {
8445        match ch {
8446            '<' => angle_depth += 1,
8447            '>' => angle_depth = angle_depth.saturating_sub(1),
8448            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
8449                return value[..index].trim();
8450            }
8451            _ => {}
8452        }
8453    }
8454    value.trim()
8455}
8456
8457fn infer_cpp_receiver_type_from_scope(
8458    scope: tree_sitter::Node<'_>,
8459    source: &str,
8460    receiver: &str,
8461    call_line: u32,
8462) -> Option<String> {
8463    let lines = source.lines().collect::<Vec<_>>();
8464    if lines.is_empty() {
8465        return None;
8466    }
8467    let scope_start = scope.start_position().row as usize;
8468    let call_index = (call_line as usize)
8469        .saturating_sub(1)
8470        .min(lines.len().saturating_sub(1));
8471    for index in (scope_start..=call_index).rev() {
8472        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
8473            return Some(receiver_type);
8474        }
8475    }
8476    None
8477}
8478
8479fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
8480    for receiver_start in identifier_occurrences(line, receiver) {
8481        let after = line[receiver_start + receiver.len()..].trim_start();
8482        if after
8483            .chars()
8484            .next()
8485            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
8486        {
8487            continue;
8488        }
8489        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
8490        let normalized = normalize_cpp_type_name(type_text)?;
8491        if normalized == "auto" {
8492            if let Some(rhs) = after.strip_prefix('=') {
8493                return infer_cpp_auto_receiver_type(rhs);
8494            }
8495            continue;
8496        }
8497        return Some(normalized);
8498    }
8499    None
8500}
8501
8502fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
8503    let candidate = prefix
8504        .rsplit([';', '{', '}', '('])
8505        .next()
8506        .unwrap_or(prefix)
8507        .trim();
8508    if candidate.is_empty() || candidate.ends_with(',') {
8509        None
8510    } else {
8511        Some(candidate)
8512    }
8513}
8514
8515fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
8516    let without_templates = strip_angle_groups(type_text);
8517    let mut cleaned = String::with_capacity(without_templates.len());
8518    for token in without_templates.split_whitespace() {
8519        if matches!(
8520            token,
8521            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
8522        ) {
8523            continue;
8524        }
8525        if !cleaned.is_empty() {
8526            cleaned.push(' ');
8527        }
8528        cleaned.push_str(token);
8529    }
8530    let token = cleaned
8531        .split_whitespace()
8532        .last()
8533        .unwrap_or(cleaned.trim())
8534        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
8535        .trim_matches(['*', '&']);
8536    let simple = token.rsplit("::").next().unwrap_or(token).trim();
8537    if simple.is_empty() || cpp_non_type_token(simple) {
8538        None
8539    } else {
8540        Some(simple.to_string())
8541    }
8542}
8543
8544fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
8545    let rhs = rhs.trim_start();
8546    if let Some(after_new) = rhs.strip_prefix("new ") {
8547        return infer_cpp_constructor_type(after_new);
8548    }
8549    infer_cpp_make_template_type(rhs)
8550        .or_else(|| infer_cpp_constructor_type(rhs))
8551        .or_else(|| infer_cpp_factory_type(rhs))
8552}
8553
8554fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
8555    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8556    let normalized = normalize_cpp_type_name(head)?;
8557    if !normalized
8558        .chars()
8559        .next()
8560        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8561    {
8562        return None;
8563    }
8564    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
8565        Some(normalized)
8566    } else {
8567        None
8568    }
8569}
8570
8571fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
8572    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8573    if !rest.trim_start().starts_with('(') {
8574        return None;
8575    }
8576    let base = head.split('<').next().unwrap_or(head);
8577    let base_simple = base.rsplit("::").next().unwrap_or(base);
8578    if !matches!(base_simple, "make_unique" | "make_shared") {
8579        return None;
8580    }
8581    first_angle_arg(head).and_then(normalize_cpp_type_name)
8582}
8583
8584fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
8585    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
8586    if !rest.trim_start().starts_with('(') {
8587        return None;
8588    }
8589    let simple = head
8590        .split('<')
8591        .next()
8592        .unwrap_or(head)
8593        .rsplit("::")
8594        .next()
8595        .unwrap_or(head);
8596    for prefix in ["make", "create", "build"] {
8597        if let Some(suffix) = simple.strip_prefix(prefix) {
8598            if suffix
8599                .chars()
8600                .next()
8601                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8602            {
8603                return normalize_cpp_type_name(suffix);
8604            }
8605        }
8606    }
8607    None
8608}
8609
8610#[derive(Debug, Clone, Copy)]
8611enum JavaLikeInvocation {
8612    Kotlin,
8613    Cpp,
8614}
8615
8616fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
8617    let value = value.trim_start();
8618    let mut end = 0usize;
8619    for (index, ch) in value.char_indices() {
8620        let allowed_separator = match flavor {
8621            JavaLikeInvocation::Kotlin => ch == '.',
8622            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
8623        };
8624        if is_code_ident_char(ch) || allowed_separator {
8625            end = index + ch.len_utf8();
8626            continue;
8627        }
8628        break;
8629    }
8630    if end == 0 {
8631        return None;
8632    }
8633    let mut rest = &value[end..];
8634    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
8635        let skipped = skip_balanced_angle(stripped)?;
8636        let rest_start = rest.len() - rest.trim_start().len();
8637        let angle_len = 1 + skipped;
8638        end += rest_start + angle_len;
8639        rest = &value[end..];
8640    }
8641    Some((value[..end].trim(), rest))
8642}
8643
8644fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
8645    let mut depth = 1usize;
8646    for (index, ch) in value_after_open.char_indices() {
8647        match ch {
8648            '<' => depth += 1,
8649            '>' => {
8650                depth = depth.saturating_sub(1);
8651                if depth == 0 {
8652                    return Some(index + ch.len_utf8());
8653                }
8654            }
8655            _ => {}
8656        }
8657    }
8658    None
8659}
8660
8661fn first_angle_arg(value: &str) -> Option<&str> {
8662    let open = value.find('<')?;
8663    let inner_len = skip_balanced_angle(&value[open + 1..])?;
8664    let inner = &value[open + 1..open + inner_len];
8665    split_top_level_commas(inner).into_iter().next()
8666}
8667
8668fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
8669    let without_generics = strip_angle_groups(type_text);
8670    let cleaned = without_generics
8671        .replace("[]", " ")
8672        .replace("...", " ")
8673        .replace(['?', '&', '*'], " ");
8674    let token = cleaned
8675        .split_whitespace()
8676        .last()
8677        .unwrap_or(cleaned.trim())
8678        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
8679    let token = token.rsplit("::").next().unwrap_or(token);
8680    let simple = token.rsplit('.').next().unwrap_or(token).trim();
8681    if simple.is_empty()
8682        || java_like_primitive_type(simple)
8683        || !simple
8684            .chars()
8685            .next()
8686            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
8687    {
8688        None
8689    } else {
8690        Some(simple.to_string())
8691    }
8692}
8693
8694fn simple_type_name(scoped_name: &str) -> Option<String> {
8695    scoped_name
8696        .rsplit("::")
8697        .find(|segment| !segment.is_empty())
8698        .and_then(normalize_receiver_type_name)
8699}
8700
8701fn strip_angle_groups(value: &str) -> String {
8702    let mut output = String::with_capacity(value.len());
8703    let mut depth = 0usize;
8704    for ch in value.chars() {
8705        match ch {
8706            '<' => {
8707                if depth == 0 {
8708                    output.push(' ');
8709                }
8710                depth += 1;
8711            }
8712            '>' => depth = depth.saturating_sub(1),
8713            _ if depth == 0 => output.push(ch),
8714            _ => {}
8715        }
8716    }
8717    output
8718}
8719
8720fn java_like_primitive_type(value: &str) -> bool {
8721    matches!(
8722        value,
8723        "boolean"
8724            | "byte"
8725            | "char"
8726            | "double"
8727            | "float"
8728            | "int"
8729            | "long"
8730            | "short"
8731            | "void"
8732            | "Boolean"
8733            | "Byte"
8734            | "Char"
8735            | "Double"
8736            | "Float"
8737            | "Int"
8738            | "Long"
8739            | "Short"
8740            | "Unit"
8741    )
8742}
8743
8744fn cpp_non_type_token(value: &str) -> bool {
8745    matches!(
8746        value,
8747        "return"
8748            | "if"
8749            | "else"
8750            | "for"
8751            | "while"
8752            | "do"
8753            | "switch"
8754            | "case"
8755            | "default"
8756            | "break"
8757            | "continue"
8758            | "goto"
8759            | "throw"
8760            | "new"
8761            | "delete"
8762            | "co_await"
8763            | "co_yield"
8764            | "co_return"
8765            | "static_cast"
8766            | "const_cast"
8767            | "dynamic_cast"
8768            | "reinterpret_cast"
8769            | "sizeof"
8770            | "alignof"
8771            | "typeid"
8772            | "and"
8773            | "or"
8774            | "not"
8775            | "xor"
8776    )
8777}
8778
8779fn receiver_is_bare_identifier(value: &str) -> bool {
8780    let mut chars = value.chars();
8781    let Some(first) = chars.next() else {
8782        return false;
8783    };
8784    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
8785}
8786
8787fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
8788    identifier_occurrences(value, needle).into_iter().next()
8789}
8790
8791fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
8792    value
8793        .match_indices(needle)
8794        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
8795        .collect()
8796}
8797
8798fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
8799    let before = value[..start].chars().next_back();
8800    let after = value[start + len..].chars().next();
8801    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
8802}
8803
8804fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
8805    let stripped = value.strip_prefix(word)?;
8806    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
8807        Some(stripped.trim_start())
8808    } else {
8809        None
8810    }
8811}
8812
8813fn is_code_ident_char(ch: char) -> bool {
8814    ch == '_' || ch.is_ascii_alphanumeric()
8815}
8816
8817fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
8818    if matches!(reference.receiver.as_str(), "self" | "Self") {
8819        return enclosing_type_from_scoped_name(&reference.caller_symbol);
8820    }
8821
8822    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
8823        return Some(reference.receiver.clone());
8824    }
8825
8826    reference
8827        .caller_signature
8828        .as_deref()
8829        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
8830}
8831
8832fn rust_receiver_looks_type_like(receiver: &str) -> bool {
8833    receiver
8834        .chars()
8835        .next()
8836        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
8837}
8838
8839fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
8840    scoped_name
8841        .rsplit_once("::")
8842        .map(|(enclosing, _)| enclosing)
8843        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
8844        .map(ToString::to_string)
8845}
8846
8847fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
8848    let params = signature_parameter_text(signature)?;
8849    for param in split_top_level_commas(params) {
8850        let Some((pattern, type_text)) = param.split_once(':') else {
8851            continue;
8852        };
8853        let Some(name) = rust_parameter_name(pattern) else {
8854            continue;
8855        };
8856        if name == receiver {
8857            return normalize_rust_receiver_type(type_text);
8858        }
8859    }
8860    None
8861}
8862
8863fn signature_parameter_text(signature: &str) -> Option<&str> {
8864    let open = signature.find('(')?;
8865    let mut depth = 0usize;
8866    for (offset, ch) in signature[open..].char_indices() {
8867        match ch {
8868            '(' => depth += 1,
8869            ')' => {
8870                depth = depth.saturating_sub(1);
8871                if depth == 0 {
8872                    return Some(&signature[open + 1..open + offset]);
8873                }
8874            }
8875            _ => {}
8876        }
8877    }
8878    None
8879}
8880
8881fn split_top_level_commas(value: &str) -> Vec<&str> {
8882    let mut parts = Vec::new();
8883    let mut start = 0usize;
8884    let mut angle_depth = 0usize;
8885    let mut paren_depth = 0usize;
8886    let mut bracket_depth = 0usize;
8887    for (index, ch) in value.char_indices() {
8888        match ch {
8889            '<' => angle_depth += 1,
8890            '>' => angle_depth = angle_depth.saturating_sub(1),
8891            '(' => paren_depth += 1,
8892            ')' => paren_depth = paren_depth.saturating_sub(1),
8893            '[' => bracket_depth += 1,
8894            ']' => bracket_depth = bracket_depth.saturating_sub(1),
8895            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
8896                let part = value[start..index].trim();
8897                if !part.is_empty() {
8898                    parts.push(part);
8899                }
8900                start = index + ch.len_utf8();
8901            }
8902            _ => {}
8903        }
8904    }
8905    let part = value[start..].trim();
8906    if !part.is_empty() {
8907        parts.push(part);
8908    }
8909    parts
8910}
8911
8912fn rust_parameter_name(pattern: &str) -> Option<&str> {
8913    let mut pattern = pattern.trim();
8914    if let Some(stripped) = pattern.strip_prefix("mut ") {
8915        pattern = stripped.trim_start();
8916    }
8917    pattern
8918        .rsplit(|ch: char| !is_rust_ident_char(ch))
8919        .find(|part| !part.is_empty())
8920}
8921
8922fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
8923    let mut ty = strip_leading_rust_type_modifiers(type_text);
8924    let owned_inner;
8925    if let Some(inner) = single_outer_generic_arg(ty) {
8926        owned_inner = inner.trim().to_string();
8927        ty = strip_leading_rust_type_modifiers(&owned_inner);
8928    }
8929    rust_base_type_ident(ty)
8930}
8931
8932fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
8933    loop {
8934        ty = ty.trim_start();
8935        if let Some(stripped) = ty.strip_prefix('&') {
8936            ty = stripped.trim_start();
8937            if let Some(stripped) = strip_leading_lifetime(ty) {
8938                ty = stripped.trim_start();
8939            }
8940            if let Some(stripped) = ty.strip_prefix("mut ") {
8941                ty = stripped.trim_start();
8942            }
8943            continue;
8944        }
8945        if let Some(stripped) = ty.strip_prefix("mut ") {
8946            ty = stripped.trim_start();
8947            continue;
8948        }
8949        if let Some(stripped) = ty.strip_prefix("dyn ") {
8950            ty = stripped.trim_start();
8951            continue;
8952        }
8953        if let Some(stripped) = ty.strip_prefix("impl ") {
8954            ty = stripped.trim_start();
8955            continue;
8956        }
8957        break ty.trim();
8958    }
8959}
8960
8961fn strip_leading_lifetime(value: &str) -> Option<&str> {
8962    let mut chars = value.char_indices();
8963    let (_, first) = chars.next()?;
8964    if first != '\'' {
8965        return None;
8966    }
8967    for (index, ch) in chars {
8968        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
8969            return Some(&value[index..]);
8970        }
8971    }
8972    Some("")
8973}
8974
8975fn single_outer_generic_arg(ty: &str) -> Option<&str> {
8976    let ty = ty.trim();
8977    let open = ty.find('<')?;
8978    let mut depth = 0usize;
8979    let mut close = None;
8980    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
8981        match ch {
8982            '<' => depth += 1,
8983            '>' => {
8984                depth = depth.saturating_sub(1);
8985                if depth == 0 {
8986                    close = Some(index);
8987                    break;
8988                }
8989            }
8990            _ => {}
8991        }
8992    }
8993    let close = close?;
8994    if !ty[close + 1..].trim().is_empty() {
8995        return None;
8996    }
8997    let inner = &ty[open + 1..close];
8998    let args = split_top_level_commas(inner);
8999    match args.as_slice() {
9000        [arg] => Some(*arg),
9001        _ => None,
9002    }
9003}
9004
9005fn rust_base_type_ident(ty: &str) -> Option<String> {
9006    let ty = ty.trim();
9007    let head = ty
9008        .split([' ', '+', '='])
9009        .find(|part| !part.is_empty())
9010        .unwrap_or(ty);
9011    let head = head.split('<').next().unwrap_or(head).trim();
9012    let ident = head
9013        .rsplit("::")
9014        .next()
9015        .unwrap_or(head)
9016        .trim_matches(|ch: char| !is_rust_ident_char(ch));
9017    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
9018        None
9019    } else {
9020        Some(ident.to_string())
9021    }
9022}
9023
9024fn is_rust_ident_char(ch: char) -> bool {
9025    ch == '_' || ch.is_ascii_alphanumeric()
9026}
9027
9028fn select_type_match_candidate(
9029    reference: &NameMatchRef,
9030    candidates: &[NameMatchCandidate],
9031    receiver_type: &str,
9032) -> Option<NameMatchCandidate> {
9033    let candidates = candidates
9034        .iter()
9035        .filter(|candidate| candidate.node_id != reference.caller_node)
9036        .filter(|candidate| {
9037            type_candidate_matches(candidate, receiver_type, &reference.method_name)
9038        })
9039        .collect::<Vec<_>>();
9040    match candidates.as_slice() {
9041        [candidate] => Some((**candidate).clone()),
9042        _ => None,
9043    }
9044}
9045
9046fn type_candidate_matches(
9047    candidate: &NameMatchCandidate,
9048    receiver_type: &str,
9049    method_name: &str,
9050) -> bool {
9051    let normalized_type = receiver_type.replace('.', "::");
9052    let suffix = format!("{normalized_type}::{method_name}");
9053    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
9054}
9055
9056fn select_name_match_candidate(
9057    reference: &NameMatchRef,
9058    candidates: &[NameMatchCandidate],
9059) -> Option<NameMatchCandidate> {
9060    let candidates = candidates
9061        .iter()
9062        .filter(|candidate| candidate.node_id != reference.caller_node)
9063        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
9064        .collect::<Vec<_>>();
9065    match candidates.as_slice() {
9066        [] => None,
9067        [candidate] => Some((**candidate).clone()),
9068        _ => select_scored_name_match_candidate(reference, &candidates),
9069    }
9070}
9071
9072fn candidate_allowed_for_reference(
9073    reference: &NameMatchRef,
9074    candidate: &NameMatchCandidate,
9075) -> bool {
9076    if !reference.colon_dispatch {
9077        return true;
9078    }
9079
9080    candidate.kind == "method"
9081        && candidate
9082            .scoped_name
9083            .split("::")
9084            .any(|segment| segment == reference.receiver)
9085}
9086
9087fn select_scored_name_match_candidate(
9088    reference: &NameMatchRef,
9089    candidates: &[&NameMatchCandidate],
9090) -> Option<NameMatchCandidate> {
9091    let receiver_words = split_camel_case(&reference.receiver);
9092    if receiver_words.is_empty() {
9093        return None;
9094    }
9095
9096    let mut best: Option<(&NameMatchCandidate, f64)> = None;
9097    let mut tied_best = false;
9098    for candidate in candidates {
9099        let candidate_words = split_camel_case(&candidate.scoped_name);
9100        let overlap = receiver_words
9101            .iter()
9102            .filter(|receiver_word| {
9103                candidate_words
9104                    .iter()
9105                    .any(|candidate_word| candidate_word == *receiver_word)
9106            })
9107            .count() as f64;
9108        let score =
9109            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
9110        match best {
9111            None => {
9112                best = Some((*candidate, score));
9113                tied_best = false;
9114            }
9115            Some((_, best_score)) if score > best_score => {
9116                best = Some((*candidate, score));
9117                tied_best = false;
9118            }
9119            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
9120                tied_best = true;
9121            }
9122            _ => {}
9123        }
9124    }
9125
9126    let (candidate, score) = best?;
9127    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
9128        Some(candidate.clone())
9129    } else {
9130        None
9131    }
9132}
9133
9134fn method_name_match_denylisted(method_name: &str) -> bool {
9135    matches!(
9136        method_name,
9137        "and_then"
9138            | "as_bytes"
9139            | "as_deref"
9140            | "as_mut"
9141            | "as_ref"
9142            | "as_str"
9143            | "borrow"
9144            | "borrow_mut"
9145            | "clear"
9146            | "clone"
9147            | "collect"
9148            | "contains"
9149            | "contains_key"
9150            | "count"
9151            | "dedup"
9152            | "default"
9153            | "drain"
9154            | "ends_with"
9155            | "entry"
9156            | "err"
9157            | "expect"
9158            | "extend"
9159            | "filter"
9160            | "filter_map"
9161            | "find"
9162            | "from"
9163            | "get"
9164            | "get_mut"
9165            | "insert"
9166            | "into"
9167            | "into_iter"
9168            | "is_empty"
9169            | "is_err"
9170            | "is_none"
9171            | "is_ok"
9172            | "is_some"
9173            | "iter"
9174            | "iter_mut"
9175            | "join"
9176            | "len"
9177            | "lock"
9178            | "map"
9179            | "map_err"
9180            | "max"
9181            | "min"
9182            | "new"
9183            | "next"
9184            | "ok"
9185            | "or_default"
9186            | "or_else"
9187            | "or_insert"
9188            | "or_insert_with"
9189            | "parse"
9190            | "pop"
9191            | "position"
9192            | "push"
9193            | "read"
9194            | "recv"
9195            | "remove"
9196            | "replace"
9197            | "retain"
9198            | "send"
9199            | "sort"
9200            | "sort_by"
9201            | "split"
9202            | "starts_with"
9203            | "sum"
9204            | "take"
9205            | "to_owned"
9206            | "to_string"
9207            | "trim"
9208            | "try_from"
9209            | "try_into"
9210            | "unwrap"
9211            | "unwrap_or"
9212            | "unwrap_or_default"
9213            | "unwrap_or_else"
9214            | "with_capacity"
9215            | "write"
9216    )
9217}
9218
9219fn split_camel_case(value: &str) -> Vec<String> {
9220    let chars = value.chars().collect::<Vec<_>>();
9221    let mut normalized = String::with_capacity(value.len() + 8);
9222    for (index, ch) in chars.iter().enumerate() {
9223        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9224        let next = chars.get(index + 1);
9225        let is_separator = ch.is_whitespace()
9226            || matches!(
9227                ch,
9228                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9229            );
9230        if is_separator {
9231            normalized.push(' ');
9232            continue;
9233        }
9234        let camel_boundary = previous.is_some_and(|prev| {
9235            (prev.is_lowercase() && ch.is_uppercase())
9236                || (prev.is_ascii_digit() && ch.is_alphabetic())
9237                || (prev.is_uppercase()
9238                    && ch.is_uppercase()
9239                    && next.is_some_and(|next| next.is_lowercase()))
9240        });
9241        if camel_boundary {
9242            normalized.push(' ');
9243        }
9244        normalized.push(*ch);
9245    }
9246
9247    normalized
9248        .split_whitespace()
9249        .filter(|word| word.len() > 1)
9250        .map(|word| word.to_ascii_lowercase())
9251        .collect()
9252}
9253
9254fn compute_path_proximity(left: &str, right: &str) -> f64 {
9255    let left_dirs = left
9256        .rsplit_once('/')
9257        .map(|(dir, _)| dir)
9258        .unwrap_or_default()
9259        .split('/')
9260        .filter(|part| !part.is_empty());
9261    let right_dirs = right
9262        .rsplit_once('/')
9263        .map(|(dir, _)| dir)
9264        .unwrap_or_default()
9265        .split('/')
9266        .filter(|part| !part.is_empty());
9267
9268    let shared = left_dirs
9269        .zip(right_dirs)
9270        .take_while(|(left, right)| left == right)
9271        .count();
9272    ((shared as f64) * 0.05).min(0.5)
9273}
9274
9275fn mark_backend_state(
9276    tx: &Transaction<'_>,
9277    project_root: &Path,
9278    rel_path: &str,
9279    content_hash: Option<&blake3::Hash>,
9280    status: &str,
9281) -> Result<()> {
9282    clear_backend_state_for_file(tx, project_root, rel_path)?;
9283    let hash = content_hash
9284        .map(|hash| hash_to_hex(*hash))
9285        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9286    tx.execute(
9287        "INSERT OR REPLACE INTO backend_file_state(
9288            backend, workspace_root, file_path, content_hash, status, updated_at
9289        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9290        params![
9291            BACKEND_TREESITTER,
9292            project_root.display().to_string(),
9293            rel_path,
9294            hash,
9295            status,
9296            unix_seconds_now(),
9297        ],
9298    )?;
9299    Ok(())
9300}
9301
9302fn clear_backend_state_for_file(
9303    tx: &Transaction<'_>,
9304    project_root: &Path,
9305    rel_path: &str,
9306) -> Result<()> {
9307    tx.execute(
9308        "DELETE FROM backend_file_state
9309         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9310        params![
9311            BACKEND_TREESITTER,
9312            project_root.display().to_string(),
9313            rel_path
9314        ],
9315    )?;
9316    Ok(())
9317}
9318
9319fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9320    tx.query_row(
9321        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9322        params![rel_path],
9323        |row| {
9324            let hash_text: String = row.get(1)?;
9325            Ok(FileRow {
9326                surface_fingerprint: row.get(0)?,
9327                freshness: FileFreshness {
9328                    content_hash: hash_from_hex(&hash_text)
9329                        .unwrap_or_else(cache_freshness::zero_hash),
9330                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9331                    size: row.get::<_, i64>(3)? as u64,
9332                },
9333            })
9334        },
9335    )
9336    .optional()
9337    .map_err(CallGraphStoreError::from)
9338}
9339
9340fn stored_node_ids_match_extract(
9341    tx: &Transaction<'_>,
9342    rel_path: &str,
9343    extract: &FileExtract,
9344) -> Result<bool> {
9345    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9346    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9347    let mut stored = BTreeSet::new();
9348    for row in rows {
9349        stored.insert(row?);
9350    }
9351    let extracted = extract
9352        .nodes
9353        .iter()
9354        .map(|node| node.id.clone())
9355        .collect::<BTreeSet<_>>();
9356    Ok(stored == extracted)
9357}
9358
9359fn update_file_fresh_metadata(
9360    tx: &Transaction<'_>,
9361    rel_path: &str,
9362    hash: &blake3::Hash,
9363    mtime: SystemTime,
9364    size: u64,
9365) -> Result<()> {
9366    tx.execute(
9367        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9368        params![
9369            rel_path,
9370            system_time_to_ns(mtime),
9371            size as i64,
9372            unix_seconds_now()
9373        ],
9374    )?;
9375    tx.execute(
9376        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9377         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9378        params![
9379            BACKEND_TREESITTER,
9380            rel_path,
9381            hash_to_hex(*hash),
9382            unix_seconds_now(),
9383        ],
9384    )?;
9385    Ok(())
9386}
9387
9388#[derive(Debug, Clone, PartialEq, Eq)]
9389struct DependentRefSelection {
9390    ref_id: String,
9391    caller_file: String,
9392}
9393
9394fn ref_ids_depending_on(
9395    tx: &Transaction<'_>,
9396    project_root: &Path,
9397    rel_path: &str,
9398) -> Result<Vec<DependentRefSelection>> {
9399    let mut stmt = tx.prepare(
9400        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
9401         FROM refs r
9402         WHERE r.caller_file IN (
9403             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
9404         )
9405            OR r.target_file = ?1
9406         ORDER BY r.ref_id",
9407    )?;
9408    let rows = stmt.query_map(params![rel_path], |row| {
9409        Ok(RefDependencyRow {
9410            ref_id: row.get(0)?,
9411            kind: row.get(1)?,
9412            caller_file: row.get(2)?,
9413            module_path: row.get(3)?,
9414            target_file: row.get(4)?,
9415        })
9416    })?;
9417    let mut ids = Vec::new();
9418    for row in rows {
9419        let row = row?;
9420        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
9421            ids.push(DependentRefSelection {
9422                ref_id: row.ref_id,
9423                caller_file: row.caller_file,
9424            });
9425        }
9426    }
9427    Ok(ids)
9428}
9429
9430fn record_dependent_refs(
9431    selected_ref_ids: &mut BTreeSet<String>,
9432    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
9433    dependent_refs: Vec<DependentRefSelection>,
9434) {
9435    for dependent_ref in dependent_refs {
9436        let DependentRefSelection {
9437            ref_id,
9438            caller_file,
9439        } = dependent_ref;
9440        selected_ref_ids.insert(ref_id.clone());
9441        selected_refs_by_caller
9442            .entry(caller_file)
9443            .or_default()
9444            .insert(ref_id);
9445    }
9446}
9447
9448#[cfg(test)]
9449fn refs_by_caller_for_ref_ids(
9450    tx: &Transaction<'_>,
9451    ref_ids: &BTreeSet<String>,
9452) -> Result<BTreeMap<String, BTreeSet<String>>> {
9453    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9454    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
9455    for ref_id in ref_ids {
9456        if let Some(caller) = stmt
9457            .query_row(params![ref_id], |row| row.get::<_, String>(0))
9458            .optional()?
9459        {
9460            by_caller.entry(caller).or_default().insert(ref_id.clone());
9461        }
9462    }
9463    Ok(by_caller)
9464}
9465
9466fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9467    tx.execute(
9468        "DELETE FROM file_dependencies WHERE file_path = ?1",
9469        params![rel_path],
9470    )?;
9471    delete_refs_for_caller(tx, rel_path)?;
9472    tx.execute(
9473        "DELETE FROM dispatch_hints WHERE file = ?1",
9474        params![rel_path],
9475    )?;
9476    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
9477    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
9478    Ok(())
9479}
9480
9481fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9482    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
9483    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9484    let mut ids = BTreeSet::new();
9485    for row in rows {
9486        ids.insert(row?);
9487    }
9488    delete_ref_ids(tx, &ids)
9489}
9490
9491fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
9492    for ref_id in ref_ids {
9493        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
9494        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
9495    }
9496    Ok(())
9497}
9498
9499fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
9500    let mut stmt = conn.prepare(
9501        "SELECT source.file_path, source.scoped_name, edges.target_file,
9502                edges.target_symbol, edges.kind, edges.line
9503         FROM edges
9504         JOIN nodes AS source ON source.id = edges.source_node
9505         ORDER BY source.file_path, source.scoped_name, edges.target_file,
9506                  edges.target_symbol, edges.kind, edges.line",
9507    )?;
9508    let rows = stmt.query_map([], |row| {
9509        Ok(StoredEdge {
9510            source_file: row.get(0)?,
9511            source_symbol: row.get(1)?,
9512            target_file: row.get(2)?,
9513            target_symbol: row.get(3)?,
9514            kind: row.get(4)?,
9515            line: row.get::<_, i64>(5)? as u32,
9516        })
9517    })?;
9518    let mut edges = BTreeSet::new();
9519    for row in rows {
9520        edges.insert(row?);
9521    }
9522    Ok(edges)
9523}
9524
9525fn module_target_from_dependencies(
9526    project_root: &Path,
9527    dependencies: &BTreeSet<String>,
9528) -> Option<String> {
9529    dependencies.iter().find_map(|dep| {
9530        let path = project_root.join(dep);
9531        if path.is_file() {
9532            Some(relative_path(project_root, &canonicalize_path(&path)))
9533        } else {
9534            None
9535        }
9536    })
9537}
9538
9539fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
9540    let mut named = HashMap::new();
9541    if let Some(full_ref) = &raw_ref.full_ref {
9542        named = parse_reexport_names(full_ref);
9543    }
9544    ReexportIndex {
9545        target_file,
9546        named,
9547        wildcard: raw_ref.wildcard,
9548    }
9549}
9550
9551fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
9552    let mut names = HashMap::new();
9553    let Some(open) = statement.find('{') else {
9554        return names;
9555    };
9556    let Some(close) = statement[open + 1..]
9557        .find('}')
9558        .map(|offset| open + 1 + offset)
9559    else {
9560        return names;
9561    };
9562    for spec in statement[open + 1..close].split(',') {
9563        let spec = spec.trim();
9564        if spec.is_empty() {
9565            continue;
9566        }
9567        if let Some((source, local)) = spec.split_once(" as ") {
9568            names.insert(local.trim().to_string(), source.trim().to_string());
9569        } else {
9570            names.insert(spec.to_string(), spec.to_string());
9571        }
9572    }
9573    names
9574}
9575
9576#[derive(Debug)]
9577struct RefDependencyRow {
9578    ref_id: String,
9579    kind: String,
9580    caller_file: String,
9581    module_path: Option<String>,
9582    target_file: Option<String>,
9583}
9584
9585fn ref_dependency_row_depends_on(
9586    project_root: &Path,
9587    row: &RefDependencyRow,
9588    rel_path: &str,
9589) -> bool {
9590    if row.target_file.as_deref() == Some(rel_path) {
9591        return true;
9592    }
9593
9594    match row.kind.as_str() {
9595        "call" => true,
9596        "import" | "reexport" => row
9597            .module_path
9598            .as_deref()
9599            .map(|module_path| {
9600                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
9601                    .contains(rel_path)
9602            })
9603            .unwrap_or(false),
9604        "export_alias" => false,
9605        _ => false,
9606    }
9607}
9608
9609fn module_dependencies_for_ref(
9610    project_root: &Path,
9611    caller_file: &str,
9612    module_path: &str,
9613) -> BTreeSet<String> {
9614    module_dependencies(project_root, &project_root.join(caller_file), module_path)
9615}
9616
9617fn import_dependencies(
9618    project_root: &Path,
9619    abs_path: &Path,
9620    imports: &[ImportStatement],
9621) -> BTreeSet<String> {
9622    let mut deps = BTreeSet::new();
9623    for import in imports {
9624        deps.extend(module_dependencies(
9625            project_root,
9626            abs_path,
9627            &import.module_path,
9628        ));
9629    }
9630    deps
9631}
9632
9633fn module_dependencies(
9634    project_root: &Path,
9635    abs_path: &Path,
9636    module_path: &str,
9637) -> BTreeSet<String> {
9638    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
9639    let caller_dir = abs_path.parent().unwrap_or(project_root);
9640    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
9641        deps.insert(relative_path(project_root, &resolved));
9642    }
9643    if module_path.starts_with('.') {
9644        let base = caller_dir.join(module_path);
9645        for candidate in relative_module_candidates(&base) {
9646            deps.insert(relative_path(project_root, &candidate));
9647        }
9648    }
9649    deps
9650}
9651
9652fn rust_module_dependencies(
9653    project_root: &Path,
9654    abs_path: &Path,
9655    module_path: &str,
9656) -> BTreeSet<String> {
9657    let mut deps = BTreeSet::new();
9658    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
9659    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
9660        return deps;
9661    };
9662    let src_prefix = rust_src_prefix(&rel_path);
9663    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
9664    if !path_segments.is_empty() {
9665        rust_push_module_dependency_candidate(
9666            project_root,
9667            &mut deps,
9668            &src_prefix,
9669            &path_segments[..path_segments.len() - 1],
9670        );
9671    }
9672    deps
9673}
9674
9675fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
9676    let path = rust_module_path_without_alias_or_use_list(module_path);
9677    let segments = path
9678        .split("::")
9679        .map(str::trim)
9680        .filter(|segment| !segment.is_empty())
9681        .collect::<Vec<_>>();
9682    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
9683        return None;
9684    }
9685    rust_resolve_segments(rel_path, &segments)
9686}
9687
9688fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
9689    let path = module_path
9690        .trim()
9691        .trim_end_matches(';')
9692        .split_once(" as ")
9693        .map(|(left, _)| left.trim())
9694        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
9695    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
9696}
9697
9698fn rust_push_module_dependency_candidate(
9699    project_root: &Path,
9700    deps: &mut BTreeSet<String>,
9701    src_prefix: &str,
9702    segments: &[String],
9703) {
9704    let candidates = if segments.is_empty() {
9705        vec![
9706            format!("{src_prefix}/lib.rs"),
9707            format!("{src_prefix}/main.rs"),
9708        ]
9709    } else {
9710        vec![
9711            format!("{}/{}.rs", src_prefix, segments.join("/")),
9712            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
9713        ]
9714    };
9715    for candidate in candidates {
9716        if project_root.join(&candidate).is_file() {
9717            deps.insert(candidate);
9718        }
9719    }
9720}
9721
9722fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
9723    let mut candidates = Vec::new();
9724    if base.extension().is_some() {
9725        candidates.push(base.to_path_buf());
9726        return candidates;
9727    }
9728    for ext in JS_TS_EXTENSIONS {
9729        candidates.push(base.with_extension(ext));
9730    }
9731    for ext in JS_TS_EXTENSIONS {
9732        candidates.push(base.join(format!("index.{ext}")));
9733    }
9734    candidates
9735}
9736
9737fn import_local_names(import: &ImportStatement) -> Vec<String> {
9738    let mut names = Vec::new();
9739    if let Some(default) = &import.default_import {
9740        names.push(default.clone());
9741    }
9742    if let Some(namespace) = &import.namespace_import {
9743        names.push(namespace.clone());
9744    }
9745    for name in &import.names {
9746        names.push(crate::imports::specifier_local_name(name).to_string());
9747    }
9748    names
9749}
9750
9751fn import_requested_names(import: &ImportStatement) -> Vec<String> {
9752    import
9753        .names
9754        .iter()
9755        .map(|name| crate::imports::specifier_imported_name(name).to_string())
9756        .collect()
9757}
9758
9759fn import_is_wildcard(import: &ImportStatement) -> bool {
9760    import.namespace_import.is_some() || import.raw_text.contains('*')
9761}
9762
9763fn namespace_alias(full_ref: &str) -> Option<String> {
9764    full_ref
9765        .split_once('.')
9766        .map(|(namespace, _)| namespace.to_string())
9767}
9768
9769fn import_kind_label(kind: ImportKind) -> &'static str {
9770    match kind {
9771        ImportKind::Value => "value",
9772        ImportKind::Type => "type",
9773        ImportKind::SideEffect => "side_effect",
9774    }
9775}
9776
9777fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
9778    match kind {
9779        SymbolKind::Function => "function",
9780        SymbolKind::Class => "class",
9781        SymbolKind::Method => "method",
9782        SymbolKind::Struct => "struct",
9783        SymbolKind::Interface => "interface",
9784        SymbolKind::Enum => "enum",
9785        SymbolKind::TypeAlias => "type_alias",
9786        SymbolKind::Variable => "variable",
9787        SymbolKind::Heading => "heading",
9788        SymbolKind::FileSummary => "file_summary",
9789    }
9790}
9791
9792fn is_type_like(kind: &SymbolKind) -> bool {
9793    matches!(
9794        kind,
9795        SymbolKind::Class
9796            | SymbolKind::Struct
9797            | SymbolKind::Interface
9798            | SymbolKind::Enum
9799            | SymbolKind::TypeAlias
9800    )
9801}
9802
9803fn lang_label(lang: LangId) -> &'static str {
9804    match lang {
9805        LangId::TypeScript => "typescript",
9806        LangId::Tsx => "tsx",
9807        LangId::JavaScript => "javascript",
9808        LangId::Python => "python",
9809        LangId::Rust => "rust",
9810        LangId::Go => "go",
9811        LangId::C => "c",
9812        LangId::Cpp => "cpp",
9813        LangId::Zig => "zig",
9814        LangId::CSharp => "csharp",
9815        LangId::Bash => "bash",
9816        LangId::Html => "html",
9817        LangId::Markdown => "markdown",
9818        LangId::Solidity => "solidity",
9819        LangId::Scss => "scss",
9820        LangId::Vue => "vue",
9821        LangId::Json => "json",
9822        LangId::Scala => "scala",
9823        LangId::Java => "java",
9824        LangId::Ruby => "ruby",
9825        LangId::Kotlin => "kotlin",
9826        LangId::Swift => "swift",
9827        LangId::Php => "php",
9828        LangId::Lua => "lua",
9829        LangId::Perl => "perl",
9830        LangId::Yaml => "yaml",
9831        LangId::Pascal => "pascal",
9832        LangId::R => "r",
9833        LangId::Groovy => "groovy",
9834        LangId::ObjC => "objc",
9835    }
9836}
9837
9838fn lang_from_label(label: &str) -> Option<LangId> {
9839    match label {
9840        "typescript" => Some(LangId::TypeScript),
9841        "tsx" => Some(LangId::Tsx),
9842        "javascript" => Some(LangId::JavaScript),
9843        "python" => Some(LangId::Python),
9844        "rust" => Some(LangId::Rust),
9845        "go" => Some(LangId::Go),
9846        "c" => Some(LangId::C),
9847        "cpp" => Some(LangId::Cpp),
9848        "zig" => Some(LangId::Zig),
9849        "csharp" => Some(LangId::CSharp),
9850        "bash" => Some(LangId::Bash),
9851        "html" => Some(LangId::Html),
9852        "markdown" => Some(LangId::Markdown),
9853        "solidity" => Some(LangId::Solidity),
9854        "scss" => Some(LangId::Scss),
9855        "vue" => Some(LangId::Vue),
9856        "json" => Some(LangId::Json),
9857        "scala" => Some(LangId::Scala),
9858        "java" => Some(LangId::Java),
9859        "ruby" => Some(LangId::Ruby),
9860        "kotlin" => Some(LangId::Kotlin),
9861        "swift" => Some(LangId::Swift),
9862        "php" => Some(LangId::Php),
9863        "lua" => Some(LangId::Lua),
9864        "perl" => Some(LangId::Perl),
9865        "yaml" => Some(LangId::Yaml),
9866        "pascal" => Some(LangId::Pascal),
9867        "r" => Some(LangId::R),
9868        "groovy" => Some(LangId::Groovy),
9869        "objc" => Some(LangId::ObjC),
9870        _ => None,
9871    }
9872}
9873
9874fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
9875    let mut normalized = if files.is_empty() {
9876        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
9877    } else {
9878        files
9879            .iter()
9880            .map(|path| normalize_file_path(project_root, path))
9881            .collect::<Result<Vec<_>>>()?
9882    };
9883    normalized.sort();
9884    normalized.dedup();
9885    Ok(normalized)
9886}
9887
9888fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
9889    let full_path = if path.is_relative() {
9890        project_root.join(path)
9891    } else {
9892        path.to_path_buf()
9893    };
9894    Ok(canonicalize_path(&full_path))
9895}
9896
9897fn canonicalize_path(path: &Path) -> PathBuf {
9898    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
9899}
9900
9901fn relative_path(project_root: &Path, path: &Path) -> String {
9902    if let Ok(stripped) = path.strip_prefix(project_root) {
9903        return stripped.to_string_lossy().replace('\\', "/");
9904    }
9905    let canon_root = canonicalize_path(project_root);
9906    let canon_path = canonicalize_path(path);
9907    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
9908        return stripped.to_string_lossy().replace('\\', "/");
9909    }
9910    canon_path.to_string_lossy().replace('\\', "/")
9911}
9912
9913fn unqualified_name(scoped: &str) -> &str {
9914    if scoped == TOP_LEVEL_SYMBOL {
9915        return scoped;
9916    }
9917    scoped
9918        .rsplit("::")
9919        .next()
9920        .unwrap_or(scoped)
9921        .rsplit('.')
9922        .next()
9923        .unwrap_or(scoped)
9924        .rsplit('#')
9925        .next()
9926        .unwrap_or(scoped)
9927}
9928
9929fn ref_id(parts: &[&str]) -> String {
9930    let joined = parts.join("\0");
9931    hash_to_hex(blake3::hash(joined.as_bytes()))
9932}
9933
9934fn hash_to_hex(hash: blake3::Hash) -> String {
9935    hash.to_hex().to_string()
9936}
9937
9938fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
9939    let bytes = hex_to_bytes(value)?;
9940    Some(blake3::Hash::from_bytes(bytes))
9941}
9942
9943fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
9944    if value.len() != 64 {
9945        return None;
9946    }
9947    let mut bytes = [0u8; 32];
9948    for (index, slot) in bytes.iter_mut().enumerate() {
9949        let start = index * 2;
9950        let end = start + 2;
9951        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
9952    }
9953    Some(bytes)
9954}
9955
9956#[derive(Debug, Clone)]
9957struct LineIndex {
9958    newline_offsets: Vec<usize>,
9959    source_len: usize,
9960}
9961
9962impl LineIndex {
9963    fn new(source: &str) -> Self {
9964        Self {
9965            newline_offsets: source
9966                .bytes()
9967                .enumerate()
9968                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
9969                .collect(),
9970            source_len: source.len(),
9971        }
9972    }
9973
9974    fn byte_to_line(&self, byte_offset: usize) -> u32 {
9975        let byte_offset = byte_offset.min(self.source_len);
9976        self.newline_offsets
9977            .partition_point(|offset| *offset < byte_offset) as u32
9978            + 1
9979    }
9980}
9981
9982fn empty_to_none(value: String) -> Option<String> {
9983    if value.is_empty() {
9984        None
9985    } else {
9986        Some(value)
9987    }
9988}
9989
9990fn bool_int(value: bool) -> i64 {
9991    if value {
9992        1
9993    } else {
9994        0
9995    }
9996}
9997
9998fn system_time_to_ns(time: SystemTime) -> i64 {
9999    time.duration_since(UNIX_EPOCH)
10000        .unwrap_or_default()
10001        .as_nanos()
10002        .min(i64::MAX as u128) as i64
10003}
10004
10005fn ns_to_system_time(value: i64) -> SystemTime {
10006    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
10007}
10008
10009fn unix_seconds_now() -> i64 {
10010    SystemTime::now()
10011        .duration_since(UNIX_EPOCH)
10012        .unwrap_or_default()
10013        .as_secs() as i64
10014}
10015
10016/// Serializes every test that drives the process-wide refresh worker
10017/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
10018/// worker down between another test's enqueue and its flush, deferring the
10019/// batch and zeroing that test's seam counts).
10020#[cfg(test)]
10021pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
10022
10023#[cfg(test)]
10024mod refresh_worker_tests {
10025    use super::*;
10026    use std::fs;
10027    use tempfile::tempdir;
10028
10029    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10030        let temp = tempdir().unwrap();
10031        let root = temp.path().join("root");
10032        let callgraph_dir = temp
10033            .path()
10034            .join("storage")
10035            .join("callgraph")
10036            .join(crate::search_index::artifact_cache_key(&root));
10037        fs::create_dir_all(&root).unwrap();
10038        let source = root.join("main.rs");
10039        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
10040        let (store, _) = CallGraphStore::cold_build_with_lease(
10041            callgraph_dir.clone(),
10042            root.clone(),
10043            std::slice::from_ref(&source),
10044        )
10045        .unwrap();
10046        drop(store);
10047        (temp, root, callgraph_dir, source)
10048    }
10049
10050    fn pending_paths() -> PendingCallGraphStorePaths {
10051        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
10052    }
10053
10054    fn wait_for_refresh_calls(root: &Path, expected: usize) {
10055        let deadline = Instant::now() + Duration::from_secs(12);
10056        while callgraph_refresh_worker_test_counts(root).0 < expected {
10057            assert!(
10058                Instant::now() < deadline,
10059                "timed out waiting for {expected} callgraph refresh worker call(s)"
10060            );
10061            std::thread::sleep(Duration::from_millis(5));
10062        }
10063    }
10064
10065    #[test]
10066    fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
10067        let _git_env = crate::test_env::hermetic_git_env_guard();
10068        let temp = tempdir().unwrap();
10069        let main = temp.path().join("main");
10070        let root = temp.path().join("worktree");
10071        fs::create_dir_all(&main).unwrap();
10072        let mut git = std::process::Command::new("git");
10073        assert!(
10074            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
10075                .status()
10076                .unwrap()
10077                .success()
10078        );
10079        let source = main.join("lib.rs");
10080        fs::write(&source, "pub fn marker() {}\n").unwrap();
10081        for args in [
10082            vec![
10083                "-C",
10084                main.to_str().unwrap(),
10085                "config",
10086                "user.email",
10087                "test@example.com",
10088            ],
10089            vec![
10090                "-C",
10091                main.to_str().unwrap(),
10092                "config",
10093                "user.name",
10094                "AFT Test",
10095            ],
10096            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
10097            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
10098        ] {
10099            let mut command = std::process::Command::new("git");
10100            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
10101                .status()
10102                .unwrap()
10103                .success());
10104        }
10105        let mut worktree = std::process::Command::new("git");
10106        assert!(crate::test_env::apply_hermetic_git_env(
10107            worktree
10108                .arg("-C")
10109                .arg(&main)
10110                .args(["worktree", "add", "--detach"])
10111                .arg(&root),
10112        )
10113        .status()
10114        .unwrap()
10115        .success());
10116
10117        let project_key = crate::search_index::artifact_cache_key(&root);
10118        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
10119        crate::root_cache::configure_artifact_access(&root, &project_key, true);
10120        let source = root.join("lib.rs");
10121        let error =
10122            CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
10123                .expect_err("borrow-only forced rebuild must remain unsatisfied");
10124
10125        assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
10126    }
10127
10128    #[test]
10129    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
10130        let _guard = REFRESH_WORKER_TEST_LOCK
10131            .lock()
10132            .unwrap_or_else(std::sync::PoisonError::into_inner);
10133        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10134        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10135        let pending = pending_paths();
10136        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10137
10138        let lifecycle = SubcLifecycleAdmission::default();
10139        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10140        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10141        let ticket = CallgraphRefreshTicket::new(
10142            lifecycle,
10143            Arc::clone(&generation),
10144            7,
10145            publish_epoch.clone(),
10146            publish_epoch.current(),
10147        );
10148        // Supersede before the worker runs: the batch must defer, not commit.
10149        generation.store(8, std::sync::atomic::Ordering::SeqCst);
10150
10151        enqueue_callgraph_store_refresh_fenced(
10152            callgraph_dir,
10153            root.clone(),
10154            vec![source.clone()],
10155            Arc::clone(&pending),
10156            ticket,
10157        );
10158        assert!(flush_callgraph_store_refreshes_with_budget(
10159            Duration::from_secs(5)
10160        ));
10161        assert_eq!(
10162            callgraph_refresh_worker_test_counts(&root).0,
10163            0,
10164            "superseded batch must not reach refresh_files"
10165        );
10166        assert!(
10167            pending.lock().contains(&source),
10168            "superseded batch must defer its paths to the pending sink"
10169        );
10170        clear_callgraph_refresh_worker_test_seam(&root);
10171    }
10172
10173    #[test]
10174    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
10175        let _guard = REFRESH_WORKER_TEST_LOCK
10176            .lock()
10177            .unwrap_or_else(std::sync::PoisonError::into_inner);
10178        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10179        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10180        let pending = pending_paths();
10181        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10182
10183        let lifecycle = SubcLifecycleAdmission::default();
10184        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
10185        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10186        let expected_epoch = publish_epoch.current();
10187        let ticket = CallgraphRefreshTicket::new(
10188            lifecycle,
10189            generation,
10190            3,
10191            publish_epoch.clone(),
10192            expected_epoch,
10193        );
10194        // A cold build published a replacement generation after enqueue.
10195        publish_epoch.next();
10196
10197        enqueue_callgraph_store_refresh_fenced(
10198            callgraph_dir,
10199            root.clone(),
10200            vec![source.clone()],
10201            Arc::clone(&pending),
10202            ticket,
10203        );
10204        assert!(flush_callgraph_store_refreshes_with_budget(
10205            Duration::from_secs(5)
10206        ));
10207        assert_eq!(
10208            callgraph_refresh_worker_test_counts(&root).0,
10209            0,
10210            "epoch-superseded batch must not reach refresh_files"
10211        );
10212        assert!(
10213            pending.lock().contains(&source),
10214            "epoch-superseded batch must defer its paths to the pending sink"
10215        );
10216        clear_callgraph_refresh_worker_test_seam(&root);
10217    }
10218
10219    #[test]
10220    fn fenced_refresh_with_current_ticket_commits_normally() {
10221        let _guard = REFRESH_WORKER_TEST_LOCK
10222            .lock()
10223            .unwrap_or_else(std::sync::PoisonError::into_inner);
10224        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10225        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10226        let pending = pending_paths();
10227        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10228
10229        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10230
10231        let lifecycle = SubcLifecycleAdmission::default();
10232        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
10233        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10234        let ticket = CallgraphRefreshTicket::new(
10235            lifecycle,
10236            generation,
10237            5,
10238            publish_epoch.clone(),
10239            publish_epoch.current(),
10240        );
10241
10242        enqueue_callgraph_store_refresh_fenced(
10243            callgraph_dir.clone(),
10244            root.clone(),
10245            vec![source.clone()],
10246            Arc::clone(&pending),
10247            ticket,
10248        );
10249        assert!(flush_callgraph_store_refreshes_with_budget(
10250            Duration::from_secs(5)
10251        ));
10252        assert_eq!(
10253            callgraph_refresh_worker_test_counts(&root).0,
10254            1,
10255            "current ticket must run the refresh"
10256        );
10257        assert!(
10258            pending.lock().is_empty(),
10259            "committed batch must not defer paths"
10260        );
10261
10262        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10263            .unwrap()
10264            .expect("published generation must remain readable");
10265        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10266        assert_eq!(
10267            tree.children[0].name, "new_leaf",
10268            "fenced commit must actually persist the refreshed content"
10269        );
10270        clear_callgraph_refresh_worker_test_seam(&root);
10271    }
10272
10273    #[test]
10274    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
10275        let _guard = REFRESH_WORKER_TEST_LOCK
10276            .lock()
10277            .unwrap_or_else(std::sync::PoisonError::into_inner);
10278        // Generous pre-drain: the refresh worker is process-wide, so a prior
10279        // test's still-running batch (slow Windows CI) must fully settle
10280        // before this test enqueues, or its wait deadline absorbs the
10281        // leftover work. Idle workers return immediately.
10282        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10283        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10284        let pending = pending_paths();
10285        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
10286
10287        enqueue_callgraph_store_refresh(
10288            callgraph_dir.clone(),
10289            root.clone(),
10290            vec![source.clone()],
10291            Arc::clone(&pending),
10292        );
10293        wait_for_refresh_calls(&root, 1);
10294        for _ in 0..3 {
10295            enqueue_callgraph_store_refresh(
10296                callgraph_dir.clone(),
10297                root.clone(),
10298                vec![source.clone()],
10299                Arc::clone(&pending),
10300            );
10301        }
10302
10303        assert!(flush_callgraph_store_refreshes_with_budget(
10304            Duration::from_secs(2)
10305        ));
10306        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
10307        assert!(pending.lock().is_empty());
10308        clear_callgraph_refresh_worker_test_seam(&root);
10309    }
10310
10311    #[test]
10312    fn queued_refresh_opens_generation_published_after_enqueue() {
10313        let _guard = REFRESH_WORKER_TEST_LOCK
10314            .lock()
10315            .unwrap_or_else(std::sync::PoisonError::into_inner);
10316        // Generous pre-drain: the refresh worker is process-wide, so a prior
10317        // test's still-running batch (slow Windows CI) must fully settle
10318        // before this test enqueues, or its wait deadline absorbs the
10319        // leftover work. Idle workers return immediately.
10320        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10321        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10322        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
10323        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
10324        let (active_held_rx, active_release_tx) =
10325            install_refresh_worker_test_gate(active_root.clone());
10326        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
10327        enqueue_callgraph_store_refresh(
10328            active_dir,
10329            active_root.clone(),
10330            vec![active_source],
10331            pending_paths(),
10332        );
10333        active_held_rx
10334            .recv_timeout(Duration::from_secs(12))
10335            .expect("active refresh worker holds the queue");
10336
10337        fs::write(
10338            &target_source,
10339            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10340        )
10341        .unwrap();
10342        enqueue_callgraph_store_refresh(
10343            target_dir.clone(),
10344            target_root.clone(),
10345            vec![target_source.clone()],
10346            pending_paths(),
10347        );
10348        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
10349            target_dir.clone(),
10350            target_root.clone(),
10351            std::slice::from_ref(&target_source),
10352        )
10353        .unwrap();
10354        fs::write(
10355            &target_source,
10356            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10357        )
10358        .unwrap();
10359        drop(new_generation);
10360
10361        active_release_tx
10362            .send(())
10363            .expect("release active refresh worker");
10364        wait_for_refresh_calls(&target_root, 1);
10365        assert!(flush_callgraph_store_refreshes_with_budget(
10366            Duration::from_secs(12)
10367        ));
10368        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
10369            .unwrap()
10370            .expect("current callgraph generation");
10371        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10372        assert_eq!(tree.children[0].name, "worker_leaf");
10373        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
10374        clear_callgraph_refresh_worker_test_seam(&active_root);
10375        clear_callgraph_refresh_worker_test_seam(&target_root);
10376    }
10377
10378    #[test]
10379    fn refresh_failure_marks_files_stale() {
10380        let _guard = REFRESH_WORKER_TEST_LOCK
10381            .lock()
10382            .unwrap_or_else(std::sync::PoisonError::into_inner);
10383        // Generous pre-drain: the refresh worker is process-wide, so a prior
10384        // test's still-running batch (slow Windows CI) must fully settle
10385        // before this test enqueues, or its wait deadline absorbs the
10386        // leftover work. Idle workers return immediately.
10387        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10388        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10389        let pending = pending_paths();
10390        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
10391
10392        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
10393        assert!(flush_callgraph_store_refreshes_with_budget(
10394            Duration::from_secs(2)
10395        ));
10396
10397        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
10398        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
10399            .unwrap()
10400            .expect("ready callgraph store");
10401        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
10402        clear_callgraph_refresh_worker_test_seam(&root);
10403    }
10404
10405    #[test]
10406    fn bounded_shutdown_defers_unprocessed_batches() {
10407        let _guard = REFRESH_WORKER_TEST_LOCK
10408            .lock()
10409            .unwrap_or_else(std::sync::PoisonError::into_inner);
10410        // Generous pre-drain: the refresh worker is process-wide, so a prior
10411        // test's still-running batch (slow Windows CI) must fully settle
10412        // before this test enqueues, or its wait deadline absorbs the
10413        // leftover work. Idle workers return immediately.
10414        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10415        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10416        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
10417        let active_pending = pending_paths();
10418        let queued_pending = pending_paths();
10419        set_callgraph_refresh_worker_test_seam(
10420            active_root.clone(),
10421            Duration::from_millis(300),
10422            false,
10423        );
10424
10425        enqueue_callgraph_store_refresh(
10426            active_dir,
10427            active_root.clone(),
10428            vec![active_source.clone()],
10429            Arc::clone(&active_pending),
10430        );
10431        wait_for_refresh_calls(&active_root, 1);
10432        enqueue_callgraph_store_refresh(
10433            queued_dir,
10434            queued_root.clone(),
10435            vec![queued_source.clone()],
10436            Arc::clone(&queued_pending),
10437        );
10438
10439        assert!(!flush_callgraph_store_refreshes_with_budget(
10440            Duration::from_millis(20)
10441        ));
10442        assert!(active_pending.lock().contains(&active_source));
10443        assert!(queued_pending.lock().contains(&queued_source));
10444        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
10445        clear_callgraph_refresh_worker_test_seam(&active_root);
10446    }
10447}
10448
10449#[cfg(test)]
10450mod cold_build_insert_tests {
10451    use super::*;
10452    use crate::imports::ImportBlock;
10453    use std::cell::Cell;
10454    use std::fs;
10455    use std::path::{Path, PathBuf};
10456    use tempfile::tempdir;
10457
10458    thread_local! {
10459        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
10460        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
10461        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
10462    }
10463
10464    fn count_caller_traversal_selects(sql: &str) {
10465        let sql = sql.trim_start();
10466        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
10467            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
10468        }
10469        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
10470            && sql.contains("e.target_file =")
10471        {
10472            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
10473        }
10474        if sql.starts_with("WITH requested") {
10475            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
10476        }
10477    }
10478
10479    #[test]
10480    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
10481        let dir = tempdir().unwrap();
10482        let previous_root = dir.path().join("previous-root");
10483        let current_root = dir.path().join("current-root");
10484        fs::create_dir_all(&previous_root).unwrap();
10485        fs::create_dir_all(&current_root).unwrap();
10486        fs::remove_dir(&previous_root).unwrap();
10487        let mut conn = Connection::open_in_memory().unwrap();
10488        initialize_schema(&conn).unwrap();
10489        conn.execute(
10490            "INSERT INTO backend_file_state(
10491                backend, workspace_root, file_path, content_hash, status, updated_at
10492             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
10493            params![previous_root.display().to_string()],
10494        )
10495        .unwrap();
10496
10497        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
10498
10499        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
10500        assert_eq!(
10501            stored_workspace_roots(&conn).unwrap(),
10502            vec![previous_root.display().to_string()]
10503        );
10504    }
10505
10506    #[test]
10507    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
10508        assert_eq!(
10509            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
10510            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
10511        );
10512    }
10513
10514    #[test]
10515    fn legacy_migration_completion_log_has_operator_fields() {
10516        assert_eq!(
10517            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
10518            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
10519        );
10520    }
10521
10522    fn write_generation_with_age(
10523        dir: &Path,
10524        project_key: &str,
10525        ordinal: u64,
10526        age: Duration,
10527    ) -> String {
10528        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
10529        let path = dir.join(&generation);
10530        fs::write(&path, b"sqlite placeholder").unwrap();
10531        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
10532        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
10533        generation
10534    }
10535
10536    #[test]
10537    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
10538        let dir = tempfile::tempdir().unwrap();
10539        let project_key = "project";
10540        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10541        let previous =
10542            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10543        let pinned =
10544            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10545        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10546
10547        gc_old_generations(dir.path(), project_key, &current);
10548
10549        assert!(dir.path().join(&previous).is_file());
10550        assert!(dir.path().join(&pinned).is_file());
10551
10552        drop(marker);
10553        gc_old_generations(dir.path(), project_key, &current);
10554
10555        assert!(dir.path().join(&previous).is_file());
10556        assert!(!dir.path().join(&pinned).exists());
10557    }
10558
10559    #[test]
10560    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
10561        let dir = tempfile::tempdir().unwrap();
10562        let project_key = "project";
10563        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10564        let _previous =
10565            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
10566        let pinned =
10567            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
10568        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
10569        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
10570
10571        gc_old_generations(dir.path(), project_key, &current);
10572
10573        assert!(dir.path().join(&pinned).is_file());
10574    }
10575
10576    #[test]
10577    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
10578        let dir = tempfile::tempdir().unwrap();
10579        let project_key = "project";
10580        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
10581        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
10582        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
10583        let old = write_generation_with_age(
10584            dir.path(),
10585            project_key,
10586            200,
10587            expired + Duration::from_secs(60),
10588        );
10589        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
10590
10591        gc_old_generations(dir.path(), project_key, &current);
10592
10593        assert!(dir.path().join(&current).is_file());
10594        assert!(dir.path().join(&previous).is_file());
10595        assert!(!dir.path().join(&old).exists());
10596    }
10597
10598    #[test]
10599    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
10600        let dir = tempfile::tempdir().unwrap();
10601        let project_key = "project".to_string();
10602        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
10603        let sqlite_path = dir.path().join(&generation);
10604        fs::remove_file(&sqlite_path).unwrap();
10605        let conn = Connection::open(&sqlite_path).unwrap();
10606        let store = CallGraphStore::from_connection(
10607            dir.path().to_path_buf(),
10608            project_key,
10609            sqlite_path,
10610            dir.path().to_path_buf(),
10611            false,
10612            Some(generation.clone()),
10613            None,
10614            None,
10615            conn,
10616        );
10617
10618        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
10619        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
10620
10621        drop(marker);
10622        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
10623    }
10624
10625    #[test]
10626    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
10627        let dir = tempdir().expect("temp dir");
10628        let file = dir.path().join("main.ts");
10629        fs::write(&file, "export function main() {}\n").expect("write fixture");
10630        let store = CallGraphStore::open(
10631            dir.path().join(".store-readiness-cache"),
10632            dir.path().to_path_buf(),
10633        )
10634        .expect("open store");
10635        {
10636            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10637            conn.trace(Some(count_caller_traversal_selects));
10638        }
10639
10640        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
10641        assert!(store.indexed_file_count().is_err());
10642        assert!(store.indexed_file_count().is_err());
10643        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
10644
10645        store
10646            .cold_build(std::slice::from_ref(&file))
10647            .expect("cold build");
10648        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
10649        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
10650        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
10651        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
10652
10653        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10654        conn.trace(None);
10655    }
10656
10657    #[test]
10658    fn callers_depth_boundary_batches_sqlite_counts() {
10659        const CALLER_COUNT: usize = 1_000;
10660
10661        let dir = tempdir().expect("temp dir");
10662        let file = dir.path().join("main.ts");
10663        let mut source = String::from("export function sharedHotHelper() {}\n");
10664        for index in 0..CALLER_COUNT {
10665            source.push_str(&format!(
10666                "export function caller{index}() {{ sharedHotHelper(); }}\n"
10667            ));
10668        }
10669        fs::write(&file, source).expect("write fixture");
10670
10671        let store = CallGraphStore::open(
10672            dir.path().join(".store-callers-query-fanout"),
10673            dir.path().to_path_buf(),
10674        )
10675        .expect("open store");
10676        store
10677            .cold_build(std::slice::from_ref(&file))
10678            .expect("cold build");
10679
10680        CALLER_QUERY_SELECTS.with(|count| count.set(0));
10681        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
10682        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
10683        {
10684            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10685            conn.trace(Some(count_caller_traversal_selects));
10686        }
10687
10688        let started = Instant::now();
10689        let result = crate::commands::callgraph_store_adapter::callers_result(
10690            &store,
10691            Path::new("main.ts"),
10692            "sharedHotHelper",
10693            1,
10694            true,
10695        )
10696        .expect("callers result");
10697        let elapsed = started.elapsed();
10698
10699        {
10700            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
10701            conn.trace(None);
10702        }
10703        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
10704        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
10705        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
10706        eprintln!(
10707            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
10708            result.total_callers,
10709            caller_queries,
10710            boundary_queries,
10711            total_selects,
10712            elapsed.as_secs_f64() * 1_000.0
10713        );
10714
10715        assert_eq!(result.total_callers, CALLER_COUNT);
10716        assert_eq!(caller_queries, 1);
10717        assert_eq!(boundary_queries, 3);
10718        assert_eq!(total_selects, 9);
10719    }
10720
10721    #[test]
10722    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
10723        let dir = tempdir().expect("temp dir");
10724        let file = dir.path().join("main.ts");
10725        fs::write(
10726            &file,
10727            r#"export function topA() {
10728  root();
10729}
10730
10731export function topB() {
10732  root();
10733}
10734
10735export function root() {
10736  leaf();
10737  missing();
10738}
10739
10740export function leaf() {}
10741"#,
10742        )
10743        .expect("write fixture");
10744
10745        let store = CallGraphStore::open(
10746            dir.path().join(".store-depth-boundary-counts"),
10747            dir.path().to_path_buf(),
10748        )
10749        .expect("open store");
10750        store
10751            .cold_build(std::slice::from_ref(&file))
10752            .expect("cold build");
10753
10754        let root = store
10755            .node_for(Path::new("main.ts"), "root")
10756            .expect("root node");
10757        let leaf = store
10758            .node_for(Path::new("main.ts"), "leaf")
10759            .expect("leaf node");
10760
10761        let (full_forward_len, full_direct_len) = {
10762            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
10763            conn.execute(
10764                "INSERT INTO edges (
10765                    edge_id, ref_id, source_node, target_node, target_file,
10766                    target_symbol, kind, line, provenance
10767                 ) VALUES (
10768                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
10769                    ?2, ?3, 'call', 98, ?4
10770                 )",
10771                rusqlite::params![
10772                    &root.node_id,
10773                    &leaf.file,
10774                    &leaf.symbol,
10775                    PROVENANCE_TREESITTER
10776                ],
10777            )
10778            .expect("insert dangling forward edge");
10779            conn.execute(
10780                "INSERT INTO edges (
10781                    edge_id, ref_id, source_node, target_node, target_file,
10782                    target_symbol, kind, line, provenance
10783                 ) VALUES (
10784                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
10785                    ?1, ?2, ?3, 'call', 99, ?4
10786                 )",
10787                rusqlite::params![
10788                    &root.node_id,
10789                    &root.file,
10790                    &root.symbol,
10791                    PROVENANCE_TREESITTER
10792                ],
10793            )
10794            .expect("insert dangling direct-caller edge");
10795
10796            let full_forward_len = forward_calls_for_node(&conn, &root)
10797                .expect("full forward calls")
10798                .len();
10799            let counted_forward_len =
10800                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
10801            assert_eq!(
10802                counted_forward_len, full_forward_len,
10803                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
10804            );
10805
10806            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
10807                .expect("full direct callers");
10808            let full_direct_len = full_direct.len();
10809            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
10810                .expect("counted direct callers");
10811            assert_eq!(
10812                counted_direct_len, full_direct_len,
10813                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
10814            );
10815
10816            let distinct_direct_len = full_direct
10817                .iter()
10818                .map(|site| {
10819                    (
10820                        site.caller.file.clone(),
10821                        site.line,
10822                        site.target_file.clone(),
10823                        site.target_symbol.clone(),
10824                    )
10825                })
10826                .collect::<BTreeSet<_>>()
10827                .len();
10828            let batch_counts = direct_caller_counts_for_tuples(
10829                &conn,
10830                &[
10831                    (root.file.clone(), root.symbol.clone()),
10832                    (root.file.clone(), root.symbol.clone()),
10833                    (leaf.file.clone(), leaf.symbol.clone()),
10834                ],
10835            )
10836            .expect("batched direct-caller counts");
10837            assert_eq!(batch_counts.len(), 2);
10838            assert_eq!(
10839                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
10840                Some(&distinct_direct_len)
10841            );
10842
10843            (full_forward_len, full_direct_len)
10844        };
10845
10846        assert_eq!(
10847            full_forward_len, 2,
10848            "fixture root should have one resolved and one unresolved outgoing call"
10849        );
10850        assert_eq!(
10851            full_direct_len, 2,
10852            "fixture root should have two real direct callers"
10853        );
10854
10855        let tree = store
10856            .call_tree(Path::new("main.ts"), "root", 0)
10857            .expect("call tree");
10858        assert!(tree.depth_limited);
10859        assert_eq!(tree.children.len(), 0);
10860        assert_eq!(
10861            tree.truncated, full_forward_len,
10862            "call_tree depth boundary must report the full forward-call list length"
10863        );
10864
10865        let callers = store
10866            .callers_of(Path::new("main.ts"), "leaf", 0)
10867            .expect("callers");
10868        assert!(callers.depth_limited);
10869        assert_eq!(callers.callers.len(), 1);
10870        assert_eq!(callers.callers[0].caller.symbol, "root");
10871        assert_eq!(
10872            callers.truncated, full_direct_len,
10873            "callers depth boundary must report the full direct-caller list length"
10874        );
10875    }
10876
10877    #[test]
10878    fn source_freshness_matches_cache_collect_for_same_bytes() {
10879        let dir = tempdir().expect("temp dir");
10880        let path = dir.path().join("fixture.ts");
10881        let source = "export function main() { return helper(); }\n";
10882        fs::write(&path, source).expect("write fixture");
10883
10884        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
10885        let actual =
10886            collect_source_freshness(&path, source).expect("collect freshness from source");
10887
10888        assert_eq!(actual, expected);
10889    }
10890
10891    #[test]
10892    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
10893        let root = tempfile::tempdir().unwrap();
10894        let callgraph_dir = tempfile::tempdir().unwrap();
10895        let source_dir = root.path().join("src");
10896        std::fs::create_dir_all(&source_dir).unwrap();
10897        let source = source_dir.join("lib.rs");
10898        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
10899        let files = vec![source.clone()];
10900        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
10901        let old_epoch = epoch.next();
10902        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
10903        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
10904        let old_epoch_flag = epoch.clone();
10905        let old_dir = callgraph_dir.path().to_path_buf();
10906        let old_root = root.path().to_path_buf();
10907        let old_files = files.clone();
10908        let old = std::thread::spawn(move || {
10909            set_cold_build_before_publish_observer(Some(Arc::new(move || {
10910                reached_tx.send(()).unwrap();
10911                release_rx.recv().unwrap();
10912            })));
10913            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
10914                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
10915            });
10916            set_cold_build_before_publish_observer(None);
10917            result
10918        });
10919        // Positive wait: the older build runs a real cold build (git probe +
10920        // SQLite schema init) before the barrier, which can exceed 5s on a
10921        // contended Windows CI runner. Only negative waits stay short.
10922        reached_rx
10923            .recv_timeout(Duration::from_secs(30))
10924            .expect("older build did not reach its publication barrier");
10925
10926        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
10927        let new_epoch = epoch.next();
10928        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
10929            CallGraphStore::cold_build_with_lease(
10930                callgraph_dir.path().to_path_buf(),
10931                root.path().to_path_buf(),
10932                &files,
10933            )
10934        })
10935        .expect("newer build should publish");
10936        drop(new_store);
10937
10938        release_tx.send(()).unwrap();
10939        assert!(matches!(
10940            old.join().unwrap(),
10941            Err(CallGraphStoreError::Superseded)
10942        ));
10943
10944        let current = CallGraphStore::open_readonly(
10945            callgraph_dir.path().to_path_buf(),
10946            root.path().to_path_buf(),
10947        )
10948        .unwrap()
10949        .expect("current callgraph generation");
10950        assert_eq!(
10951            current
10952                .nodes_matching("new_generation_marker")
10953                .unwrap()
10954                .len(),
10955            1
10956        );
10957        assert!(current
10958            .nodes_matching("old_generation_marker")
10959            .unwrap()
10960            .is_empty());
10961    }
10962
10963    #[test]
10964    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
10965        let dir = tempdir().expect("temp dir");
10966        let project_root = dir.path();
10967        let extract = fixture_extract(project_root);
10968        let resolved = fixture_resolved(&extract);
10969
10970        let reference = build_reference_connection(project_root, &extract, &resolved);
10971        let optimized = build_optimized_connection(project_root, &extract, &resolved);
10972
10973        for table in [
10974            "files",
10975            "nodes",
10976            "file_dependencies",
10977            "dispatch_hints",
10978            "refs",
10979            "edges",
10980        ] {
10981            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
10982            // the reference and optimized builds run sequentially and can straddle a
10983            // one-second tick under load, so it is legitimately allowed to differ.
10984            // This mirrors the existing exclusions of `backend_file_state.updated_at`
10985            // and the chunked-vs-unchunked sibling test. The check is for structural
10986            // row equivalence of the optimized bulk insert, not wall-clock equality.
10987            let excluded: &[&str] = if table == "files" {
10988                &["indexed_at"]
10989            } else {
10990                &[]
10991            };
10992            assert_eq!(
10993                table_rows_without(&reference, table, excluded),
10994                table_rows_without(&optimized, table, excluded),
10995                "table `{table}` rows must match apart from wall-clock columns"
10996            );
10997        }
10998        assert_eq!(
10999            backend_state_rows(&reference),
11000            backend_state_rows(&optimized),
11001            "backend freshness rows must match apart from updated_at"
11002        );
11003        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
11004    }
11005
11006    #[test]
11007    fn cold_build_chunked_matches_unchunked_logical_rows() {
11008        let dir = tempdir().expect("temp dir");
11009        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
11010        write_chunked_equivalence_fixture(&project_root);
11011        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11012        assert!(
11013            files.len() > 6,
11014            "fixture should be large enough to split into multiple chunks"
11015        );
11016
11017        let unchunked = CallGraphStore::open(
11018            project_root.join(".store-unchunked"),
11019            project_root.to_path_buf(),
11020        )
11021        .expect("open unchunked store");
11022        let unchunked_stats = unchunked
11023            .cold_build_chunked(&files, 0)
11024            .expect("unchunked cold build");
11025
11026        let chunked = CallGraphStore::open(
11027            project_root.join(".store-chunked"),
11028            project_root.to_path_buf(),
11029        )
11030        .expect("open chunked store");
11031        let chunked_stats = chunked
11032            .cold_build_chunked(&files, 3)
11033            .expect("chunked cold build");
11034
11035        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
11036        assert_eq!(
11037            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
11038            chunked.edge_snapshot().expect("chunked edge snapshot"),
11039            "public edge snapshots must match"
11040        );
11041
11042        let dispatch_edges = {
11043            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
11044            conn.query_row(
11045                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
11046                [],
11047                |row| row.get::<_, i64>(0),
11048            )
11049            .expect("count dispatch edges")
11050        };
11051        assert!(
11052            dispatch_edges > 0,
11053            "fixture must exercise method-dispatch edge insertion"
11054        );
11055
11056        for table in [
11057            "edges",
11058            "refs",
11059            "nodes",
11060            "file_dependencies",
11061            "dispatch_hints",
11062        ] {
11063            assert_eq!(
11064                graph_table_rows(&unchunked, table),
11065                graph_table_rows(&chunked, table),
11066                "chunked cold build must match unchunked rows for {table}"
11067            );
11068        }
11069        assert_eq!(
11070            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
11071            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
11072            "files rows must match apart from indexed_at"
11073        );
11074        assert_eq!(
11075            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
11076            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
11077            "backend freshness rows must match apart from updated_at"
11078        );
11079
11080        let published_dir = project_root.join(".store-published");
11081        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
11082            published_dir.clone(),
11083            project_root.to_path_buf(),
11084            &files,
11085            0,
11086        )
11087        .expect("published unchunked cold build");
11088        assert!(
11089            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
11090                .expect("needs_cold_build after publish"),
11091            "published store should be ready"
11092        );
11093        drop(_published);
11094        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
11095            published_dir,
11096            project_root.to_path_buf(),
11097            &files,
11098            3,
11099        )
11100        .expect("ensure with a different chunk size");
11101        assert!(
11102            rebuild_stats.is_none(),
11103            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
11104        );
11105    }
11106
11107    // Perf A/B bench (not a gate): measures cold_build wall time at a given
11108    // chunk size against a real repo. Driven by env so the same binary can A/B
11109    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
11110    // memory work. Run:
11111    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
11112    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
11113    #[test]
11114    #[ignore]
11115    fn bench_cold_build_chunk() {
11116        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
11117        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
11118            .expect("AFT_PERF_CHUNK")
11119            .parse()
11120            .expect("AFT_PERF_CHUNK must be a non-negative integer");
11121        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
11122        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11123        let dir = tempdir().expect("temp dir");
11124        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
11125            .expect("open store");
11126        let started = Instant::now();
11127        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
11128        let ms = started.elapsed().as_millis();
11129        println!(
11130            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
11131            stats.files, stats.nodes, stats.refs, stats.edges
11132        );
11133    }
11134
11135    #[test]
11136    fn persisted_workspace_reexport_selects_its_package_dependency() {
11137        let root = tempdir().expect("temp dir");
11138        let dependencies = BTreeSet::from([
11139            "packages/aft-bridge/src/index.ts".to_string(),
11140            "packages/opencode-plugin/src/types.ts".to_string(),
11141        ]);
11142        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
11143
11144        assert_eq!(
11145            stored_dependencies_for_module(
11146                root.path(),
11147                "packages/opencode-plugin/src/shared/bash-hints.ts",
11148                "@cortexkit/aft-bridge",
11149                &dependencies,
11150                &indexed_files,
11151            ),
11152            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
11153        );
11154    }
11155
11156    #[test]
11157    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
11158        let dir = tempdir().expect("temp dir");
11159        let project_root = dir.path();
11160        let files =
11161            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
11162        let index_path = project_root.join("src/index.ts");
11163
11164        let store = CallGraphStore::open(
11165            project_root.join(".store-incremental-barrel"),
11166            project_root.to_path_buf(),
11167        )
11168        .expect("open incremental store");
11169        store.cold_build(&files).expect("initial cold build");
11170
11171        {
11172            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11173            let tx = conn.transaction().expect("dependency transaction");
11174            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
11175                .expect("dependent refs for barrel");
11176            let selected_ref_ids = dependent_refs
11177                .iter()
11178                .map(|dependent_ref| dependent_ref.ref_id.clone())
11179                .collect::<BTreeSet<_>>();
11180            let mut threaded_ref_ids = BTreeSet::new();
11181            let mut threaded_by_caller = BTreeMap::new();
11182            record_dependent_refs(
11183                &mut threaded_ref_ids,
11184                &mut threaded_by_caller,
11185                dependent_refs,
11186            );
11187            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
11188                .expect("old per-ref caller lookup");
11189
11190            assert_eq!(threaded_ref_ids, selected_ref_ids);
11191            assert_eq!(threaded_by_caller, old_by_caller);
11192            for consumer in [
11193                "src/consumer_a.ts",
11194                "src/consumer_b.ts",
11195                "src/consumer_c.ts",
11196            ] {
11197                assert!(
11198                    threaded_by_caller.contains_key(consumer),
11199                    "barrel edit should select dependent refs from {consumer}"
11200                );
11201            }
11202        }
11203
11204        fs::write(
11205            &index_path,
11206            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
11207        )
11208        .expect("edit barrel");
11209        let stats = store
11210            .refresh_files(std::slice::from_ref(&index_path))
11211            .expect("incremental refresh");
11212        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
11213        assert!(
11214            stats.dependency_selected_refs > 0,
11215            "barrel surface edit should select dependent refs"
11216        );
11217
11218        let cold_store = CallGraphStore::open(
11219            project_root.join(".store-cold-barrel"),
11220            project_root.to_path_buf(),
11221        )
11222        .expect("open cold rebuild store");
11223        cold_store
11224            .cold_build(&files)
11225            .expect("comparison cold build");
11226
11227        for table in [
11228            "nodes",
11229            "refs",
11230            "file_dependencies",
11231            "edges",
11232            "dispatch_hints",
11233        ] {
11234            assert_eq!(
11235                graph_table_rows(&store, table),
11236                graph_table_rows(&cold_store, table),
11237                "incremental refresh {table} rows must match cold rebuild"
11238            );
11239        }
11240
11241        let consumer_path = project_root.join("src/consumer_a.ts");
11242        fs::write(
11243            &consumer_path,
11244            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
11245        )
11246        .expect("edit barrel consumer");
11247        store
11248            .refresh_files(std::slice::from_ref(&consumer_path))
11249            .expect("refresh consumer through unchanged barrel");
11250        cold_store
11251            .cold_build(&files)
11252            .expect("comparison cold rebuild after consumer refresh");
11253        for table in [
11254            "nodes",
11255            "refs",
11256            "file_dependencies",
11257            "edges",
11258            "dispatch_hints",
11259        ] {
11260            assert_eq!(
11261                graph_table_rows(&store, table),
11262                graph_table_rows(&cold_store, table),
11263                "refresh through a persisted barrel must preserve cold-build {table} rows"
11264            );
11265        }
11266    }
11267
11268    fn build_reference_connection(
11269        project_root: &Path,
11270        extract: &FileExtract,
11271        resolved: &ResolvedRef,
11272    ) -> Connection {
11273        let mut conn = Connection::open_in_memory().expect("open reference db");
11274        configure_build_connection(&conn).expect("configure reference db");
11275        initialize_schema(&conn).expect("initialize reference schema");
11276        {
11277            let tx = conn.transaction().expect("reference transaction");
11278            clear_tables(&tx).expect("reference clear");
11279            insert_meta(&tx).expect("reference meta");
11280            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
11281            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
11282            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11283                .expect("reference dispatch edges");
11284            assert_eq!(supplemental, 0);
11285            tx.commit().expect("reference commit");
11286        }
11287        conn
11288    }
11289
11290    fn build_optimized_connection(
11291        project_root: &Path,
11292        extract: &FileExtract,
11293        resolved: &ResolvedRef,
11294    ) -> Connection {
11295        let mut conn = Connection::open_in_memory().expect("open optimized db");
11296        configure_build_connection(&conn).expect("configure optimized db");
11297        initialize_schema(&conn).expect("initialize optimized schema");
11298        {
11299            let tx = conn.transaction().expect("optimized transaction");
11300            clear_tables(&tx).expect("optimized clear");
11301            insert_meta(&tx).expect("optimized meta");
11302            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
11303            {
11304                let workspace_root = project_root.display().to_string();
11305                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
11306                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
11307                    .expect("optimized file extract");
11308                insert_resolved_ref_prepared(&mut inserts, resolved)
11309                    .expect("optimized resolved ref");
11310            }
11311            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
11312            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
11313                .expect("optimized dispatch edges");
11314            assert_eq!(supplemental, 0);
11315            tx.commit().expect("optimized commit");
11316        }
11317        conn
11318    }
11319
11320    fn fixture_extract(_project_root: &Path) -> FileExtract {
11321        let rel_path = "src/main.ts".to_string();
11322        let target_path = "src/helper.ts".to_string();
11323        let node = NodeRecord {
11324            id: "node-main".to_string(),
11325            file_path: rel_path.clone(),
11326            name: "main".to_string(),
11327            scoped_name: "main".to_string(),
11328            kind: "function".to_string(),
11329            range: Range {
11330                start_line: 0,
11331                start_col: 0,
11332                end_line: 0,
11333                end_col: 32,
11334            },
11335            range_ordinal: 0,
11336            signature: Some("export function main()".to_string()),
11337            exported: true,
11338            is_default_export: false,
11339            is_type_like: false,
11340            is_callgraph_entry_point: true,
11341        };
11342        let mut dependencies = BTreeSet::new();
11343        dependencies.insert(target_path.clone());
11344        let raw_ref = RawRef {
11345            ref_id: "ref-main-helper".to_string(),
11346            caller_node: Some(node.id.clone()),
11347            caller_symbol: Some(node.scoped_name.clone()),
11348            caller_file: rel_path.clone(),
11349            kind: "call".to_string(),
11350            short_name: Some("helper".to_string()),
11351            full_ref: Some("helper".to_string()),
11352            module_path: None,
11353            import_kind: None,
11354            local_name: Some("helper".to_string()),
11355            requested_name: Some("helper".to_string()),
11356            namespace_alias: None,
11357            wildcard: false,
11358            line: 1,
11359            byte_start: 24,
11360            byte_end: 32,
11361            dependencies,
11362        };
11363        FileExtract {
11364            rel_path,
11365            freshness: FileFreshness {
11366                mtime: UNIX_EPOCH + Duration::from_secs(123),
11367                size: 40,
11368                content_hash: cache_freshness::hash_bytes(b"fixture source"),
11369            },
11370            lang: LangId::TypeScript,
11371            data: FileCallData {
11372                calls_by_symbol: HashMap::new(),
11373                exported_symbols: Vec::new(),
11374                symbol_metadata: HashMap::new(),
11375                default_export_symbol: None,
11376                import_block: ImportBlock::empty(),
11377                lang: LangId::TypeScript,
11378            },
11379            nodes: vec![node.clone()],
11380            raw_refs: vec![raw_ref],
11381            dispatch_hints: vec![DispatchHint {
11382                id: "dispatch-main-helper".to_string(),
11383                method_name: "helper".to_string(),
11384                caller_node: node.id,
11385                file: "src/main.ts".to_string(),
11386                line: 1,
11387                byte_start: 24,
11388                byte_end: 32,
11389            }],
11390            surface_fingerprint: "surface".to_string(),
11391        }
11392    }
11393
11394    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
11395        let raw = extract.raw_refs[0].clone();
11396        let mut dependencies = raw.dependencies.clone();
11397        dependencies.insert("src/helper.ts".to_string());
11398        ResolvedRef {
11399            edge: Some(EdgeRecord {
11400                edge_id: "edge-main-helper".to_string(),
11401                source_node: raw.caller_node.clone().expect("caller node"),
11402                target_node: Some("node-helper".to_string()),
11403                target_file: "src/helper.ts".to_string(),
11404                target_symbol: "helper".to_string(),
11405                kind: "call".to_string(),
11406                line: raw.line,
11407            }),
11408            raw,
11409            status: "resolved".to_string(),
11410            target_node: Some("node-helper".to_string()),
11411            target_file: Some("src/helper.ts".to_string()),
11412            target_symbol: Some("helper".to_string()),
11413            dependencies,
11414        }
11415    }
11416
11417    fn write_chunked_equivalence_fixture(project_root: &Path) {
11418        let ts_dir = project_root.join("ts");
11419        fs::create_dir_all(&ts_dir).expect("create ts dir");
11420        fs::write(
11421            ts_dir.join("leaf.ts"),
11422            "export function leaf(value: number) {\n  return value + 1;\n}\n",
11423        )
11424        .expect("write ts leaf");
11425        fs::write(
11426            ts_dir.join("mid.ts"),
11427            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
11428        )
11429        .expect("write ts mid");
11430        fs::write(
11431            ts_dir.join("entry.ts"),
11432            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
11433        )
11434        .expect("write ts entry");
11435        fs::write(
11436            ts_dir.join("worker.ts"),
11437            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
11438        )
11439        .expect("write ts worker");
11440        for idx in 0..4 {
11441            fs::write(
11442                ts_dir.join(format!("extra_{idx}.ts")),
11443                format!(
11444                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
11445                ),
11446            )
11447            .expect("write ts extra");
11448        }
11449
11450        let rust_dir = project_root.join("src");
11451        let commands_dir = rust_dir.join("commands");
11452        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
11453        fs::write(
11454            rust_dir.join("context.rs"),
11455            r#"pub struct AppContext;
11456
11457impl AppContext {
11458    pub fn callgraph_store_for_ops(&self) -> usize {
11459        1
11460    }
11461}
11462"#,
11463        )
11464        .expect("write rust context");
11465        fs::write(
11466            rust_dir.join("lib.rs"),
11467            "pub mod context;\npub mod commands;\n",
11468        )
11469        .expect("write rust lib");
11470        fs::write(
11471            commands_dir.join("mod.rs"),
11472            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
11473        )
11474        .expect("write rust commands mod");
11475        for name in ["callers", "impact", "trace_to"] {
11476            fs::write(
11477                commands_dir.join(format!("{name}.rs")),
11478                format!(
11479                    r#"use crate::context::AppContext;
11480
11481pub fn handle_{name}(ctx: &AppContext) -> usize {{
11482    ctx.callgraph_store_for_ops()
11483}}
11484"#
11485                ),
11486            )
11487            .expect("write rust command");
11488        }
11489    }
11490
11491    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
11492        let src_dir = project_root.join("src");
11493        fs::create_dir_all(&src_dir).expect("create src dir");
11494
11495        let target_path = src_dir.join("target.ts");
11496        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
11497            .expect("write target");
11498
11499        let index_path = src_dir.join("index.ts");
11500        fs::write(&index_path, barrel_source).expect("write barrel");
11501
11502        let mut files = vec![target_path, index_path];
11503        for (file_name, function_name) in [
11504            ("consumer_a.ts", "consumerA"),
11505            ("consumer_b.ts", "consumerB"),
11506            ("consumer_c.ts", "consumerC"),
11507        ] {
11508            let path = src_dir.join(file_name);
11509            fs::write(
11510                &path,
11511                format!(
11512                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
11513                ),
11514            )
11515            .expect("write consumer");
11516            files.push(path);
11517        }
11518        files
11519    }
11520
11521    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
11522        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11523        table_rows(&conn, table)
11524    }
11525
11526    fn graph_table_rows_without(
11527        store: &CallGraphStore,
11528        table: &str,
11529        excluded_columns: &[&str],
11530    ) -> Vec<String> {
11531        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11532        table_rows_without(&conn, table, excluded_columns)
11533    }
11534
11535    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
11536        table_rows_without(conn, table, &[])
11537    }
11538
11539    fn table_rows_without(
11540        conn: &Connection,
11541        table: &str,
11542        excluded_columns: &[&str],
11543    ) -> Vec<String> {
11544        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
11545        let columns: Vec<String> = conn
11546            .prepare(&format!("PRAGMA table_info({table})"))
11547            .expect("prepare table_info")
11548            .query_map([], |row| row.get::<_, String>(1))
11549            .expect("query table_info")
11550            .collect::<std::result::Result<Vec<String>, _>>()
11551            .expect("collect columns")
11552            .into_iter()
11553            .filter(|column| !excluded_columns.contains(column.as_str()))
11554            .collect();
11555        let sql = format!(
11556            "SELECT {} FROM {table} ORDER BY {}",
11557            columns.join(", "),
11558            columns.join(", ")
11559        );
11560        conn.prepare(&sql)
11561            .expect("prepare table rows")
11562            .query_map([], |row| row_to_strings(row, columns.len()))
11563            .expect("query table rows")
11564            .collect::<std::result::Result<_, _>>()
11565            .expect("collect table rows")
11566    }
11567
11568    fn assert_cold_build_stats_match_except_elapsed(
11569        expected: &ColdBuildStats,
11570        actual: &ColdBuildStats,
11571    ) {
11572        assert_eq!(actual.files, expected.files, "file counts must match");
11573        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
11574        assert_eq!(actual.refs, expected.refs, "ref counts must match");
11575        assert_eq!(actual.edges, expected.edges, "edge counts must match");
11576        assert_eq!(
11577            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
11578            expected
11579                .failed_files
11580                .iter()
11581                .cloned()
11582                .collect::<BTreeSet<_>>(),
11583            "failed file sets must match"
11584        );
11585    }
11586
11587    fn backend_state_rows(conn: &Connection) -> Vec<String> {
11588        conn.prepare(
11589            "SELECT backend, workspace_root, file_path, content_hash, status
11590             FROM backend_file_state
11591             ORDER BY backend, workspace_root, file_path, content_hash, status",
11592        )
11593        .expect("prepare backend rows")
11594        .query_map([], |row| row_to_strings(row, 5))
11595        .expect("query backend rows")
11596        .collect::<std::result::Result<_, _>>()
11597        .expect("collect backend rows")
11598    }
11599
11600    fn secondary_indexes(conn: &Connection) -> Vec<String> {
11601        let mut indexes = Vec::new();
11602        for table in [
11603            "files",
11604            "nodes",
11605            "refs",
11606            "file_dependencies",
11607            "edges",
11608            "dispatch_hints",
11609            "type_ref_names",
11610            "backend_file_state",
11611            "meta",
11612        ] {
11613            let sql = format!("PRAGMA index_list({table})");
11614            let mut stmt = conn.prepare(&sql).expect("prepare index list");
11615            let rows = stmt
11616                .query_map([], |row| row.get::<_, String>(1))
11617                .expect("query index list");
11618            for name in rows {
11619                let name = name.expect("index name");
11620                if name.starts_with("idx_") {
11621                    indexes.push(format!("{table}:{name}"));
11622                }
11623            }
11624        }
11625        indexes.sort();
11626        indexes
11627    }
11628
11629    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
11630        let mut values = Vec::with_capacity(len);
11631        for index in 0..len {
11632            let value = row.get_ref(index)?;
11633            values.push(match value {
11634                rusqlite::types::ValueRef::Null => "NULL".to_string(),
11635                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
11636                rusqlite::types::ValueRef::Real(value) => value.to_string(),
11637                rusqlite::types::ValueRef::Text(value) => {
11638                    String::from_utf8_lossy(value).into_owned()
11639                }
11640                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
11641            });
11642        }
11643        Ok(values.join("\u{1f}"))
11644    }
11645}
11646
11647#[cfg(test)]
11648mod rust_resolution_tests {
11649    use super::*;
11650    use crate::inspect::job::CallgraphSnapshot;
11651    use std::fs;
11652    use tempfile::tempdir;
11653
11654    #[test]
11655    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
11656        let dir = tempdir().expect("tempdir");
11657        let root = dir.path();
11658        write_rust_manifest(root, "scoped-alias-fixture");
11659        write_file(
11660            root,
11661            "src/lib.rs",
11662            r#"pub mod finalization_contract;
11663
11664pub fn run_alias() {
11665    use crate::finalization_contract as fc;
11666    fc::check_mason_contract();
11667}
11668"#,
11669        );
11670        write_file(
11671            root,
11672            "src/finalization_contract.rs",
11673            r#"pub fn check_mason_contract() {}
11674fn planted_dead() {}
11675"#,
11676        );
11677
11678        let (store, snapshot) = cold_build_twice(root);
11679        assert_direct_caller(
11680            &store,
11681            "src/finalization_contract.rs",
11682            "check_mason_contract",
11683            "src/lib.rs",
11684            "run_alias",
11685        );
11686        assert_projected_call(
11687            root,
11688            &snapshot,
11689            "src/finalization_contract.rs",
11690            "check_mason_contract",
11691        );
11692        assert_no_projected_call(
11693            root,
11694            &snapshot,
11695            "src/finalization_contract.rs",
11696            "planted_dead",
11697        );
11698        assert!(
11699            store
11700                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
11701                .expect("planted dead callers")
11702                .is_empty(),
11703            "planted-dead guard should stay without callers"
11704        );
11705    }
11706
11707    #[test]
11708    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
11709        let dir = tempdir().expect("tempdir");
11710        let root = dir.path();
11711        write_rust_manifest(root, "inline-module-fixture");
11712        write_file(
11713            root,
11714            "src/lib.rs",
11715            r#"mod work_graph { fn operations() {} }
11716mod manifest { fn operations() {} }
11717mod audit { fn operations() {} }
11718mod dispatch { fn operations() {} }
11719mod finalization { fn operations() {} }
11720
11721pub fn run_inline_operations() {
11722    work_graph::operations();
11723    manifest::operations();
11724    audit::operations();
11725    dispatch::operations();
11726    finalization::operations();
11727}
11728
11729fn planted_dead() {}
11730"#,
11731        );
11732
11733        let (store, snapshot) = cold_build_twice(root);
11734        for module in [
11735            "work_graph",
11736            "manifest",
11737            "audit",
11738            "dispatch",
11739            "finalization",
11740        ] {
11741            assert_direct_caller(
11742                &store,
11743                "src/lib.rs",
11744                &format!("{module}::operations"),
11745                "src/lib.rs",
11746                "run_inline_operations",
11747            );
11748        }
11749        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
11750        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11751    }
11752
11753    #[test]
11754    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
11755        let dir = tempdir().expect("tempdir");
11756        let root = dir.path();
11757        fs::write(
11758            root.join("Cargo.toml"),
11759            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
11760        )
11761        .expect("write workspace manifest");
11762        write_file(
11763            root,
11764            "crates/but-action/Cargo.toml",
11765            r#"[package]
11766name = "but-action"
11767version = "0.1.0"
11768edition = "2021"
11769"#,
11770        );
11771        write_file(
11772            root,
11773            "crates/but-action/src/lib.rs",
11774            "mod action;\npub use action::{list_actions};\n",
11775        );
11776        write_file(
11777            root,
11778            "crates/but-action/src/action.rs",
11779            "pub fn list_actions() {}\nfn planted_dead() {}\n",
11780        );
11781        write_file(
11782            root,
11783            "crates/app/Cargo.toml",
11784            r#"[package]
11785name = "app"
11786version = "0.1.0"
11787edition = "2021"
11788"#,
11789        );
11790        write_file(
11791            root,
11792            "crates/app/src/lib.rs",
11793            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
11794        );
11795
11796        let (store, snapshot) = cold_build_twice(root);
11797        assert_direct_caller(
11798            &store,
11799            "crates/but-action/src/action.rs",
11800            "list_actions",
11801            "crates/app/src/lib.rs",
11802            "run_actions",
11803        );
11804        assert!(
11805            store
11806                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
11807                .expect("lib reexport callers")
11808                .is_empty(),
11809            "call should target the reexported source function, not lib.rs"
11810        );
11811        assert_projected_call(
11812            root,
11813            &snapshot,
11814            "crates/but-action/src/action.rs",
11815            "list_actions",
11816        );
11817        assert_no_projected_call(
11818            root,
11819            &snapshot,
11820            "crates/but-action/src/action.rs",
11821            "planted_dead",
11822        );
11823    }
11824
11825    #[test]
11826    fn rust_generic_self_turbofish_method_dispatch_resolves() {
11827        let dir = tempdir().expect("tempdir");
11828        let root = dir.path();
11829        write_rust_manifest(root, "generic-self-fixture");
11830        write_file(
11831            root,
11832            "src/lib.rs",
11833            r#"pub struct Matcher;
11834
11835impl Matcher {
11836    pub fn run(&self) -> bool {
11837        self.fuzzy_match_optimal::<usize>("needle")
11838    }
11839
11840    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
11841        let _ = std::marker::PhantomData::<T>;
11842        true
11843    }
11844
11845    fn planted_dead(&self) {}
11846}
11847
11848pub fn entry() -> bool {
11849    let matcher = Matcher;
11850    matcher.run()
11851}
11852"#,
11853        );
11854
11855        let (store, snapshot) = cold_build_twice(root);
11856        assert_direct_caller(
11857            &store,
11858            "src/lib.rs",
11859            "Matcher::fuzzy_match_optimal",
11860            "src/lib.rs",
11861            "Matcher::run",
11862        );
11863        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
11864        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
11865    }
11866
11867    #[test]
11868    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
11869        let dir = tempdir().expect("tempdir");
11870        let root = dir.path();
11871        write_rust_manifest(root, "manifest-operations-fixture");
11872        write_file(
11873            root,
11874            "src/main.rs",
11875            r#"mod dispatch;
11876use dispatch::{manifest_operations};
11877
11878fn main() {
11879    manifest_operations();
11880}
11881"#,
11882        );
11883        write_file(
11884            root,
11885            "src/dispatch.rs",
11886            r#"mod work_graph { fn operations() {} }
11887mod manifest { fn operations() {} }
11888mod audit { fn operations() {} }
11889mod descriptor { fn operations() {} }
11890mod writer { fn operations() {} }
11891
11892pub fn manifest_operations() {
11893    manifest::operations();
11894}
11895
11896pub fn work_graph_operations() {
11897    work_graph::operations();
11898}
11899
11900pub fn audit_operations() {
11901    audit::operations();
11902}
11903
11904pub fn descriptor_operations() {
11905    descriptor::operations();
11906}
11907
11908pub fn writer_operations() {
11909    writer::operations();
11910}
11911
11912fn planted_dead() {}
11913"#,
11914        );
11915
11916        let (store, snapshot) = cold_build_twice(root);
11917        assert_direct_caller(
11918            &store,
11919            "src/dispatch.rs",
11920            "manifest_operations",
11921            "src/main.rs",
11922            "main",
11923        );
11924        assert_direct_caller(
11925            &store,
11926            "src/dispatch.rs",
11927            "manifest::operations",
11928            "src/dispatch.rs",
11929            "manifest_operations",
11930        );
11931        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
11932        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
11933        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
11934    }
11935
11936    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
11937        let files = rust_files(root);
11938        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
11939            .expect("open first store");
11940        first.cold_build(&files).expect("first cold build");
11941        let first_snapshot =
11942            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
11943
11944        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
11945            .expect("open second store");
11946        second.cold_build(&files).expect("second cold build");
11947        let second_snapshot =
11948            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
11949
11950        assert_eq!(
11951            projection_rows(&first_snapshot),
11952            projection_rows(&second_snapshot),
11953            "cold-build projection should be deterministic"
11954        );
11955        (first, first_snapshot)
11956    }
11957
11958    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
11959        let mut rows = Vec::new();
11960        for export in &snapshot.exported_symbols {
11961            rows.push(format!(
11962                "export\t{}\t{}\t{}\t{}",
11963                export.file.display(),
11964                export.symbol,
11965                export.kind,
11966                export.line
11967            ));
11968        }
11969        for call in &snapshot.outbound_calls {
11970            rows.push(format!(
11971                "call\t{}\t{}\t{}\t{}\t{}",
11972                call.caller_file.display(),
11973                call.caller_symbol,
11974                call.target,
11975                call.line,
11976                call.provenance
11977            ));
11978        }
11979        for file in &snapshot.entry_points {
11980            rows.push(format!("entry_file\t{}", file.display()));
11981        }
11982        for (file, symbols) in &snapshot.entry_point_symbols {
11983            for symbol in symbols {
11984                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
11985            }
11986        }
11987        rows.sort();
11988        rows
11989    }
11990
11991    fn assert_direct_caller(
11992        store: &CallGraphStore,
11993        target_rel: &str,
11994        target_symbol: &str,
11995        caller_rel: &str,
11996        caller_symbol: &str,
11997    ) {
11998        let callers = store
11999            .direct_callers_of(Path::new(target_rel), target_symbol)
12000            .unwrap_or_else(|error| {
12001                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
12002            });
12003        assert!(
12004            callers.iter().any(|site| {
12005                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
12006            }),
12007            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
12008        );
12009    }
12010
12011    fn assert_projected_call(
12012        root: &Path,
12013        snapshot: &CallgraphSnapshot,
12014        target_rel: &str,
12015        symbol: &str,
12016    ) {
12017        let target = projected_target(root, target_rel, symbol);
12018        assert!(
12019            snapshot.outbound_calls.iter().any(|call| {
12020                call.target == target
12021                    || call.target.starts_with(&format!(
12022                        "{target}{}",
12023                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12024                    ))
12025            }),
12026            "expected projected call to {target}; calls: {:#?}",
12027            snapshot.outbound_calls
12028        );
12029    }
12030
12031    fn assert_no_projected_call(
12032        root: &Path,
12033        snapshot: &CallgraphSnapshot,
12034        target_rel: &str,
12035        symbol: &str,
12036    ) {
12037        let target = projected_target(root, target_rel, symbol);
12038        assert!(
12039            snapshot.outbound_calls.iter().all(|call| {
12040                call.target != target
12041                    && !call.target.starts_with(&format!(
12042                        "{target}{}",
12043                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12044                    ))
12045            }),
12046            "did not expect projected call to {target}; calls: {:#?}",
12047            snapshot.outbound_calls
12048        );
12049    }
12050
12051    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
12052        let path = fs::canonicalize(root.join(target_rel)).expect("canonical target path");
12053        format!("{}::{symbol}", path.display())
12054    }
12055
12056    fn write_rust_manifest(root: &Path, name: &str) {
12057        write_file(
12058            root,
12059            "Cargo.toml",
12060            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
12061        );
12062    }
12063
12064    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
12065        let path = root.join(rel_path);
12066        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
12067        fs::write(&path, source).expect("write fixture file");
12068        path
12069    }
12070
12071    fn rust_files(root: &Path) -> Vec<PathBuf> {
12072        let mut files = Vec::new();
12073        collect_rust_files(root, &mut files);
12074        files.sort();
12075        files
12076    }
12077
12078    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
12079        for entry in fs::read_dir(dir).expect("read fixture dir") {
12080            let entry = entry.expect("read fixture entry");
12081            let path = entry.path();
12082            if path.is_dir() {
12083                let name = path
12084                    .file_name()
12085                    .and_then(|name| name.to_str())
12086                    .unwrap_or("");
12087                if !name.starts_with(".store") {
12088                    collect_rust_files(&path, files);
12089                }
12090            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
12091                files.push(path);
12092            }
12093        }
12094    }
12095}
12096
12097#[cfg(test)]
12098mod build_pool_tests {
12099    use super::build_pool_size;
12100
12101    #[test]
12102    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
12103        let size = build_pool_size();
12104        // Never zero, never the full core count, never above the 8 cap — this is
12105        // the starvation guard for the cold-build's all-cores tree-sitter pass.
12106        assert!(size >= 1, "pool size must be at least 1");
12107        assert!(size <= 8, "pool size must be capped at 8, got {size}");
12108
12109        let cores = std::thread::available_parallelism()
12110            .map(|p| p.get())
12111            .unwrap_or(1);
12112        let expected = cores.div_ceil(2).clamp(1, 8);
12113        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
12114    }
12115}
12116
12117#[cfg(test)]
12118mod reexport_resolution_tests {
12119    use super::*;
12120
12121    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
12122        ProjectIndex {
12123            project_root: PathBuf::from("/fixture"),
12124            files: files.into_iter().collect(),
12125            caller_data: HashMap::new(),
12126            workspace_crate_prefixes: std::sync::OnceLock::new(),
12127        }
12128    }
12129
12130    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
12131        DbFileIndex {
12132            lang: None,
12133            exports: HashSet::new(),
12134            default_export: None,
12135            export_aliases: HashMap::new(),
12136            node_by_scoped: HashMap::new(),
12137            node_by_bare: HashMap::new(),
12138            module_targets: HashMap::new(),
12139            reexports: reexport_targets
12140                .iter()
12141                .map(|target| ReexportIndex {
12142                    target_file: Some((*target).to_string()),
12143                    named: HashMap::new(),
12144                    wildcard: true,
12145                })
12146                .collect(),
12147        }
12148    }
12149
12150    /// A dense wildcard re-export cycle (barrel files re-exporting each
12151    /// other) must resolve in O(files), not O(branching^depth). Without the
12152    /// resolver's memoization, resolving a MISSING symbol through this
12153    /// 12-file complete digraph explores ~11^16 paths and this test never
12154    /// finishes: the depth cap bounds path length, not path count, and one
12155    /// such resolution can pin a worker thread at 100% CPU indefinitely.
12156    #[test]
12157    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
12158        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
12159        let files = names
12160            .iter()
12161            .map(|name| {
12162                let targets: Vec<&str> = names
12163                    .iter()
12164                    .filter(|other| *other != name)
12165                    .map(String::as_str)
12166                    .collect();
12167                (name.clone(), barrel_file(&targets))
12168            })
12169            .collect();
12170        let index = barrel_index(files);
12171
12172        assert_eq!(
12173            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
12174            None
12175        );
12176    }
12177
12178    /// Depth-dominance counterexample: the walk first reaches `shared` down a
12179    /// 16-hop chain (no budget left for its leaf), then reaches it again
12180    /// directly at depth 1. Plain visited-set pruning would skip the second
12181    /// visit and lose a resolution the capped resolver finds; the
12182    /// depth-dominance memo revisits because the second arrival is shallower.
12183    #[test]
12184    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
12185        let mut leaf = barrel_file(&[]);
12186        leaf.exports.insert("deep_symbol".to_string());
12187        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
12188        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
12189        // entry's SECOND reexport goes straight to shared.
12190        files.push((
12191            "src/entry.ts".to_string(),
12192            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
12193        ));
12194        for i in 0..15 {
12195            let next = if i == 14 {
12196                "src/shared.ts".to_string()
12197            } else {
12198                format!("src/chain{}.ts", i + 1)
12199            };
12200            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
12201        }
12202        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
12203        files.push(("src/leaf.ts".to_string(), leaf));
12204        let index = barrel_index(files);
12205
12206        assert_eq!(
12207            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
12208            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
12209            "a shallower re-visit must not be pruned by a deeper capped visit"
12210        );
12211    }
12212
12213    #[test]
12214    fn symbol_reachable_through_reexport_cycle_still_resolves() {
12215        let mut leaf = barrel_file(&[]);
12216        leaf.exports.insert("real_symbol".to_string());
12217        let index = barrel_index(vec![
12218            (
12219                "src/a.ts".to_string(),
12220                barrel_file(&["src/b.ts", "src/a.ts"]),
12221            ),
12222            (
12223                "src/b.ts".to_string(),
12224                barrel_file(&["src/a.ts", "src/leaf.ts"]),
12225            ),
12226            ("src/leaf.ts".to_string(), leaf),
12227        ]);
12228
12229        assert_eq!(
12230            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
12231            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
12232        );
12233    }
12234}
12235
12236#[cfg(test)]
12237mod method_dispatch_inference_tests {
12238    use super::*;
12239    use std::fs;
12240    use tempfile::tempdir;
12241
12242    #[test]
12243    fn java_field_receiver_type_selects_declared_class_method() {
12244        let source = r#"class EntryPoint {
12245    private UserService userService;
12246
12247    void handle() {
12248        userService.find();
12249    }
12250}
12251
12252class UserService {
12253    void find() {}
12254}
12255
12256class AuditService {
12257    void find() {}
12258}
12259"#;
12260        let dir = tempdir().expect("temp dir");
12261        let root = dir.path();
12262        write_fixture(root, "src/EntryPoint.java", source);
12263        let reference = reference(
12264            "java",
12265            "src/EntryPoint.java",
12266            "EntryPoint::handle",
12267            "userService",
12268            "find",
12269            line_of(source, "userService.find()"),
12270        );
12271        let mut cache = DispatchSourceCache::new();
12272
12273        let receiver_type =
12274            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
12275        assert_eq!(receiver_type, "UserService");
12276
12277        let candidates = vec![
12278            method_candidate("audit", "AuditService::find"),
12279            method_candidate("user", "UserService::find"),
12280        ];
12281        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
12282            .expect("type candidate");
12283        assert_eq!(selected.scoped_name, "UserService::find");
12284
12285        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
12286        assert!(
12287            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
12288        );
12289    }
12290
12291    #[test]
12292    fn kotlin_property_and_local_value_types_are_inferred() {
12293        let source = r#"class Handler {
12294    private val auditService: AuditService = AuditService()
12295
12296    fun handle() {
12297        auditService.find()
12298        val userService: UserService = UserService()
12299        userService.find()
12300        val billingService = BillingService()
12301        billingService.find()
12302    }
12303}
12304
12305class UserService { fun find() {} }
12306class AuditService { fun find() {} }
12307class BillingService { fun find() {} }
12308"#;
12309        let dir = tempdir().expect("temp dir");
12310        let root = dir.path();
12311        write_fixture(root, "src/Handler.kt", source);
12312        let mut cache = DispatchSourceCache::new();
12313
12314        let audit_ref = reference(
12315            "kotlin",
12316            "src/Handler.kt",
12317            "Handler::handle",
12318            "auditService",
12319            "find",
12320            line_of(source, "auditService.find()"),
12321        );
12322        assert_eq!(
12323            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
12324            Some("AuditService")
12325        );
12326
12327        let user_ref = reference(
12328            "kotlin",
12329            "src/Handler.kt",
12330            "Handler::handle",
12331            "userService",
12332            "find",
12333            line_of(source, "userService.find()"),
12334        );
12335        assert_eq!(
12336            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
12337            Some("UserService")
12338        );
12339
12340        let billing_ref = reference(
12341            "kotlin",
12342            "src/Handler.kt",
12343            "Handler::handle",
12344            "billingService",
12345            "find",
12346            line_of(source, "billingService.find()"),
12347        );
12348        assert_eq!(
12349            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
12350            Some("BillingService")
12351        );
12352    }
12353
12354    #[test]
12355    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
12356        let source = r#"struct Foo { void run(); };
12357struct PointerFoo { void run(); };
12358struct FactoryFoo { void run(); };
12359FactoryFoo makeFactoryFoo();
12360
12361void handle() {
12362    Foo foo;
12363    foo.run();
12364    PointerFoo* pointerFoo = nullptr;
12365    pointerFoo->run();
12366    auto factoryFoo = makeFactoryFoo();
12367    factoryFoo.run();
12368}
12369"#;
12370        let dir = tempdir().expect("temp dir");
12371        let root = dir.path();
12372        write_fixture(root, "src/fixture.cpp", source);
12373        let mut cache = DispatchSourceCache::new();
12374
12375        let foo_ref = reference(
12376            "cpp",
12377            "src/fixture.cpp",
12378            "handle",
12379            "foo",
12380            "run",
12381            line_of(source, "foo.run()"),
12382        );
12383        assert_eq!(
12384            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
12385            Some("Foo")
12386        );
12387
12388        let pointer_ref = reference(
12389            "cpp",
12390            "src/fixture.cpp",
12391            "handle",
12392            "pointerFoo",
12393            "run",
12394            line_of(source, "pointerFoo->run()"),
12395        );
12396        assert_eq!(
12397            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
12398            Some("PointerFoo")
12399        );
12400
12401        let factory_ref = reference(
12402            "cpp",
12403            "src/fixture.cpp",
12404            "handle",
12405            "factoryFoo",
12406            "run",
12407            line_of(source, "factoryFoo.run()"),
12408        );
12409        assert_eq!(
12410            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
12411            Some("FactoryFoo")
12412        );
12413    }
12414
12415    #[test]
12416    fn unknown_java_receiver_still_uses_name_match_fallback() {
12417        let source = r#"class EntryPoint {
12418    void handle() {
12419        service.runSpecial();
12420    }
12421}
12422
12423class OnlyService {
12424    void runSpecial() {}
12425}
12426"#;
12427        let dir = tempdir().expect("temp dir");
12428        let root = dir.path();
12429        write_fixture(root, "src/EntryPoint.java", source);
12430        let reference = reference(
12431            "java",
12432            "src/EntryPoint.java",
12433            "EntryPoint::handle",
12434            "service",
12435            "runSpecial",
12436            line_of(source, "service.runSpecial()"),
12437        );
12438        let mut cache = DispatchSourceCache::new();
12439
12440        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
12441        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
12442        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
12443        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
12444    }
12445
12446    fn reference(
12447        lang: &str,
12448        caller_file: &str,
12449        caller_symbol: &str,
12450        receiver: &str,
12451        method_name: &str,
12452        line: u32,
12453    ) -> NameMatchRef {
12454        NameMatchRef {
12455            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
12456            caller_node: format!("{caller_symbol}:node"),
12457            caller_file: caller_file.to_string(),
12458            caller_symbol: caller_symbol.to_string(),
12459            caller_signature: None,
12460            receiver: receiver.to_string(),
12461            method_name: method_name.to_string(),
12462            colon_dispatch: false,
12463            line,
12464            lang: lang.to_string(),
12465        }
12466    }
12467
12468    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
12469        NameMatchCandidate {
12470            node_id: node_id.to_string(),
12471            file_path: "src/targets.fixture".to_string(),
12472            scoped_name: scoped_name.to_string(),
12473            kind: "method".to_string(),
12474        }
12475    }
12476
12477    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
12478        let path = root.join(rel_path);
12479        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
12480        fs::write(path, source).expect("write fixture");
12481    }
12482
12483    fn line_of(source: &str, needle: &str) -> u32 {
12484        source
12485            .lines()
12486            .position(|line| line.contains(needle))
12487            .map(|index| index as u32 + 1)
12488            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
12489    }
12490}