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
287type WorkspaceCratePrefixes = HashMap<String, String>;
288
289#[derive(Clone, Debug, Default)]
290struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
291
292const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
293
294pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
295    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
296}
297
298#[derive(Clone, Debug, Hash, PartialEq, Eq)]
299struct RefreshRoot {
300    callgraph_dir: PathBuf,
301    project_root: PathBuf,
302}
303
304#[derive(Clone)]
305pub(crate) struct CallgraphRefreshTicket {
306    lifecycle: SubcLifecycleAdmission,
307    generation_flag: Arc<std::sync::atomic::AtomicU64>,
308    expected_generation: u64,
309    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
310    expected_publish_epoch: u64,
311}
312
313impl CallgraphRefreshTicket {
314    pub(crate) fn new(
315        lifecycle: SubcLifecycleAdmission,
316        generation_flag: Arc<std::sync::atomic::AtomicU64>,
317        expected_generation: u64,
318        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
319        expected_publish_epoch: u64,
320    ) -> Self {
321        Self {
322            lifecycle,
323            generation_flag,
324            expected_generation,
325            publish_epoch,
326            expected_publish_epoch,
327        }
328    }
329
330    fn is_current(&self) -> bool {
331        self.lifecycle
332            .is_current(self.generation_flag.as_ref(), self.expected_generation)
333            && self.publish_epoch.current() == self.expected_publish_epoch
334    }
335}
336
337#[derive(Clone)]
338struct RefreshBatch {
339    root: RefreshRoot,
340    paths: BTreeSet<PathBuf>,
341    pending_sinks: Vec<PendingCallGraphStorePaths>,
342    ticket: Option<CallgraphRefreshTicket>,
343}
344
345impl RefreshBatch {
346    fn defer(&self) {
347        for sink in &self.pending_sinks {
348            sink.lock().extend(self.paths.iter().cloned());
349        }
350    }
351
352    fn merge(
353        &mut self,
354        paths: impl IntoIterator<Item = PathBuf>,
355        sink: PendingCallGraphStorePaths,
356        ticket: Option<CallgraphRefreshTicket>,
357    ) {
358        self.paths.extend(paths);
359        if ticket.is_some() {
360            self.ticket = ticket;
361        }
362        if !self
363            .pending_sinks
364            .iter()
365            .any(|existing| Arc::ptr_eq(existing, &sink))
366        {
367            self.pending_sinks.push(sink);
368        }
369    }
370}
371
372#[derive(Default)]
373struct RefreshQueue {
374    order: VecDeque<RefreshRoot>,
375    queued: HashMap<RefreshRoot, RefreshBatch>,
376    active: Option<RefreshBatch>,
377    shutdown_requested: bool,
378}
379
380struct RefreshWorkerShared {
381    queue: Mutex<RefreshQueue>,
382    wake: Condvar,
383}
384
385struct RefreshWorker {
386    shared: Arc<RefreshWorkerShared>,
387    thread: Mutex<Option<JoinHandle<()>>>,
388}
389
390struct RefreshWorkerWatchdog {
391    first_path: PathBuf,
392    batch_len: usize,
393    started: Instant,
394}
395
396impl RefreshWorkerWatchdog {
397    fn start(paths: &[PathBuf]) -> Self {
398        Self {
399            first_path: paths
400                .first()
401                .expect("non-empty callgraph refresh batch has a first path")
402                .clone(),
403            batch_len: paths.len(),
404            started: Instant::now(),
405        }
406    }
407}
408
409impl Drop for RefreshWorkerWatchdog {
410    fn drop(&mut self) {
411        let elapsed = self.started.elapsed();
412        if elapsed < REFRESH_WORKER_WARN_AFTER {
413            return;
414        }
415        let path = if self.batch_len == 1 {
416            self.first_path.display().to_string()
417        } else {
418            format!(
419                "{} (+{} paths)",
420                self.first_path.display(),
421                self.batch_len - 1
422            )
423        };
424        log::warn!(
425            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
426            path,
427            elapsed.as_millis()
428        );
429        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
430            log::warn!(
431                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
432                path,
433                elapsed.as_millis()
434            );
435        }
436    }
437}
438
439impl RefreshWorker {
440    fn spawn() -> Arc<Self> {
441        let shared = Arc::new(RefreshWorkerShared {
442            queue: Mutex::new(RefreshQueue::default()),
443            wake: Condvar::new(),
444        });
445        let thread_shared = Arc::clone(&shared);
446        let thread = std::thread::Builder::new()
447            .name("aft-callgraph-refresh".to_string())
448            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
449            .expect("failed to spawn callgraph refresh worker");
450        Arc::new(Self {
451            shared,
452            thread: Mutex::new(Some(thread)),
453        })
454    }
455
456    fn enqueue(
457        &self,
458        root: RefreshRoot,
459        paths: Vec<PathBuf>,
460        pending_sink: PendingCallGraphStorePaths,
461        ticket: Option<CallgraphRefreshTicket>,
462    ) -> bool {
463        let mut queue = self
464            .shared
465            .queue
466            .lock()
467            .expect("callgraph refresh queue mutex poisoned");
468        if queue.shutdown_requested {
469            pending_sink.lock().extend(paths);
470            return false;
471        }
472        if let Some(batch) = queue.queued.get_mut(&root) {
473            batch.merge(paths, pending_sink, ticket);
474        } else {
475            queue.order.push_back(root.clone());
476            queue.queued.insert(
477                root.clone(),
478                RefreshBatch {
479                    root,
480                    paths: paths.into_iter().collect(),
481                    pending_sinks: vec![pending_sink],
482                    ticket,
483                },
484            );
485        }
486        self.shared.wake.notify_one();
487        true
488    }
489
490    fn shutdown_with_budget(&self, budget: Duration) -> bool {
491        let deadline = Instant::now() + budget;
492        let mut queue = self
493            .shared
494            .queue
495            .lock()
496            .expect("callgraph refresh queue mutex poisoned");
497        queue.shutdown_requested = true;
498        self.shared.wake.notify_one();
499        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
500            let remaining = deadline.saturating_duration_since(Instant::now());
501            let (next, _) = self
502                .shared
503                .wake
504                .wait_timeout(queue, remaining)
505                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
506            queue = next;
507        }
508        let drained = queue.active.is_none() && queue.order.is_empty();
509        if !drained {
510            if let Some(active) = queue.active.as_ref() {
511                active.defer();
512            }
513            for batch in queue.queued.values() {
514                batch.defer();
515            }
516            queue.order.clear();
517            queue.queued.clear();
518        }
519        drop(queue);
520
521        if drained {
522            if let Some(thread) = self
523                .thread
524                .lock()
525                .expect("callgraph refresh worker thread mutex poisoned")
526                .take()
527            {
528                let _ = thread.join();
529            }
530        }
531        drained
532    }
533}
534
535static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
536
537pub fn enqueue_callgraph_store_refresh(
538    callgraph_dir: PathBuf,
539    project_root: PathBuf,
540    paths: Vec<PathBuf>,
541    pending_sink: PendingCallGraphStorePaths,
542) -> bool {
543    enqueue_callgraph_store_refresh_inner(callgraph_dir, project_root, paths, pending_sink, None)
544}
545
546pub(crate) fn enqueue_callgraph_store_refresh_fenced(
547    callgraph_dir: PathBuf,
548    project_root: PathBuf,
549    paths: Vec<PathBuf>,
550    pending_sink: PendingCallGraphStorePaths,
551    ticket: CallgraphRefreshTicket,
552) -> bool {
553    enqueue_callgraph_store_refresh_inner(
554        callgraph_dir,
555        project_root,
556        paths,
557        pending_sink,
558        Some(ticket),
559    )
560}
561
562fn enqueue_callgraph_store_refresh_inner(
563    callgraph_dir: PathBuf,
564    project_root: PathBuf,
565    paths: Vec<PathBuf>,
566    pending_sink: PendingCallGraphStorePaths,
567    ticket: Option<CallgraphRefreshTicket>,
568) -> bool {
569    if paths.is_empty() {
570        return true;
571    }
572    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
573    let worker = {
574        let mut worker = slot
575            .lock()
576            .expect("callgraph refresh worker mutex poisoned");
577        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
578    };
579    worker.enqueue(
580        RefreshRoot {
581            callgraph_dir,
582            project_root,
583        },
584        paths,
585        pending_sink,
586        ticket,
587    )
588}
589
590pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
591    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
592}
593
594#[doc(hidden)]
595pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
596    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
597    let worker = slot
598        .lock()
599        .expect("callgraph refresh worker mutex poisoned")
600        .clone();
601    let Some(worker) = worker else {
602        return true;
603    };
604    let drained = worker.shutdown_with_budget(budget);
605    if drained {
606        let mut current = slot
607            .lock()
608            .expect("callgraph refresh worker mutex poisoned");
609        if current
610            .as_ref()
611            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
612        {
613            *current = None;
614        }
615    }
616    drained
617}
618
619fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
620    // The worker owns these caches so maps are shared only by refreshes for the
621    // same canonical root and disappear when the worker shuts down.
622    let mut workspace_crate_prefixes = HashMap::new();
623    loop {
624        let batch = {
625            let mut queue = shared
626                .queue
627                .lock()
628                .expect("callgraph refresh queue mutex poisoned");
629            loop {
630                if let Some(root) = queue.order.pop_front() {
631                    let batch = queue
632                        .queued
633                        .remove(&root)
634                        .expect("queued callgraph refresh root has a batch");
635                    queue.active = Some(batch.clone());
636                    break batch;
637                }
638                if queue.shutdown_requested {
639                    return;
640                }
641                queue = shared
642                    .wake
643                    .wait(queue)
644                    .expect("callgraph refresh queue mutex poisoned while waiting");
645            }
646        };
647
648        process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
649
650        let mut queue = shared
651            .queue
652            .lock()
653            .expect("callgraph refresh queue mutex poisoned");
654        queue.active = None;
655        shared.wake.notify_all();
656    }
657}
658
659fn process_callgraph_refresh_batch(
660    batch: &RefreshBatch,
661    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
662) {
663    // A manifest event is an invalidation signal, not a source file to parse.
664    // Drop the root's map even for a superseded batch: the filesystem changed,
665    // and a later configure must never inherit crate membership from before it.
666    if batch
667        .paths
668        .iter()
669        .any(|path| invalidates_workspace_crate_prefix_cache(path))
670    {
671        workspace_crate_prefixes.remove(&batch.root);
672    }
673
674    if batch
675        .ticket
676        .as_ref()
677        .is_some_and(|ticket| !ticket.is_current())
678    {
679        // Superseded before starting: park the paths so the next configure's
680        // pending replay (or unbind cleanup) decides their fate.
681        batch.defer();
682        return;
683    }
684    let paths = batch
685        .paths
686        .iter()
687        .filter(|path| crate::parser::detect_language(path).is_some())
688        .cloned()
689        .collect::<Vec<_>>();
690    if paths.is_empty() {
691        return;
692    }
693    let workspace_crate_prefix_cache =
694        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
695    let _watchdog = RefreshWorkerWatchdog::start(&paths);
696    let store = match CallGraphStore::open_ready(
697        batch.root.callgraph_dir.clone(),
698        batch.root.project_root.clone(),
699    ) {
700        Ok(Some(store)) => store,
701        Ok(None) => {
702            batch.defer();
703            return;
704        }
705        Err(error) => {
706            batch.defer();
707            crate::slog_warn!(
708                "callgraph store writer open failed during refresh; deferred paths: {}",
709                error
710            );
711            return;
712        }
713    };
714
715    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
716    note_refresh_worker_call_for_test(&batch.root.project_root);
717    #[cfg(test)]
718    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
719        let _ = gate.held_tx.send(());
720        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
721    }
722    if !test_seam.delay.is_zero() {
723        std::thread::sleep(test_seam.delay);
724    }
725    if batch
726        .ticket
727        .as_ref()
728        .is_some_and(|ticket| !ticket.is_current())
729    {
730        batch.defer();
731        return;
732    }
733    let refresh_result = if test_seam.fail_refresh {
734        Err(CallGraphStoreError::Unavailable(
735            "injected refresh worker failure".to_string(),
736        ))
737    } else if let Some(ticket) = &batch.ticket {
738        with_publish_epoch(
739            ticket.publish_epoch.clone(),
740            ticket.expected_publish_epoch,
741            || {
742                with_refresh_commit_admission(
743                    ticket.lifecycle.clone(),
744                    Arc::clone(&ticket.generation_flag),
745                    ticket.expected_generation,
746                    || {
747                        store
748                            .refresh_files_with_workspace_crate_prefix_cache(
749                                &paths,
750                                workspace_crate_prefix_cache.clone(),
751                            )
752                            .map(|_| ())
753                    },
754                )
755            },
756        )
757    } else {
758        store
759            .refresh_files_with_workspace_crate_prefix_cache(
760                &paths,
761                workspace_crate_prefix_cache.clone(),
762            )
763            .map(|_| ())
764    };
765    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
766        // The commit lost the fence race: a newer configure or publication
767        // owns the store now. Defer instead of stale-marking — the paths were
768        // never committed, and the replacement generation re-indexes them.
769        batch.defer();
770        return;
771    }
772    if let Err(error) = refresh_result {
773        crate::slog_warn!("callgraph store refresh failed: {}", error);
774        match store.mark_files_stale(&paths) {
775            Ok(marked) => {
776                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
777                crate::slog_warn!(
778                    "marked {} callgraph store file(s) stale after refresh failure",
779                    marked.len()
780                );
781            }
782            Err(mark_error) => crate::slog_warn!(
783                "failed to mark callgraph store files stale after refresh failure: {}",
784                mark_error
785            ),
786        }
787    } else {
788        crate::logging::note_callgraph_invalidations(paths.len());
789    }
790}
791
792fn workspace_crate_prefix_cache_for_root(
793    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
794    root: &RefreshRoot,
795) -> WorkspaceCratePrefixCache {
796    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
797        // Eviction only costs a future rebuild; it cannot make resolution stale.
798        if let Some(evicted) = caches.keys().next().cloned() {
799            caches.remove(&evicted);
800        }
801    }
802    caches.entry(root.clone()).or_default().clone()
803}
804
805#[derive(Clone, Copy, Default)]
806struct RefreshWorkerTestSeam {
807    delay: Duration,
808    fail_refresh: bool,
809    refresh_calls: usize,
810    stale_marks: usize,
811}
812
813static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
814    OnceLock::new();
815
816#[cfg(test)]
817struct RefreshWorkerTestGate {
818    held_tx: crossbeam_channel::Sender<()>,
819    release_rx: crossbeam_channel::Receiver<()>,
820}
821
822#[cfg(test)]
823static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
824    OnceLock::new();
825
826#[cfg(test)]
827fn install_refresh_worker_test_gate(
828    project_root: PathBuf,
829) -> (
830    crossbeam_channel::Receiver<()>,
831    crossbeam_channel::Sender<()>,
832) {
833    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
834    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
835    REFRESH_WORKER_TEST_GATES
836        .get_or_init(|| Mutex::new(HashMap::new()))
837        .lock()
838        .expect("callgraph refresh test gate mutex poisoned")
839        .insert(
840            project_root,
841            RefreshWorkerTestGate {
842                held_tx,
843                release_rx,
844            },
845        );
846    (held_rx, release_tx)
847}
848
849#[cfg(test)]
850fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
851    REFRESH_WORKER_TEST_GATES
852        .get_or_init(|| Mutex::new(HashMap::new()))
853        .lock()
854        .expect("callgraph refresh test gate mutex poisoned")
855        .remove(project_root)
856}
857
858fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
859    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
860        return RefreshWorkerTestSeam::default();
861    };
862    seams
863        .lock()
864        .expect("callgraph refresh test seam mutex poisoned")
865        .get(project_root)
866        .copied()
867        .unwrap_or_default()
868}
869
870fn note_refresh_worker_call_for_test(project_root: &Path) {
871    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
872        if let Some(seam) = seams
873            .lock()
874            .expect("callgraph refresh test seam mutex poisoned")
875            .get_mut(project_root)
876        {
877            seam.refresh_calls += 1;
878        }
879    }
880}
881
882fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
883    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
884        if let Some(seam) = seams
885            .lock()
886            .expect("callgraph refresh test seam mutex poisoned")
887            .get_mut(project_root)
888        {
889            seam.stale_marks += 1;
890        }
891    }
892}
893
894#[doc(hidden)]
895pub fn set_callgraph_refresh_worker_test_seam(
896    project_root: PathBuf,
897    delay: Duration,
898    fail_refresh: bool,
899) {
900    REFRESH_WORKER_TEST_SEAMS
901        .get_or_init(|| Mutex::new(HashMap::new()))
902        .lock()
903        .expect("callgraph refresh test seam mutex poisoned")
904        .insert(
905            project_root,
906            RefreshWorkerTestSeam {
907                delay,
908                fail_refresh,
909                ..RefreshWorkerTestSeam::default()
910            },
911        );
912}
913
914#[doc(hidden)]
915pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
916    let seam = refresh_worker_test_seam(project_root);
917    (seam.refresh_calls, seam.stale_marks)
918}
919
920#[doc(hidden)]
921pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
922    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
923        seams
924            .lock()
925            .expect("callgraph refresh test seam mutex poisoned")
926            .remove(project_root);
927    }
928}
929
930#[derive(Debug)]
931pub struct CallGraphStore {
932    project_root: PathBuf,
933    project_key: String,
934    /// The concrete on-disk DB file this store opened. With the generation
935    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
936    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
937    sqlite_path: PathBuf,
938    /// Root-keyed directory whose pointer controls this store. For a legacy
939    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
940    /// newly published root-keyed generation invalidates the fallback reader.
941    publication_dir: PathBuf,
942    /// True only when the root-keyed read path opened data from a legacy
943    /// harness partition. Writer-capable callers use this to schedule migration
944    /// without making read-only/worktree callers acquire a writer lease.
945    legacy_fallback: bool,
946    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
947    /// or `None` when it opened the legacy single-file DB. Used to detect when
948    /// another process has published a newer generation so this process can
949    /// drop its connection and reopen (see `current_generation`).
950    generation: Option<String>,
951    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
952    read_marker: Option<crate::root_cache::ReadMarker>,
953    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
954    // Failed validations are not cached, so a later successful build remains visible.
955    database_ready: AtomicBool,
956    conn: Mutex<Connection>,
957}
958
959#[derive(Debug)]
960pub struct ReadonlyCallGraphStore {
961    inner: CallGraphStore,
962}
963
964pub trait CallGraphRead {
965    fn project_root(&self) -> &Path;
966    fn project_key(&self) -> &str;
967    fn sqlite_path(&self) -> &Path;
968    fn is_current(&self) -> bool;
969    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
970    fn indexed_file_count(&self) -> Result<usize>;
971    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
972    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
973    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
974    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
975    fn direct_caller_counts_of(
976        &self,
977        targets: &[(String, String)],
978    ) -> Result<HashMap<(String, String), usize>>;
979    fn outgoing_calls_for_symbols(
980        &self,
981        sources: &[(String, String)],
982    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
983    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
984        -> Result<StoreCallersResult>;
985    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
986    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
987    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
988    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
989    fn call_tree(
990        &self,
991        file_rel: &Path,
992        symbol: &str,
993        depth: usize,
994    ) -> Result<callgraph::CallTreeNode>;
995    fn trace_to(
996        &self,
997        file_rel: &Path,
998        symbol: &str,
999        max_depth: usize,
1000    ) -> Result<callgraph::TraceToResult>;
1001    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1002    fn trace_to_symbol(
1003        &self,
1004        file_rel: &Path,
1005        symbol: &str,
1006        to_symbol: &str,
1007        to_file: Option<&Path>,
1008        max_depth: usize,
1009    ) -> Result<callgraph::TraceToSymbolResult>;
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq)]
1013enum OpenRootRepair {
1014    None,
1015    ReRooted,
1016    NeedsRebuild {
1017        previous_roots: Vec<String>,
1018        current_root: String,
1019        reason: String,
1020    },
1021}
1022
1023struct OpenedStore {
1024    store: CallGraphStore,
1025    root_repair: OpenRootRepair,
1026}
1027
1028#[derive(Clone, Debug)]
1029struct LegacyCallgraphPartition {
1030    harness: String,
1031    dir: PathBuf,
1032    key: String,
1033    bytes: u64,
1034    freshness: Option<SystemTime>,
1035}
1036
1037#[derive(Clone, Debug)]
1038struct LegacyCallgraphTarget {
1039    partition: LegacyCallgraphPartition,
1040    sqlite_path: PathBuf,
1041    generation: Option<String>,
1042    source_bytes: u64,
1043    source_blake3: String,
1044}
1045
1046#[derive(Clone, Debug)]
1047struct SourceFingerprint {
1048    bytes: u64,
1049    blake3: String,
1050}
1051
1052#[derive(Clone, Debug)]
1053struct PublishedLegacyMigration {
1054    generation: String,
1055    migrated_bytes: u64,
1056}
1057
1058#[derive(Debug, Clone)]
1059pub struct ColdBuildStats {
1060    pub files: usize,
1061    pub nodes: usize,
1062    pub refs: usize,
1063    pub edges: usize,
1064    pub failed_files: Vec<String>,
1065    pub elapsed_ms: u128,
1066}
1067
1068#[derive(Debug, Clone)]
1069pub struct IncrementalStats {
1070    pub changed_files: Vec<String>,
1071    pub surface_changed: Vec<String>,
1072    pub deleted_files: Vec<String>,
1073    pub dependency_selected_refs: usize,
1074    pub refreshed_own_files: usize,
1075}
1076
1077/// Phase timings for the copy-based incremental refresh benchmark.
1078#[doc(hidden)]
1079#[derive(Debug, Clone, Default, PartialEq, Eq)]
1080pub struct RefreshFilesProfile {
1081    pub parse: Duration,
1082    pub dependency_selection: Duration,
1083    pub row_deletes: Duration,
1084    pub row_inserts: Duration,
1085    pub dependent_parse: Duration,
1086    pub index_load: Duration,
1087    pub ref_resolution: Duration,
1088    pub method_dispatch: Duration,
1089    pub commit: Duration,
1090    pub total: Duration,
1091}
1092
1093impl RefreshFilesProfile {
1094    pub fn report(&self) -> String {
1095        format!(
1096            "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",
1097            self.parse.as_millis(),
1098            self.dependency_selection.as_millis(),
1099            self.row_deletes.as_millis(),
1100            self.row_inserts.as_millis(),
1101            self.dependent_parse.as_millis(),
1102            self.index_load.as_millis(),
1103            self.ref_resolution.as_millis(),
1104            self.method_dispatch.as_millis(),
1105            self.commit.as_millis(),
1106            self.total.as_millis(),
1107        )
1108    }
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1112pub struct StoredEdge {
1113    pub source_file: String,
1114    pub source_symbol: String,
1115    pub target_file: String,
1116    pub target_symbol: String,
1117    pub kind: String,
1118    pub line: u32,
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct StoreNode {
1123    node_id: String,
1124    pub file: String,
1125    pub symbol: String,
1126    pub name: String,
1127    pub kind: String,
1128    pub line: u32,
1129    pub end_line: u32,
1130    pub signature: Option<String>,
1131    pub exported: bool,
1132    pub is_entry_point: bool,
1133    pub lang: LangId,
1134}
1135
1136#[cfg(test)]
1137impl StoreNode {
1138    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1139        Self {
1140            node_id: format!("{file}:{symbol}"),
1141            file: file.to_string(),
1142            symbol: symbol.to_string(),
1143            name: symbol.to_string(),
1144            kind: "function".to_string(),
1145            line: 1,
1146            end_line: 1,
1147            signature: None,
1148            exported: is_entry_point,
1149            is_entry_point,
1150            lang: LangId::TypeScript,
1151        }
1152    }
1153}
1154
1155#[derive(Debug, Clone, PartialEq, Eq)]
1156pub struct StoreCallSite {
1157    pub caller: StoreNode,
1158    pub target_file: String,
1159    pub target_symbol: String,
1160    pub target: Option<StoreNode>,
1161    pub line: u32,
1162    pub byte_start: usize,
1163    pub byte_end: usize,
1164    pub resolved: bool,
1165    pub provenance: String,
1166}
1167
1168impl StoreCallSite {
1169    pub fn approximate(&self) -> bool {
1170        self.provenance == PROVENANCE_NAME_MATCH
1171    }
1172
1173    pub fn resolved_by(&self) -> &str {
1174        &self.provenance
1175    }
1176
1177    pub fn supplemental_resolution(&self) -> Option<&str> {
1178        match self.provenance.as_str() {
1179            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
1180            _ => None,
1181        }
1182    }
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq)]
1186pub struct StoreUnresolvedCall {
1187    pub caller: StoreNode,
1188    pub symbol: String,
1189    pub full_ref: Option<String>,
1190    pub line: u32,
1191    pub byte_start: usize,
1192    pub byte_end: usize,
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct StoreCallersResult {
1197    pub target: StoreNode,
1198    pub callers: Vec<StoreCallSite>,
1199    pub scanned_files: usize,
1200    pub depth_limited: bool,
1201    pub truncated: usize,
1202}
1203
1204#[derive(Debug, Clone, PartialEq, Eq)]
1205pub struct StoreImpactCaller {
1206    pub site: StoreCallSite,
1207    pub signature: Option<String>,
1208    pub is_entry_point: bool,
1209    pub call_expression: Option<String>,
1210    pub parameters: Vec<String>,
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1214pub struct StoreImpactResult {
1215    pub target: StoreNode,
1216    pub parameters: Vec<String>,
1217    pub callers: Vec<StoreImpactCaller>,
1218    pub depth_limited: bool,
1219    pub truncated: usize,
1220}
1221
1222#[derive(Debug, Clone)]
1223struct ExtractFailure {
1224    rel_path: String,
1225    freshness: Option<FileFreshness>,
1226}
1227
1228#[derive(Debug, Clone)]
1229struct BuildExtractsResult {
1230    extracts: Vec<FileExtract>,
1231    failures: Vec<ExtractFailure>,
1232}
1233
1234#[derive(Debug, Clone)]
1235enum StoreForwardCall {
1236    Resolved(StoreCallSite),
1237    Unresolved(StoreUnresolvedCall),
1238}
1239
1240impl StoreForwardCall {
1241    fn byte_start(&self) -> usize {
1242        match self {
1243            Self::Resolved(site) => site.byte_start,
1244            Self::Unresolved(call) => call.byte_start,
1245        }
1246    }
1247
1248    fn line(&self) -> u32 {
1249        match self {
1250            Self::Resolved(site) => site.line,
1251            Self::Unresolved(call) => call.line,
1252        }
1253    }
1254}
1255
1256#[derive(Debug, Clone)]
1257struct FileExtract {
1258    rel_path: String,
1259    freshness: FileFreshness,
1260    lang: LangId,
1261    data: FileCallData,
1262    nodes: Vec<NodeRecord>,
1263    raw_refs: Vec<RawRef>,
1264    dispatch_hints: Vec<DispatchHint>,
1265    surface_fingerprint: String,
1266}
1267
1268#[derive(Debug, Clone)]
1269struct NodeRecord {
1270    id: String,
1271    file_path: String,
1272    name: String,
1273    scoped_name: String,
1274    kind: String,
1275    range: Range,
1276    range_ordinal: u32,
1277    signature: Option<String>,
1278    exported: bool,
1279    is_default_export: bool,
1280    is_type_like: bool,
1281    is_callgraph_entry_point: bool,
1282}
1283
1284#[derive(Debug, Clone)]
1285struct RawRef {
1286    ref_id: String,
1287    caller_node: Option<String>,
1288    caller_symbol: Option<String>,
1289    caller_file: String,
1290    kind: String,
1291    short_name: Option<String>,
1292    full_ref: Option<String>,
1293    module_path: Option<String>,
1294    import_kind: Option<String>,
1295    local_name: Option<String>,
1296    requested_name: Option<String>,
1297    namespace_alias: Option<String>,
1298    wildcard: bool,
1299    line: u32,
1300    byte_start: usize,
1301    byte_end: usize,
1302    dependencies: BTreeSet<String>,
1303}
1304
1305#[derive(Debug, Clone)]
1306struct ResolvedRef {
1307    raw: RawRef,
1308    status: String,
1309    target_node: Option<String>,
1310    target_file: Option<String>,
1311    target_symbol: Option<String>,
1312    dependencies: BTreeSet<String>,
1313    edge: Option<EdgeRecord>,
1314}
1315
1316#[derive(Debug, Clone)]
1317struct EdgeRecord {
1318    edge_id: String,
1319    source_node: String,
1320    target_node: Option<String>,
1321    target_file: String,
1322    target_symbol: String,
1323    kind: String,
1324    line: u32,
1325}
1326
1327#[derive(Debug, Clone)]
1328struct DispatchHint {
1329    id: String,
1330    method_name: String,
1331    caller_node: String,
1332    file: String,
1333    line: u32,
1334    byte_start: usize,
1335    byte_end: usize,
1336}
1337
1338#[derive(Debug, Clone)]
1339struct NameMatchRef {
1340    ref_id: String,
1341    caller_node: String,
1342    caller_file: String,
1343    caller_symbol: String,
1344    caller_signature: Option<String>,
1345    receiver: String,
1346    method_name: String,
1347    colon_dispatch: bool,
1348    line: u32,
1349    lang: String,
1350}
1351
1352#[derive(Debug, Clone)]
1353struct NameMatchCandidate {
1354    node_id: String,
1355    file_path: String,
1356    scoped_name: String,
1357    kind: String,
1358}
1359
1360#[derive(Debug, Clone)]
1361struct FileRow {
1362    surface_fingerprint: String,
1363    freshness: FileFreshness,
1364}
1365
1366#[derive(Debug, Clone)]
1367struct DbFileIndex {
1368    lang: Option<LangId>,
1369    exports: HashSet<String>,
1370    default_export: Option<String>,
1371    export_aliases: HashMap<String, String>,
1372    node_by_scoped: HashMap<String, String>,
1373    node_by_bare: HashMap<String, String>,
1374    module_targets: HashMap<String, Option<String>>,
1375    reexports: Vec<ReexportIndex>,
1376}
1377
1378#[derive(Debug, Clone)]
1379struct ReexportIndex {
1380    target_file: Option<String>,
1381    named: HashMap<String, String>,
1382    wildcard: bool,
1383}
1384
1385#[derive(Debug, Clone)]
1386struct ProjectIndex<'a> {
1387    project_root: PathBuf,
1388    files: HashMap<String, DbFileIndex>,
1389    caller_data: HashMap<String, &'a FileCallData>,
1390    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
1391    /// watcher events replace the cache before another batch can resolve refs.
1392    /// Cold/direct refreshes use a private cache so each refresh builds and uses
1393    /// its own workspace mapping.
1394    workspace_crate_prefixes: WorkspaceCratePrefixCache,
1395}
1396
1397impl ProjectIndex<'_> {
1398    /// Resolve a crate name to its `src` prefix, building the workspace map on
1399    /// first use. Refresh-worker indexes for the same root share this cache.
1400    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
1401        self.workspace_crate_prefixes
1402            .0
1403            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
1404            .get(crate_name)
1405            .cloned()
1406    }
1407}
1408
1409impl CallGraphStore {
1410    pub fn open_if_enabled(
1411        options: CallGraphStoreOptions,
1412        callgraph_dir: PathBuf,
1413        project_root: PathBuf,
1414    ) -> Result<Option<Self>> {
1415        if !options.enabled {
1416            return Ok(None);
1417        }
1418        Self::open(callgraph_dir, project_root).map(Some)
1419    }
1420
1421    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
1422        let project_key = crate::search_index::artifact_cache_key(&project_root);
1423        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1424        else {
1425            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1426                Some(store) => Ok(store.into_inner()),
1427                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key),
1428            };
1429        };
1430        std::fs::create_dir_all(&callgraph_dir)?;
1431        // Resolve the current generation via the pointer (falling back to the
1432        // legacy single-file DB). If nothing is published yet, open the legacy
1433        // path so a brand-new store still gets a writable DB + schema.
1434        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
1435            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
1436        let OpenedStore { store, root_repair } = Self::open_at_path(
1437            project_root.clone(),
1438            project_key,
1439            sqlite_path,
1440            generation,
1441            true,
1442            Some(Arc::clone(&writer_lease)),
1443            None,
1444        )?;
1445        match root_repair {
1446            OpenRootRepair::NeedsRebuild { .. } => {
1447                log_root_repair_rebuild(&root_repair);
1448                drop(store);
1449                drop(writer_lease);
1450                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1451                let (store, _stats) =
1452                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1453                Ok(store)
1454            }
1455            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
1456        }
1457    }
1458
1459    pub fn open_readonly(
1460        callgraph_dir: PathBuf,
1461        project_root: PathBuf,
1462    ) -> Result<Option<ReadonlyCallGraphStore>> {
1463        let project_key = crate::search_index::artifact_cache_key(&project_root);
1464        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1465        {
1466            let conn = open_readonly_connection(&sqlite_path)?;
1467            if !database_ready(&conn).unwrap_or(false) {
1468                return Ok(None);
1469            }
1470            let marker_label = generation.as_deref().unwrap_or("legacy");
1471            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
1472            return Ok(Some(ReadonlyCallGraphStore::from_inner(
1473                Self::from_connection(
1474                    project_root,
1475                    project_key,
1476                    sqlite_path,
1477                    callgraph_dir,
1478                    false,
1479                    generation,
1480                    None,
1481                    Some(read_marker),
1482                    conn,
1483                ),
1484            )));
1485        }
1486
1487        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
1488            return Ok(None);
1489        };
1490        crate::slog_warn!(
1491            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
1492            target.partition.harness,
1493            target.sqlite_path.display()
1494        );
1495        let conn = open_readonly_connection(&target.sqlite_path)?;
1496        if !database_ready(&conn).unwrap_or(false) {
1497            return Ok(None);
1498        }
1499        let marker_label =
1500            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
1501        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
1502        Ok(Some(ReadonlyCallGraphStore::from_inner(
1503            Self::from_connection(
1504                project_root,
1505                project_key,
1506                target.sqlite_path,
1507                callgraph_dir,
1508                true,
1509                target.generation,
1510                None,
1511                Some(read_marker),
1512                conn,
1513            ),
1514        )))
1515    }
1516
1517    /// Open the currently-published ready store with write access so moved-root
1518    /// metadata can be repaired before projection readers consume it. Unlike
1519    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
1520    /// ready generation exists, it returns `Ok(None)` instead of creating an
1521    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
1522    pub fn open_ready_repairing(
1523        callgraph_dir: PathBuf,
1524        project_root: PathBuf,
1525    ) -> Result<Option<Self>> {
1526        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true, true)
1527    }
1528
1529    /// Open a ready store for bounded maintenance work without repairing root
1530    /// metadata or starting a cold rebuild. A store that needs either action is
1531    /// reported as unavailable so a background build can own that work.
1532    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
1533        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false, false)
1534    }
1535
1536    pub fn open_ready_no_rebuild(
1537        callgraph_dir: PathBuf,
1538        project_root: PathBuf,
1539    ) -> Result<Option<Self>> {
1540        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true, true)
1541    }
1542
1543    fn open_ready_with_rebuild_policy(
1544        callgraph_dir: PathBuf,
1545        project_root: PathBuf,
1546        allow_cold_build: bool,
1547        allow_root_repair: bool,
1548        allow_borrow_only: bool,
1549    ) -> Result<Option<Self>> {
1550        let project_key = crate::search_index::artifact_cache_key(&project_root);
1551        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1552        else {
1553            if !allow_borrow_only {
1554                return Ok(None);
1555            }
1556            return Self::open_readonly(callgraph_dir, project_root)
1557                .map(|store| store.map(ReadonlyCallGraphStore::into_inner));
1558        };
1559        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1560        else {
1561            return Ok(None);
1562        };
1563        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
1564            project_root.clone(),
1565            project_key,
1566            sqlite_path,
1567            generation,
1568            true,
1569            Some(Arc::clone(&writer_lease)),
1570            None,
1571            allow_root_repair,
1572        )?;
1573        match root_repair {
1574            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
1575                log_root_repair_rebuild(&root_repair);
1576                drop(store);
1577                drop(writer_lease);
1578                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1579                let (store, _stats) =
1580                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
1581                Ok(Some(store))
1582            }
1583            OpenRootRepair::NeedsRebuild { .. } => {
1584                crate::slog_info!(
1585                    "callgraph store root repair requires rebuild; open-only reader reports unavailable"
1586                );
1587                Ok(None)
1588            }
1589            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1590        }
1591    }
1592
1593    pub fn cold_build_with_lease(
1594        callgraph_dir: PathBuf,
1595        project_root: PathBuf,
1596        files: &[PathBuf],
1597    ) -> Result<(Self, ColdBuildStats)> {
1598        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
1599    }
1600
1601    pub fn cold_build_with_lease_chunked(
1602        callgraph_dir: PathBuf,
1603        project_root: PathBuf,
1604        files: &[PathBuf],
1605        chunk_size: usize,
1606    ) -> Result<(Self, ColdBuildStats)> {
1607        Self::cold_build_with_lease_chunked_inner(
1608            callgraph_dir,
1609            project_root,
1610            files,
1611            chunk_size,
1612            false,
1613        )
1614    }
1615
1616    pub(crate) fn force_cold_build_with_lease_chunked(
1617        callgraph_dir: PathBuf,
1618        project_root: PathBuf,
1619        files: &[PathBuf],
1620        chunk_size: usize,
1621    ) -> Result<(Self, ColdBuildStats)> {
1622        Self::cold_build_with_lease_chunked_inner(
1623            callgraph_dir,
1624            project_root,
1625            files,
1626            chunk_size,
1627            true,
1628        )
1629    }
1630
1631    fn cold_build_with_lease_chunked_inner(
1632        callgraph_dir: PathBuf,
1633        project_root: PathBuf,
1634        files: &[PathBuf],
1635        chunk_size: usize,
1636        require_new_publication: bool,
1637    ) -> Result<(Self, ColdBuildStats)> {
1638        let project_key = crate::search_index::artifact_cache_key(&project_root);
1639        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1640        else {
1641            if require_new_publication {
1642                return Err(CallGraphStoreError::Unavailable(
1643                    "forced rebuild could not acquire the writer lease".to_string(),
1644                ));
1645            }
1646            let store = match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1647                Some(store) => store.into_inner(),
1648                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)?,
1649            };
1650            return Ok((
1651                store,
1652                ColdBuildStats {
1653                    files: 0,
1654                    nodes: 0,
1655                    refs: 0,
1656                    edges: 0,
1657                    failed_files: Vec::new(),
1658                    elapsed_ms: 0,
1659                },
1660            ));
1661        };
1662        std::fs::create_dir_all(&callgraph_dir)?;
1663        let (stats, generation) = Self::cold_build_publish_locked(
1664            &callgraph_dir,
1665            &project_root,
1666            &project_key,
1667            files,
1668            chunk_size,
1669            Arc::clone(&writer_lease),
1670        )?;
1671        let store = Self::open_generation(
1672            &callgraph_dir,
1673            project_root,
1674            project_key,
1675            generation,
1676            writer_lease,
1677        )?;
1678        Ok((store, stats))
1679    }
1680
1681    pub fn ensure_built_with_lease(
1682        callgraph_dir: PathBuf,
1683        project_root: PathBuf,
1684        files: &[PathBuf],
1685    ) -> Result<(Self, Option<ColdBuildStats>)> {
1686        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
1687    }
1688
1689    pub fn ensure_built_with_lease_chunked(
1690        callgraph_dir: PathBuf,
1691        project_root: PathBuf,
1692        files: &[PathBuf],
1693        chunk_size: usize,
1694    ) -> Result<(Self, Option<ColdBuildStats>)> {
1695        let project_key = crate::search_index::artifact_cache_key(&project_root);
1696        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1697        else {
1698            return match Self::open_readonly(callgraph_dir.clone(), project_root.clone())? {
1699                Some(store) => Ok((store.into_inner(), None)),
1700                None => Self::borrow_only_empty(callgraph_dir, project_root, project_key)
1701                    .map(|store| (store, None)),
1702            };
1703        };
1704        std::fs::create_dir_all(&callgraph_dir)?;
1705        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1706        // Another process may have published a ready generation while we waited
1707        // for the lock — open it instead of rebuilding. If that generation is
1708        // from this same project at an older filesystem root, repair the root
1709        // metadata in-place while still holding the build lease. If data rows
1710        // contain absolute paths, publish a fresh generation under this lease
1711        // rather than recursively reacquiring the same lock.
1712        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1713        {
1714            let OpenedStore { store, root_repair } = Self::open_at_path(
1715                project_root.clone(),
1716                project_key.clone(),
1717                sqlite_path,
1718                generation,
1719                true,
1720                Some(Arc::clone(&writer_lease)),
1721                None,
1722            )?;
1723            match root_repair {
1724                OpenRootRepair::NeedsRebuild { .. } => {
1725                    log_root_repair_rebuild(&root_repair);
1726                    drop(store);
1727                    let (stats, generation) = Self::cold_build_publish_locked(
1728                        &callgraph_dir,
1729                        &project_root,
1730                        &project_key,
1731                        files,
1732                        chunk_size,
1733                        Arc::clone(&writer_lease),
1734                    )?;
1735                    let store = Self::open_generation(
1736                        &callgraph_dir,
1737                        project_root,
1738                        project_key,
1739                        generation,
1740                        writer_lease,
1741                    )?;
1742                    return Ok((store, Some(stats)));
1743                }
1744                OpenRootRepair::None | OpenRootRepair::ReRooted => {
1745                    return Ok((store, None));
1746                }
1747            }
1748        }
1749        if let Some(store) = try_legacy_migration_or_fallback(
1750            &callgraph_dir,
1751            &project_root,
1752            &project_key,
1753            Arc::clone(&writer_lease),
1754        )? {
1755            return Ok((store, None));
1756        }
1757        let (stats, generation) = Self::cold_build_publish_locked(
1758            &callgraph_dir,
1759            &project_root,
1760            &project_key,
1761            files,
1762            chunk_size,
1763            Arc::clone(&writer_lease),
1764        )?;
1765        let store = Self::open_generation(
1766            &callgraph_dir,
1767            project_root,
1768            project_key,
1769            generation,
1770            writer_lease,
1771        )?;
1772        Ok((store, Some(stats)))
1773    }
1774
1775    /// Migrate a legacy harness-partition store without falling through to a
1776    /// cold build. This is used after a query has already opened a read-only
1777    /// fallback: the caller runs it on the same limited background lane as cold
1778    /// builds while queries continue using that fallback. Public so crash/retry
1779    /// tests can drive the migration synchronously on a thread where the
1780    /// thread-local failure seams apply.
1781    pub fn migrate_legacy_with_lease(
1782        callgraph_dir: PathBuf,
1783        project_root: PathBuf,
1784    ) -> Result<Option<Self>> {
1785        let project_key = crate::search_index::artifact_cache_key(&project_root);
1786        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
1787        else {
1788            return Ok(None);
1789        };
1790        std::fs::create_dir_all(&callgraph_dir)?;
1791        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
1792
1793        // Another writer may have completed the migration while this worker was
1794        // waiting for the lease. Adopt its root-keyed generation rather than
1795        // copying the legacy source a second time.
1796        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
1797        {
1798            let OpenedStore { store, root_repair } = Self::open_at_path(
1799                project_root,
1800                project_key,
1801                sqlite_path,
1802                generation,
1803                true,
1804                Some(writer_lease),
1805                None,
1806            )?;
1807            return match root_repair {
1808                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
1809                OpenRootRepair::NeedsRebuild { reason, .. } => {
1810                    Err(CallGraphStoreError::Unavailable(format!(
1811                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
1812                    )))
1813                }
1814            };
1815        }
1816
1817        let store = try_legacy_migration_or_fallback(
1818            &callgraph_dir,
1819            &project_root,
1820            &project_key,
1821            writer_lease,
1822        )?;
1823        // A disk-floor or backup-budget failure returns a readable legacy store.
1824        // Keep the already-resident fallback instead of sending this duplicate
1825        // reader through the background-install channel.
1826        Ok(store.filter(|store| !store.is_legacy_fallback()))
1827    }
1828
1829    /// Build a fresh DB and publish it as a new generation, then atomically flip
1830    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
1831    /// succeeds even when other processes hold an older generation open (the
1832    /// multi-TUI Windows case). The builder owns the temp + generation files
1833    /// exclusively (unique pid+nanos names), so it can rename/replace them
1834    /// freely; only the tiny pointer is shared, and only Rust std touches it.
1835    ///
1836    /// Returns the published generation file name so callers open exactly the
1837    /// generation they built (avoiding a race where a concurrent build's flip
1838    /// would otherwise reopen a different generation).
1839    fn cold_build_publish_locked(
1840        callgraph_dir: &Path,
1841        project_root: &Path,
1842        project_key: &str,
1843        files: &[PathBuf],
1844        chunk_size: usize,
1845        writer_lease: Arc<crate::root_cache::WriterLease>,
1846    ) -> Result<(ColdBuildStats, String)> {
1847        let generation = generation_file_name(project_key);
1848        let gen_path = callgraph_dir.join(&generation);
1849        let temp_path = callgraph_dir.join(format!(
1850            "{generation}.tmp.{}.{}",
1851            std::process::id(),
1852            now_nanos()
1853        ));
1854        remove_sqlite_file_set(&temp_path);
1855
1856        let stats = {
1857            let temp_store = Self::open_at_path(
1858                project_root.to_path_buf(),
1859                project_key.to_string(),
1860                temp_path.clone(),
1861                None,
1862                false,
1863                Some(Arc::clone(&writer_lease)),
1864                None,
1865            )?
1866            .store;
1867            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
1868            temp_store.prepare_for_atomic_swap()?;
1869            stats
1870        };
1871
1872        notify_cold_build_before_publish_observer();
1873        let publication = publish_if_current(|| {
1874            verify_writer_lease(&writer_lease)?;
1875            // Move the finished build to its final generation path. This target is
1876            // brand-new and owned by us, so the rename never hits an open file.
1877            remove_sqlite_file_set(&gen_path);
1878            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
1879            crate::fs_lock::sync_parent(&gen_path);
1880            remove_sqlite_sidecars(&gen_path);
1881
1882            notify_cold_build_swap_observer(&temp_path, &gen_path);
1883
1884            // Atomically publish the new generation, then best-effort GC old ones.
1885            verify_writer_lease(&writer_lease)?;
1886            publish_pointer(callgraph_dir, project_key, &generation)?;
1887            gc_old_generations(callgraph_dir, project_key, &generation);
1888            // Store-wide orphan sweep on the same cadence: reclaims aged build
1889            // temps for roots that no longer build here, which the per-root GC
1890            // above never reaches.
1891            sweep_orphaned_build_temps_store_wide(callgraph_dir);
1892            Ok(())
1893        });
1894        if matches!(publication, Err(CallGraphStoreError::Superseded)) {
1895            remove_sqlite_file_set(&temp_path);
1896        }
1897        publication?;
1898        Ok((stats, generation))
1899    }
1900
1901    /// Open a specific just-published generation (read-write, WAL) so a builder
1902    /// returns a store pinned to exactly what it built.
1903    fn open_generation(
1904        callgraph_dir: &Path,
1905        project_root: PathBuf,
1906        project_key: String,
1907        generation: String,
1908        writer_lease: Arc<crate::root_cache::WriterLease>,
1909    ) -> Result<Self> {
1910        let gen_path = callgraph_dir.join(&generation);
1911        Ok(Self::open_at_path(
1912            project_root,
1913            project_key,
1914            gen_path,
1915            Some(generation),
1916            true,
1917            Some(writer_lease),
1918            None,
1919        )?
1920        .store)
1921    }
1922
1923    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
1924        let project_key = crate::search_index::artifact_cache_key(project_root);
1925        // A cold build is needed unless a ready generation (or ready legacy DB)
1926        // is currently published.
1927        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
1928    }
1929
1930    fn open_at_path(
1931        project_root: PathBuf,
1932        project_key: String,
1933        sqlite_path: PathBuf,
1934        generation: Option<String>,
1935        use_wal: bool,
1936        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1937        read_marker: Option<crate::root_cache::ReadMarker>,
1938    ) -> Result<OpenedStore> {
1939        Self::open_at_path_with_root_repair(
1940            project_root,
1941            project_key,
1942            sqlite_path,
1943            generation,
1944            use_wal,
1945            writer_lease,
1946            read_marker,
1947            true,
1948        )
1949    }
1950
1951    fn open_at_path_with_root_repair(
1952        project_root: PathBuf,
1953        project_key: String,
1954        sqlite_path: PathBuf,
1955        generation: Option<String>,
1956        use_wal: bool,
1957        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1958        read_marker: Option<crate::root_cache::ReadMarker>,
1959        allow_root_repair: bool,
1960    ) -> Result<OpenedStore> {
1961        if let Some(lease) = writer_lease.as_ref() {
1962            verify_writer_lease(lease)?;
1963        }
1964        if let Some(parent) = sqlite_path.parent() {
1965            std::fs::create_dir_all(parent)?;
1966        }
1967        let mut conn = Connection::open(&sqlite_path)?;
1968        if use_wal {
1969            configure_connection(&conn)?;
1970        } else {
1971            configure_build_connection(&conn)?;
1972        }
1973        if let Some(lease) = writer_lease.as_ref() {
1974            verify_writer_lease(lease)?;
1975        }
1976        initialize_schema(&conn)?;
1977        if let Some(lease) = writer_lease.as_ref() {
1978            verify_writer_lease(lease)?;
1979        }
1980        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
1981        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
1982            (Some(marker), _, _) => Some(marker),
1983            (None, Some(label), Some(cache_dir)) => {
1984                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
1985            }
1986            (None, _, _) => None,
1987        };
1988        let publication_dir = sqlite_path
1989            .parent()
1990            .map(Path::to_path_buf)
1991            .unwrap_or_default();
1992        let store = Self::from_connection(
1993            project_root,
1994            project_key,
1995            sqlite_path,
1996            publication_dir,
1997            false,
1998            generation,
1999            writer_lease,
2000            read_marker,
2001            conn,
2002        );
2003        Ok(OpenedStore { store, root_repair })
2004    }
2005
2006    fn borrow_only_empty(
2007        callgraph_dir: PathBuf,
2008        project_root: PathBuf,
2009        project_key: String,
2010    ) -> Result<Self> {
2011        let conn = Connection::open_in_memory()?;
2012        initialize_schema(&conn)?;
2013        conn.pragma_update(None, "query_only", true)?;
2014        Ok(Self::from_connection(
2015            project_root,
2016            project_key.clone(),
2017            callgraph_dir.join(format!("{project_key}.borrow-only")),
2018            callgraph_dir,
2019            false,
2020            None,
2021            None,
2022            None,
2023            conn,
2024        ))
2025    }
2026
2027    fn prepare_for_atomic_swap(&self) -> Result<()> {
2028        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2029        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
2030        Ok(())
2031    }
2032
2033    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
2034        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
2035            self.sqlite_path
2036                .parent()
2037                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
2038        });
2039        if protected_reader {
2040            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
2041        } else {
2042            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
2043        }
2044    }
2045
2046    fn from_connection(
2047        project_root: PathBuf,
2048        project_key: String,
2049        sqlite_path: PathBuf,
2050        publication_dir: PathBuf,
2051        legacy_fallback: bool,
2052        generation: Option<String>,
2053        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
2054        read_marker: Option<crate::root_cache::ReadMarker>,
2055        conn: Connection,
2056    ) -> Self {
2057        Self {
2058            project_root,
2059            project_key,
2060            sqlite_path,
2061            publication_dir,
2062            legacy_fallback,
2063            generation,
2064            writer_lease,
2065            read_marker,
2066            database_ready: AtomicBool::new(false),
2067            conn: Mutex::new(conn),
2068        }
2069    }
2070
2071    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
2072        if self.database_ready.load(AtomicOrdering::Acquire) {
2073            return Ok(());
2074        }
2075        ensure_database_ready(conn)?;
2076        self.database_ready.store(true, AtomicOrdering::Release);
2077        Ok(())
2078    }
2079
2080    pub fn project_root(&self) -> &Path {
2081        &self.project_root
2082    }
2083
2084    pub fn project_key(&self) -> &str {
2085        &self.project_key
2086    }
2087
2088    pub fn sqlite_path(&self) -> &Path {
2089        &self.sqlite_path
2090    }
2091
2092    /// Whether this store is reading from a legacy harness partition because
2093    /// the root-keyed store has not published a generation yet.
2094    pub fn is_legacy_fallback(&self) -> bool {
2095        self.legacy_fallback
2096    }
2097
2098    pub(crate) fn is_legacy_migration(&self) -> bool {
2099        self.generation.as_deref().is_some_and(|generation| {
2100            migration_generation_requires_manifest(generation)
2101                && migration_manifest_valid(&self.publication_dir, generation)
2102        })
2103    }
2104
2105    pub fn writer_epoch_for_test(&self) -> Option<&str> {
2106        self.writer_lease.as_ref().map(|lease| lease.epoch())
2107    }
2108
2109    fn verify_writer_lease(&self) -> Result<()> {
2110        let Some(lease) = self.writer_lease.as_ref() else {
2111            return Err(CallGraphStoreError::Unavailable(
2112                "callgraph store opened read-only; write API is unavailable".to_string(),
2113            ));
2114        };
2115        verify_writer_lease(lease)
2116    }
2117
2118    fn refresh_read_marker(&self) -> Result<()> {
2119        if let Some(marker) = self.read_marker.as_ref() {
2120            marker.touch_if_due()?;
2121        }
2122        Ok(())
2123    }
2124
2125    /// True if this store still reflects the currently-published generation.
2126    /// Cheap (one small pointer-file read). When false, another process (or a
2127    /// local cold rebuild) has published a newer generation and the holder
2128    /// should drop this store and reopen via the pointer to converge. A missing
2129    /// pointer keeps the current store (legacy DB still valid, or transient).
2130    pub fn is_current(&self) -> bool {
2131        let _ = self.refresh_read_marker();
2132        match (
2133            read_pointer(&self.publication_dir, &self.project_key),
2134            &self.generation,
2135        ) {
2136            // Even when both generations happen to have the same filename, the
2137            // root-keyed pointer names a different directory from the fallback.
2138            (Some(_), _) if self.legacy_fallback => false,
2139            (Some(published), Some(opened)) => &published == opened,
2140            // A generation now supersedes the legacy single-file DB we opened.
2141            (Some(_), None) => false,
2142            // No pointer: keep serving (legacy DB, or an anomalous pointer
2143            // removal where our open generation file is still valid).
2144            (None, _) => true,
2145        }
2146    }
2147
2148    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
2149        self.cold_build_chunked(files, 0)
2150    }
2151
2152    pub fn cold_build_chunked(
2153        &self,
2154        files: &[PathBuf],
2155        chunk_size: usize,
2156    ) -> Result<ColdBuildStats> {
2157        let started = Instant::now();
2158        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
2159        macro_rules! phase {
2160            ($label:expr, $t:expr) => {
2161                if bench {
2162                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
2163                    let _ = std::io::Write::flush(&mut std::io::stderr());
2164                }
2165            };
2166        }
2167        let files = normalize_file_list(&self.project_root, files)?;
2168
2169        if chunk_size == 0 {
2170            let t = Instant::now();
2171            let build = build_extracts_parallel(&self.project_root, &files);
2172            phase!("extract_parallel", t);
2173            let extracts = build.extracts;
2174            let failures = build.failures;
2175            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
2176
2177            let t = Instant::now();
2178            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
2179            phase!("build_index", t);
2180            let t = Instant::now();
2181            let mut resolved_refs = Vec::new();
2182            for extract in &extracts {
2183                for raw_ref in &extract.raw_refs {
2184                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
2185                }
2186            }
2187            phase!("resolve_refs", t);
2188            let ref_count = resolved_refs.len();
2189            let edge_count = resolved_refs
2190                .iter()
2191                .filter(|item| item.edge.is_some())
2192                .count();
2193
2194            let t = Instant::now();
2195            self.verify_writer_lease()?;
2196            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2197            let tx = conn.transaction()?;
2198            clear_tables(&tx)?;
2199            insert_meta(&tx)?;
2200            drop_cold_build_secondary_indexes(&tx)?;
2201            {
2202                let workspace_root = self.project_root.display().to_string();
2203                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2204                for extract in &extracts {
2205                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
2206                }
2207                for failure in &failures {
2208                    insert_backend_state_prepared(
2209                        &mut inserts.backend_state,
2210                        &workspace_root,
2211                        &failure.rel_path,
2212                        failure
2213                            .freshness
2214                            .as_ref()
2215                            .map(|freshness| &freshness.content_hash),
2216                        "stale",
2217                    )?;
2218                }
2219                for resolved in &resolved_refs {
2220                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
2221                }
2222            }
2223            create_cold_build_secondary_indexes(&tx)?;
2224            let supplemental_edge_count =
2225                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
2226            set_meta_ready(&tx, true)?;
2227            tx.commit()?;
2228            phase!("sqlite_insert", t);
2229
2230            let elapsed_ms = started.elapsed().as_millis();
2231            crate::slog_info!(
2232                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
2233                extracts.len(),
2234                node_count,
2235                ref_count,
2236                edge_count + supplemental_edge_count,
2237                elapsed_ms
2238            );
2239            return Ok(ColdBuildStats {
2240                files: extracts.len(),
2241                nodes: node_count,
2242                refs: ref_count,
2243                edges: edge_count + supplemental_edge_count,
2244                failed_files: failures
2245                    .into_iter()
2246                    .map(|failure| failure.rel_path)
2247                    .collect(),
2248                elapsed_ms,
2249            });
2250        }
2251
2252        // Chunked implementation: parse and resolve in batches to reduce peak
2253        // memory during cold build without changing the persisted graph.
2254        let t = Instant::now();
2255        self.verify_writer_lease()?;
2256        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2257        let tx = conn.transaction()?;
2258        clear_tables(&tx)?;
2259        insert_meta(&tx)?;
2260        drop_cold_build_secondary_indexes(&tx)?;
2261
2262        let mut all_raw_refs = Vec::new();
2263        let mut failures = Vec::new();
2264        let mut node_count = 0;
2265        let mut files_parsed = 0;
2266
2267        let mut persistent_call_data = Vec::new();
2268        let mut file_to_call_data_index = HashMap::new();
2269        let mut files_index = HashMap::new();
2270
2271        let workspace_root = self.project_root.display().to_string();
2272
2273        {
2274            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2275            for chunk in files.chunks(chunk_size) {
2276                let build = build_extracts_parallel(&self.project_root, chunk);
2277                failures.extend(build.failures.clone());
2278
2279                for extract in build.extracts {
2280                    files_parsed += 1;
2281                    node_count += extract.nodes.len();
2282                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
2283
2284                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
2285                    files_index.insert(extract.rel_path.clone(), db_file_index);
2286
2287                    persistent_call_data.push(extract.data);
2288                    let idx = persistent_call_data.len() - 1;
2289                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
2290
2291                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
2292                }
2293                for failure in &build.failures {
2294                    insert_backend_state_prepared(
2295                        &mut inserts.backend_state,
2296                        &workspace_root,
2297                        &failure.rel_path,
2298                        failure
2299                            .freshness
2300                            .as_ref()
2301                            .map(|freshness| &freshness.content_hash),
2302                        "stale",
2303                    )?;
2304                }
2305            }
2306        }
2307
2308        let mut caller_data = HashMap::new();
2309        for (rel_path, idx) in &file_to_call_data_index {
2310            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
2311        }
2312        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
2313        let index = ProjectIndex::from_parts(
2314            &self.project_root,
2315            files_index,
2316            caller_data,
2317            WorkspaceCratePrefixCache::default(),
2318        );
2319
2320        let mut resolved_refs = Vec::new();
2321        for (_, raw_refs) in all_raw_refs {
2322            for raw_ref in raw_refs {
2323                resolved_refs.push(resolve_ref(raw_ref, &index)?);
2324            }
2325        }
2326
2327        let ref_count = resolved_refs.len();
2328        let edge_count = resolved_refs
2329            .iter()
2330            .filter(|item| item.edge.is_some())
2331            .count();
2332
2333        {
2334            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
2335            for resolved in &resolved_refs {
2336                insert_resolved_ref_prepared(&mut inserts, resolved)?;
2337            }
2338        }
2339        create_cold_build_secondary_indexes(&tx)?;
2340        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
2341            &tx,
2342            &self.project_root,
2343            &indexed_caller_files,
2344            chunk_size,
2345        )?;
2346        set_meta_ready(&tx, true)?;
2347        tx.commit()?;
2348        phase!("sqlite_insert", t);
2349
2350        let elapsed_ms = started.elapsed().as_millis();
2351        crate::slog_info!(
2352            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
2353            files_parsed,
2354            node_count,
2355            ref_count,
2356            edge_count + supplemental_edge_count,
2357            elapsed_ms
2358        );
2359        Ok(ColdBuildStats {
2360            files: files_parsed,
2361            nodes: node_count,
2362            refs: ref_count,
2363            edges: edge_count + supplemental_edge_count,
2364            failed_files: failures
2365                .into_iter()
2366                .map(|failure| failure.rel_path)
2367                .collect(),
2368            elapsed_ms,
2369        })
2370    }
2371
2372    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
2373        self.refresh_files_with_workspace_crate_prefix_cache(
2374            changed_files,
2375            WorkspaceCratePrefixCache::default(),
2376        )
2377    }
2378
2379    fn refresh_files_with_workspace_crate_prefix_cache(
2380        &self,
2381        changed_files: &[PathBuf],
2382        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2383    ) -> Result<IncrementalStats> {
2384        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2385            changed_files,
2386            workspace_crate_prefixes,
2387        )?;
2388        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
2389            eprintln!("refresh_files phases: {}", profile.report());
2390        }
2391        Ok(stats)
2392    }
2393
2394    /// Run an incremental refresh and return phase timings for an offline store copy.
2395    #[doc(hidden)]
2396    pub fn refresh_files_profiled(
2397        &self,
2398        changed_files: &[PathBuf],
2399    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2400        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
2401            changed_files,
2402            WorkspaceCratePrefixCache::default(),
2403        )
2404    }
2405
2406    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
2407        &self,
2408        changed_files: &[PathBuf],
2409        workspace_crate_prefixes: WorkspaceCratePrefixCache,
2410    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
2411        let total_started = Instant::now();
2412        let mut profile = RefreshFilesProfile::default();
2413        self.verify_writer_lease()?;
2414        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2415        let tx = conn.transaction()?;
2416        ensure_database_ready(&tx)?;
2417        let mut changed = Vec::new();
2418        let mut surface_changed = BTreeSet::new();
2419        let mut deleted = BTreeSet::new();
2420        let mut own_refresh = BTreeSet::new();
2421        let mut selected_ref_ids = BTreeSet::new();
2422        let mut selected_refs_by_caller = BTreeMap::new();
2423        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
2424
2425        for input in changed_files {
2426            let abs_path = normalize_file_path(&self.project_root, input)?;
2427            let rel_path = relative_path(&self.project_root, &abs_path);
2428            changed.push(rel_path.clone());
2429            let old_row = load_file_row(&tx, &rel_path)?;
2430            if !abs_path.exists() {
2431                if old_row.is_some() {
2432                    surface_changed.insert(rel_path.clone());
2433                    deleted.insert(rel_path.clone());
2434                    let started = Instant::now();
2435                    let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2436                    profile.dependency_selection += started.elapsed();
2437                    record_dependent_refs(
2438                        &mut selected_ref_ids,
2439                        &mut selected_refs_by_caller,
2440                        dependent_refs,
2441                    );
2442                    let started = Instant::now();
2443                    delete_file_rows(&tx, &rel_path)?;
2444                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2445                    profile.row_deletes += started.elapsed();
2446                }
2447                continue;
2448            }
2449
2450            if let Some(row) = &old_row {
2451                match cache_freshness::verify_file(&abs_path, &row.freshness) {
2452                    FreshnessVerdict::HotFresh => continue,
2453                    FreshnessVerdict::ContentFresh {
2454                        new_mtime,
2455                        new_size,
2456                    } => {
2457                        update_file_fresh_metadata(
2458                            &tx,
2459                            &rel_path,
2460                            &row.freshness.content_hash,
2461                            new_mtime,
2462                            new_size,
2463                        )?;
2464                        continue;
2465                    }
2466                    FreshnessVerdict::Deleted => {
2467                        surface_changed.insert(rel_path.clone());
2468                        deleted.insert(rel_path.clone());
2469                        let started = Instant::now();
2470                        let dependent_refs =
2471                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2472                        profile.dependency_selection += started.elapsed();
2473                        record_dependent_refs(
2474                            &mut selected_ref_ids,
2475                            &mut selected_refs_by_caller,
2476                            dependent_refs,
2477                        );
2478                        let started = Instant::now();
2479                        delete_file_rows(&tx, &rel_path)?;
2480                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
2481                        profile.row_deletes += started.elapsed();
2482                        continue;
2483                    }
2484                    FreshnessVerdict::Stale => {}
2485                }
2486            }
2487
2488            let started = Instant::now();
2489            let extract = build_file_extract(&self.project_root, &abs_path)?;
2490            profile.parse += started.elapsed();
2491            let surface_is_changed = old_row
2492                .as_ref()
2493                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
2494                .unwrap_or(true);
2495            if surface_is_changed {
2496                surface_changed.insert(rel_path.clone());
2497                let started = Instant::now();
2498                let dependent_refs = ref_ids_depending_on(&tx, &self.project_root, &rel_path)?;
2499                profile.dependency_selection += started.elapsed();
2500                record_dependent_refs(
2501                    &mut selected_ref_ids,
2502                    &mut selected_refs_by_caller,
2503                    dependent_refs,
2504                );
2505            }
2506            own_refresh.insert(rel_path.clone());
2507            let started = Instant::now();
2508            delete_file_rows(&tx, &rel_path)?;
2509            profile.row_deletes += started.elapsed();
2510            let started = Instant::now();
2511            insert_file_extract(&tx, &self.project_root, &extract)?;
2512            profile.row_inserts += started.elapsed();
2513            changed_extracts.insert(rel_path, extract);
2514        }
2515
2516        let dependency_selected_refs = selected_ref_ids.len();
2517        let mut touched_callers: BTreeSet<String> =
2518            selected_refs_by_caller.keys().cloned().collect();
2519        touched_callers.extend(own_refresh.iter().cloned());
2520
2521        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
2522        for rel_path in &touched_callers {
2523            if deleted.contains(rel_path) {
2524                continue;
2525            }
2526            if let Some(extract) = changed_extracts.get(rel_path) {
2527                caller_extracts.insert(rel_path.clone(), extract.clone());
2528                continue;
2529            }
2530            let abs_path = self.project_root.join(rel_path);
2531            if abs_path.exists() {
2532                let started = Instant::now();
2533                let extract = build_file_extract(&self.project_root, &abs_path)?;
2534                profile.dependent_parse += started.elapsed();
2535                caller_extracts.insert(rel_path.clone(), extract);
2536            }
2537        }
2538
2539        let dependency_callers = touched_callers
2540            .iter()
2541            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
2542            .cloned()
2543            .collect::<Vec<_>>();
2544        for rel_path in dependency_callers {
2545            let Some(extract) = caller_extracts.get(&rel_path) else {
2546                continue;
2547            };
2548            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
2549                continue;
2550            }
2551
2552            own_refresh.insert(rel_path.clone());
2553            let started = Instant::now();
2554            delete_file_rows(&tx, &rel_path)?;
2555            profile.row_deletes += started.elapsed();
2556            let started = Instant::now();
2557            insert_file_extract(&tx, &self.project_root, extract)?;
2558            profile.row_inserts += started.elapsed();
2559        }
2560
2561        let started = Instant::now();
2562        let index = ProjectIndex::from_db_and_callers(
2563            &tx,
2564            &self.project_root,
2565            &caller_extracts,
2566            workspace_crate_prefixes,
2567        )?;
2568        profile.index_load += started.elapsed();
2569        let started = Instant::now();
2570        for rel_path in &touched_callers {
2571            if deleted.contains(rel_path) {
2572                continue;
2573            }
2574            let Some(extract) = caller_extracts.get(rel_path) else {
2575                continue;
2576            };
2577            if own_refresh.contains(rel_path) {
2578                delete_refs_for_caller(&tx, rel_path)?;
2579                for raw_ref in &extract.raw_refs {
2580                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2581                    insert_resolved_ref(&tx, &resolved)?;
2582                }
2583                continue;
2584            }
2585
2586            let selected_for_caller = selected_refs_by_caller
2587                .get(rel_path)
2588                .cloned()
2589                .unwrap_or_default();
2590            delete_ref_ids(&tx, &selected_for_caller)?;
2591            for raw_ref in &extract.raw_refs {
2592                if selected_for_caller.contains(&raw_ref.ref_id) {
2593                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
2594                    insert_resolved_ref(&tx, &resolved)?;
2595                }
2596            }
2597        }
2598        profile.ref_resolution += started.elapsed();
2599
2600        let started = Instant::now();
2601        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
2602        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
2603        profile.method_dispatch += started.elapsed();
2604
2605        let started = Instant::now();
2606        commit_incremental_if_current(tx)?;
2607        profile.commit += started.elapsed();
2608        profile.total = total_started.elapsed();
2609        Ok((
2610            IncrementalStats {
2611                changed_files: changed,
2612                surface_changed: surface_changed.into_iter().collect(),
2613                deleted_files: deleted.into_iter().collect(),
2614                dependency_selected_refs,
2615                refreshed_own_files: own_refresh.len(),
2616            },
2617            profile,
2618        ))
2619    }
2620
2621    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
2622        self.cold_build(current_files)
2623    }
2624
2625    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
2626        self.verify_writer_lease()?;
2627        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
2628        let tx = conn.transaction()?;
2629        let mut marked = Vec::new();
2630        for path in files {
2631            let abs_path = normalize_file_path(&self.project_root, path)?;
2632            let rel_path = relative_path(&self.project_root, &abs_path);
2633            let freshness = cache_freshness::collect(&abs_path).ok();
2634            mark_backend_state(
2635                &tx,
2636                &self.project_root,
2637                &rel_path,
2638                freshness.as_ref().map(|freshness| &freshness.content_hash),
2639                "stale",
2640            )?;
2641            marked.push(rel_path);
2642        }
2643        tx.commit()?;
2644        marked.sort();
2645        marked.dedup();
2646        Ok(marked)
2647    }
2648
2649    pub fn stale_files(&self) -> Result<Vec<String>> {
2650        self.refresh_read_marker()?;
2651        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2652        let mut stmt = conn.prepare(
2653            "SELECT DISTINCT file_path FROM backend_file_state
2654             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
2655             ORDER BY file_path",
2656        )?;
2657        let rows = stmt.query_map(
2658            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
2659            |row| row.get::<_, String>(0),
2660        )?;
2661        rows.collect::<std::result::Result<Vec<_>, _>>()
2662            .map_err(Into::into)
2663    }
2664
2665    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
2666        self.refresh_read_marker()?;
2667        let rel_path = relative_path(
2668            &self.project_root,
2669            &normalize_file_path(&self.project_root, file)?,
2670        );
2671        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2672        conn.query_row(
2673            "SELECT status FROM backend_file_state
2674             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
2675             ORDER BY updated_at DESC LIMIT 1",
2676            params![
2677                BACKEND_TREESITTER,
2678                self.project_root.display().to_string(),
2679                rel_path
2680            ],
2681            |row| row.get(0),
2682        )
2683        .optional()
2684        .map_err(Into::into)
2685    }
2686
2687    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
2688        self.refresh_read_marker()?;
2689        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2690        self.ensure_ready(&conn)?;
2691        edge_snapshot_with_conn(&conn)
2692    }
2693
2694    pub fn indexed_file_count(&self) -> Result<usize> {
2695        self.refresh_read_marker()?;
2696        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2697        self.ensure_ready(&conn)?;
2698        indexed_file_count(&conn)
2699    }
2700
2701    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
2702        self.refresh_read_marker()?;
2703        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2704        let rel_path = relative_path(&self.project_root, &abs_path);
2705        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2706        self.ensure_ready(&conn)?;
2707        resolve_node_for_rel(&conn, &rel_path, symbol)
2708    }
2709
2710    /// Return all positional nodes matching a legacy symbol query in a file.
2711    ///
2712    /// Consumers that need legacy compatibility can collapse these by
2713    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
2714    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
2715        self.refresh_read_marker()?;
2716        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2717        let rel_path = relative_path(&self.project_root, &abs_path);
2718        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2719        self.ensure_ready(&conn)?;
2720        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
2721    }
2722
2723    /// Return all positional nodes matching a symbol query anywhere in the store.
2724    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
2725        self.refresh_read_marker()?;
2726        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2727        self.ensure_ready(&conn)?;
2728        nodes_matching_symbol(&conn, symbol)
2729    }
2730
2731    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
2732    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
2733        self.refresh_read_marker()?;
2734        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
2735        let rel_path = relative_path(&self.project_root, &abs_path);
2736        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2737        self.ensure_ready(&conn)?;
2738        direct_callers_for_tuple(&conn, &rel_path, symbol)
2739    }
2740
2741    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
2742    pub fn direct_caller_counts_of(
2743        &self,
2744        targets: &[(String, String)],
2745    ) -> Result<HashMap<(String, String), usize>> {
2746        if targets.is_empty() {
2747            return Ok(HashMap::new());
2748        }
2749        self.refresh_read_marker()?;
2750        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2751        self.ensure_ready(&conn)?;
2752        direct_caller_counts_for_tuples(&conn, targets)
2753    }
2754
2755    pub fn callers_of(
2756        &self,
2757        file_rel: &Path,
2758        symbol: &str,
2759        depth: usize,
2760    ) -> Result<StoreCallersResult> {
2761        let target = self.node_for(file_rel, symbol)?;
2762        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2763        self.ensure_ready(&conn)?;
2764        let effective_depth = depth.max(1);
2765        let mut visited = HashSet::new();
2766        let mut callers = Vec::new();
2767        let mut depth_limited = false;
2768        let mut truncated = 0usize;
2769        collect_callers_recursive(
2770            &conn,
2771            &target.file,
2772            &target.symbol,
2773            effective_depth,
2774            0,
2775            &mut visited,
2776            &mut callers,
2777            &mut depth_limited,
2778            &mut truncated,
2779        )?;
2780        Ok(StoreCallersResult {
2781            target,
2782            callers,
2783            scanned_files: indexed_file_count(&conn)?,
2784            depth_limited,
2785            truncated,
2786        })
2787    }
2788
2789    pub fn impact_of(
2790        &self,
2791        file_rel: &Path,
2792        symbol: &str,
2793        depth: usize,
2794    ) -> Result<StoreImpactResult> {
2795        let callers = self.callers_of(file_rel, symbol, depth)?;
2796        let target_parameters = callers
2797            .target
2798            .signature
2799            .as_deref()
2800            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
2801            .unwrap_or_default();
2802        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
2803        for site in &callers.callers {
2804            source_lines_by_file
2805                .entry(site.caller.file.clone())
2806                .or_insert_with(|| {
2807                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
2808                });
2809        }
2810        let enriched = callers
2811            .callers
2812            .iter()
2813            .map(|site| StoreImpactCaller {
2814                site: site.clone(),
2815                signature: site.caller.signature.clone(),
2816                is_entry_point: site.caller.is_entry_point,
2817                call_expression: source_lines_by_file
2818                    .get(&site.caller.file)
2819                    .and_then(|lines| lines.as_ref())
2820                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
2821                    .cloned(),
2822                parameters: site
2823                    .caller
2824                    .signature
2825                    .as_deref()
2826                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
2827                    .unwrap_or_default(),
2828            })
2829            .collect();
2830        Ok(StoreImpactResult {
2831            target: callers.target,
2832            parameters: target_parameters,
2833            callers: enriched,
2834            depth_limited: callers.depth_limited,
2835            truncated: callers.truncated,
2836        })
2837    }
2838
2839    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2840        self.refresh_read_marker()?;
2841        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2842        self.ensure_ready(&conn)?;
2843        outgoing_calls_for_node(&conn, node)
2844    }
2845
2846    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
2847    pub fn outgoing_calls_for_symbols(
2848        &self,
2849        sources: &[(String, String)],
2850    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2851        if sources.is_empty() {
2852            return Ok(HashMap::new());
2853        }
2854        self.refresh_read_marker()?;
2855        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2856        self.ensure_ready(&conn)?;
2857        outgoing_calls_for_symbol_tuples(&conn, sources)
2858    }
2859
2860    /// Return resolved direct self-call refs suppressed from the general edge table.
2861    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
2862        self.refresh_read_marker()?;
2863        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2864        self.ensure_ready(&conn)?;
2865        resolved_self_calls_for_node(&conn, node)
2866    }
2867
2868    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
2869        self.refresh_read_marker()?;
2870        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2871        self.ensure_ready(&conn)?;
2872        unresolved_calls_for_node(&conn, node)
2873    }
2874
2875    pub fn call_tree(
2876        &self,
2877        file_rel: &Path,
2878        symbol: &str,
2879        max_depth: usize,
2880    ) -> Result<callgraph::CallTreeNode> {
2881        let node = self.node_for(file_rel, symbol)?;
2882        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2883        self.ensure_ready(&conn)?;
2884        let mut visited = HashSet::new();
2885        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
2886    }
2887
2888    pub fn trace_to(
2889        &self,
2890        file_rel: &Path,
2891        symbol: &str,
2892        max_depth: usize,
2893    ) -> Result<callgraph::TraceToResult> {
2894        let target = self.node_for(file_rel, symbol)?;
2895        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
2896        self.ensure_ready(&conn)?;
2897        let effective_max = if max_depth == 0 { 10 } else { max_depth };
2898
2899        #[derive(Clone)]
2900        struct PathElem {
2901            node: StoreNode,
2902        }
2903
2904        let initial = vec![PathElem {
2905            node: target.clone(),
2906        }];
2907        let mut complete_paths = Vec::new();
2908        if target.is_entry_point {
2909            complete_paths.push(initial.clone());
2910        }
2911
2912        let mut queue = vec![(initial, 0usize)];
2913        let mut max_depth_reached = false;
2914        let mut truncated_paths = 0usize;
2915
2916        while let Some((path, depth)) = queue.pop() {
2917            if depth >= effective_max {
2918                max_depth_reached = true;
2919                continue;
2920            }
2921            let Some(current) = path.last() else {
2922                continue;
2923            };
2924            let callers =
2925                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
2926            if callers.is_empty() {
2927                if path.len() > 1 {
2928                    truncated_paths += 1;
2929                }
2930                continue;
2931            }
2932
2933            let mut has_new_path = false;
2934            for site in callers {
2935                if path.iter().any(|elem| {
2936                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
2937                }) {
2938                    continue;
2939                }
2940                has_new_path = true;
2941                let mut new_path = path.clone();
2942                new_path.push(PathElem {
2943                    node: site.caller.clone(),
2944                });
2945                if site.caller.is_entry_point {
2946                    complete_paths.push(new_path.clone());
2947                }
2948                queue.push((new_path, depth + 1));
2949            }
2950            if !has_new_path && path.len() > 1 {
2951                truncated_paths += 1;
2952            }
2953        }
2954
2955        let mut paths: Vec<callgraph::TracePath> = complete_paths
2956            .into_iter()
2957            .map(|mut elems| {
2958                elems.reverse();
2959                let hops = elems
2960                    .iter()
2961                    .enumerate()
2962                    .map(|(index, elem)| callgraph::TraceHop {
2963                        symbol: elem.node.symbol.clone(),
2964                        file: elem.node.file.clone(),
2965                        line: elem.node.line,
2966                        signature: elem.node.signature.clone(),
2967                        is_entry_point: index == 0 && elem.node.is_entry_point,
2968                    })
2969                    .collect();
2970                callgraph::TracePath { hops }
2971            })
2972            .collect();
2973        paths.sort_by(|left, right| {
2974            let left_entry = left
2975                .hops
2976                .first()
2977                .map(|hop| hop.symbol.as_str())
2978                .unwrap_or("");
2979            let right_entry = right
2980                .hops
2981                .first()
2982                .map(|hop| hop.symbol.as_str())
2983                .unwrap_or("");
2984            left_entry
2985                .cmp(right_entry)
2986                .then(left.hops.len().cmp(&right.hops.len()))
2987        });
2988        let entry_points_found = paths
2989            .iter()
2990            .filter_map(|path| path.hops.first())
2991            .filter(|hop| hop.is_entry_point)
2992            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
2993            .collect::<HashSet<_>>()
2994            .len();
2995
2996        Ok(callgraph::TraceToResult {
2997            target_symbol: target.symbol,
2998            target_file: target.file,
2999            total_paths: paths.len(),
3000            paths,
3001            entry_points_found,
3002            max_depth_reached,
3003            truncated_paths,
3004        })
3005    }
3006
3007    pub fn trace_to_symbol_candidates(
3008        &self,
3009        to_symbol: &str,
3010    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
3011        self.refresh_read_marker()?;
3012        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3013        self.ensure_ready(&conn)?;
3014        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
3015        for node in nodes_matching_symbol(&conn, to_symbol)? {
3016            candidates_by_file
3017                .entry(node.file)
3018                .and_modify(|line| *line = (*line).min(node.line))
3019                .or_insert(node.line);
3020        }
3021        let mut candidates: Vec<_> = candidates_by_file
3022            .into_iter()
3023            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
3024            .collect();
3025        candidates
3026            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
3027        Ok(candidates)
3028    }
3029
3030    pub fn trace_to_symbol(
3031        &self,
3032        file_rel: &Path,
3033        symbol: &str,
3034        to_symbol: &str,
3035        to_file: Option<&Path>,
3036        max_depth: usize,
3037    ) -> Result<callgraph::TraceToSymbolResult> {
3038        let origin = self.node_for(file_rel, symbol)?;
3039        let target_file = to_file
3040            .map(|path| normalize_file_path(&self.project_root, path))
3041            .transpose()?
3042            .map(|path| relative_path(&self.project_root, &path));
3043        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3044        self.ensure_ready(&conn)?;
3045        let effective_max = if max_depth == 0 {
3046            10
3047        } else {
3048            max_depth.min(16)
3049        };
3050
3051        let start_hop = trace_to_symbol_hop(&origin);
3052        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
3053            return Ok(callgraph::TraceToSymbolResult {
3054                path: Some(vec![start_hop]),
3055                complete: true,
3056                reason: None,
3057            });
3058        }
3059
3060        let mut queue = VecDeque::new();
3061        queue.push_back((origin.clone(), vec![start_hop], 0usize));
3062        let mut visited = HashSet::new();
3063        visited.insert((origin.file.clone(), origin.symbol.clone()));
3064        let mut max_depth_exhausted = false;
3065
3066        while let Some((current, path, depth)) = queue.pop_front() {
3067            let callees = outgoing_calls_for_node(&conn, &current)?
3068                .into_iter()
3069                .filter_map(|site| site.target)
3070                .collect::<Vec<_>>();
3071
3072            if depth >= effective_max {
3073                if callees
3074                    .iter()
3075                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
3076                {
3077                    max_depth_exhausted = true;
3078                }
3079                continue;
3080            }
3081
3082            for callee in callees {
3083                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
3084                    continue;
3085                }
3086                let mut next_path = path.clone();
3087                next_path.push(trace_to_symbol_hop(&callee));
3088                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
3089                    return Ok(callgraph::TraceToSymbolResult {
3090                        path: Some(next_path),
3091                        complete: true,
3092                        reason: None,
3093                    });
3094                }
3095                queue.push_back((callee, next_path, depth + 1));
3096            }
3097        }
3098
3099        if max_depth_exhausted {
3100            Ok(callgraph::TraceToSymbolResult {
3101                path: None,
3102                complete: false,
3103                reason: Some("max_depth_exhausted".to_string()),
3104            })
3105        } else {
3106            Ok(callgraph::TraceToSymbolResult {
3107                path: None,
3108                complete: true,
3109                reason: Some("no_path_found".to_string()),
3110            })
3111        }
3112    }
3113}
3114
3115impl ReadonlyCallGraphStore {
3116    fn from_inner(inner: CallGraphStore) -> Self {
3117        Self { inner }
3118    }
3119
3120    fn into_inner(self) -> CallGraphStore {
3121        self.inner
3122    }
3123
3124    pub fn project_root(&self) -> &Path {
3125        self.inner.project_root()
3126    }
3127
3128    pub fn project_key(&self) -> &str {
3129        self.inner.project_key()
3130    }
3131
3132    pub fn sqlite_path(&self) -> &Path {
3133        self.inner.sqlite_path()
3134    }
3135
3136    /// Report the open generation handle. SQLite-owned allocations are measured
3137    /// once by the process-wide SQLite allocator counters.
3138    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3139        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
3140    }
3141
3142    /// Whether this reader is temporarily serving a legacy harness partition.
3143    pub fn is_legacy_fallback(&self) -> bool {
3144        self.inner.is_legacy_fallback()
3145    }
3146
3147    pub fn is_current(&self) -> bool {
3148        self.inner.is_current()
3149    }
3150
3151    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3152        self.inner.edge_snapshot()
3153    }
3154
3155    pub fn indexed_file_count(&self) -> Result<usize> {
3156        self.inner.indexed_file_count()
3157    }
3158
3159    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3160        self.inner.node_for(file_rel, symbol)
3161    }
3162
3163    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3164        self.inner.nodes_for(file_rel, symbol)
3165    }
3166
3167    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3168        self.inner.nodes_matching(symbol)
3169    }
3170
3171    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3172        self.inner.direct_callers_of(file_rel, symbol)
3173    }
3174
3175    pub fn direct_caller_counts_of(
3176        &self,
3177        targets: &[(String, String)],
3178    ) -> Result<HashMap<(String, String), usize>> {
3179        self.inner.direct_caller_counts_of(targets)
3180    }
3181
3182    pub fn callers_of(
3183        &self,
3184        file_rel: &Path,
3185        symbol: &str,
3186        depth: usize,
3187    ) -> Result<StoreCallersResult> {
3188        self.inner.callers_of(file_rel, symbol, depth)
3189    }
3190
3191    pub fn impact_of(
3192        &self,
3193        file_rel: &Path,
3194        symbol: &str,
3195        depth: usize,
3196    ) -> Result<StoreImpactResult> {
3197        self.inner.impact_of(file_rel, symbol, depth)
3198    }
3199
3200    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3201        self.inner.outgoing_calls_of(node)
3202    }
3203
3204    pub fn outgoing_calls_for_symbols(
3205        &self,
3206        sources: &[(String, String)],
3207    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3208        self.inner.outgoing_calls_for_symbols(sources)
3209    }
3210
3211    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3212        self.inner.resolved_self_calls_of(node)
3213    }
3214
3215    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3216        self.inner.unresolved_calls_of(node)
3217    }
3218
3219    pub fn call_tree(
3220        &self,
3221        file_rel: &Path,
3222        symbol: &str,
3223        depth: usize,
3224    ) -> Result<callgraph::CallTreeNode> {
3225        self.inner.call_tree(file_rel, symbol, depth)
3226    }
3227
3228    pub fn trace_to(
3229        &self,
3230        file_rel: &Path,
3231        symbol: &str,
3232        max_depth: usize,
3233    ) -> Result<callgraph::TraceToResult> {
3234        self.inner.trace_to(file_rel, symbol, max_depth)
3235    }
3236
3237    pub fn trace_to_symbol_candidates(
3238        &self,
3239        to_symbol: &str,
3240    ) -> Result<Vec<TraceToSymbolCandidate>> {
3241        self.inner.trace_to_symbol_candidates(to_symbol)
3242    }
3243
3244    pub fn trace_to_symbol(
3245        &self,
3246        file_rel: &Path,
3247        symbol: &str,
3248        to_symbol: &str,
3249        to_file: Option<&Path>,
3250        max_depth: usize,
3251    ) -> Result<callgraph::TraceToSymbolResult> {
3252        self.inner
3253            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3254    }
3255}
3256
3257impl CallGraphRead for CallGraphStore {
3258    fn project_root(&self) -> &Path {
3259        CallGraphStore::project_root(self)
3260    }
3261    fn project_key(&self) -> &str {
3262        CallGraphStore::project_key(self)
3263    }
3264    fn sqlite_path(&self) -> &Path {
3265        CallGraphStore::sqlite_path(self)
3266    }
3267    fn is_current(&self) -> bool {
3268        CallGraphStore::is_current(self)
3269    }
3270    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3271        CallGraphStore::edge_snapshot(self)
3272    }
3273    fn indexed_file_count(&self) -> Result<usize> {
3274        CallGraphStore::indexed_file_count(self)
3275    }
3276    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3277        CallGraphStore::node_for(self, file_rel, symbol)
3278    }
3279    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3280        CallGraphStore::nodes_for(self, file_rel, symbol)
3281    }
3282    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3283        CallGraphStore::nodes_matching(self, symbol)
3284    }
3285    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3286        CallGraphStore::direct_callers_of(self, file_rel, symbol)
3287    }
3288    fn direct_caller_counts_of(
3289        &self,
3290        targets: &[(String, String)],
3291    ) -> Result<HashMap<(String, String), usize>> {
3292        CallGraphStore::direct_caller_counts_of(self, targets)
3293    }
3294    fn callers_of(
3295        &self,
3296        file_rel: &Path,
3297        symbol: &str,
3298        depth: usize,
3299    ) -> Result<StoreCallersResult> {
3300        CallGraphStore::callers_of(self, file_rel, symbol, depth)
3301    }
3302    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3303        CallGraphStore::impact_of(self, file_rel, symbol, depth)
3304    }
3305    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3306        CallGraphStore::outgoing_calls_of(self, node)
3307    }
3308    fn outgoing_calls_for_symbols(
3309        &self,
3310        sources: &[(String, String)],
3311    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3312        CallGraphStore::outgoing_calls_for_symbols(self, sources)
3313    }
3314    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3315        CallGraphStore::resolved_self_calls_of(self, node)
3316    }
3317    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3318        CallGraphStore::unresolved_calls_of(self, node)
3319    }
3320    fn call_tree(
3321        &self,
3322        file_rel: &Path,
3323        symbol: &str,
3324        depth: usize,
3325    ) -> Result<callgraph::CallTreeNode> {
3326        CallGraphStore::call_tree(self, file_rel, symbol, depth)
3327    }
3328    fn trace_to(
3329        &self,
3330        file_rel: &Path,
3331        symbol: &str,
3332        max_depth: usize,
3333    ) -> Result<callgraph::TraceToResult> {
3334        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
3335    }
3336    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3337        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
3338    }
3339    fn trace_to_symbol(
3340        &self,
3341        file_rel: &Path,
3342        symbol: &str,
3343        to_symbol: &str,
3344        to_file: Option<&Path>,
3345        max_depth: usize,
3346    ) -> Result<callgraph::TraceToSymbolResult> {
3347        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
3348    }
3349}
3350
3351impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
3352    fn project_root(&self) -> &Path {
3353        (**self).project_root()
3354    }
3355    fn project_key(&self) -> &str {
3356        (**self).project_key()
3357    }
3358    fn sqlite_path(&self) -> &Path {
3359        (**self).sqlite_path()
3360    }
3361    fn is_current(&self) -> bool {
3362        (**self).is_current()
3363    }
3364    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3365        (**self).edge_snapshot()
3366    }
3367    fn indexed_file_count(&self) -> Result<usize> {
3368        (**self).indexed_file_count()
3369    }
3370    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3371        (**self).node_for(file_rel, symbol)
3372    }
3373    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3374        (**self).nodes_for(file_rel, symbol)
3375    }
3376    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3377        (**self).nodes_matching(symbol)
3378    }
3379    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3380        (**self).direct_callers_of(file_rel, symbol)
3381    }
3382    fn direct_caller_counts_of(
3383        &self,
3384        targets: &[(String, String)],
3385    ) -> Result<HashMap<(String, String), usize>> {
3386        (**self).direct_caller_counts_of(targets)
3387    }
3388    fn callers_of(
3389        &self,
3390        file_rel: &Path,
3391        symbol: &str,
3392        depth: usize,
3393    ) -> Result<StoreCallersResult> {
3394        (**self).callers_of(file_rel, symbol, depth)
3395    }
3396    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3397        (**self).impact_of(file_rel, symbol, depth)
3398    }
3399    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3400        (**self).outgoing_calls_of(node)
3401    }
3402    fn outgoing_calls_for_symbols(
3403        &self,
3404        sources: &[(String, String)],
3405    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3406        (**self).outgoing_calls_for_symbols(sources)
3407    }
3408    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3409        (**self).resolved_self_calls_of(node)
3410    }
3411    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3412        (**self).unresolved_calls_of(node)
3413    }
3414    fn call_tree(
3415        &self,
3416        file_rel: &Path,
3417        symbol: &str,
3418        depth: usize,
3419    ) -> Result<callgraph::CallTreeNode> {
3420        (**self).call_tree(file_rel, symbol, depth)
3421    }
3422    fn trace_to(
3423        &self,
3424        file_rel: &Path,
3425        symbol: &str,
3426        max_depth: usize,
3427    ) -> Result<callgraph::TraceToResult> {
3428        (**self).trace_to(file_rel, symbol, max_depth)
3429    }
3430    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3431        (**self).trace_to_symbol_candidates(to_symbol)
3432    }
3433    fn trace_to_symbol(
3434        &self,
3435        file_rel: &Path,
3436        symbol: &str,
3437        to_symbol: &str,
3438        to_file: Option<&Path>,
3439        max_depth: usize,
3440    ) -> Result<callgraph::TraceToSymbolResult> {
3441        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3442    }
3443}
3444
3445impl CallGraphRead for ReadonlyCallGraphStore {
3446    fn project_root(&self) -> &Path {
3447        self.project_root()
3448    }
3449    fn project_key(&self) -> &str {
3450        self.project_key()
3451    }
3452    fn sqlite_path(&self) -> &Path {
3453        self.sqlite_path()
3454    }
3455    fn is_current(&self) -> bool {
3456        self.is_current()
3457    }
3458    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
3459        self.edge_snapshot()
3460    }
3461    fn indexed_file_count(&self) -> Result<usize> {
3462        self.indexed_file_count()
3463    }
3464    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
3465        self.node_for(file_rel, symbol)
3466    }
3467    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
3468        self.nodes_for(file_rel, symbol)
3469    }
3470    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
3471        self.nodes_matching(symbol)
3472    }
3473    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
3474        self.direct_callers_of(file_rel, symbol)
3475    }
3476    fn direct_caller_counts_of(
3477        &self,
3478        targets: &[(String, String)],
3479    ) -> Result<HashMap<(String, String), usize>> {
3480        self.direct_caller_counts_of(targets)
3481    }
3482    fn callers_of(
3483        &self,
3484        file_rel: &Path,
3485        symbol: &str,
3486        depth: usize,
3487    ) -> Result<StoreCallersResult> {
3488        self.callers_of(file_rel, symbol, depth)
3489    }
3490    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
3491        self.impact_of(file_rel, symbol, depth)
3492    }
3493    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3494        self.outgoing_calls_of(node)
3495    }
3496    fn outgoing_calls_for_symbols(
3497        &self,
3498        sources: &[(String, String)],
3499    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3500        self.outgoing_calls_for_symbols(sources)
3501    }
3502    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3503        self.resolved_self_calls_of(node)
3504    }
3505    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
3506        self.unresolved_calls_of(node)
3507    }
3508    fn call_tree(
3509        &self,
3510        file_rel: &Path,
3511        symbol: &str,
3512        depth: usize,
3513    ) -> Result<callgraph::CallTreeNode> {
3514        self.call_tree(file_rel, symbol, depth)
3515    }
3516    fn trace_to(
3517        &self,
3518        file_rel: &Path,
3519        symbol: &str,
3520        max_depth: usize,
3521    ) -> Result<callgraph::TraceToResult> {
3522        self.trace_to(file_rel, symbol, max_depth)
3523    }
3524    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
3525        self.trace_to_symbol_candidates(to_symbol)
3526    }
3527    fn trace_to_symbol(
3528        &self,
3529        file_rel: &Path,
3530        symbol: &str,
3531        to_symbol: &str,
3532        to_file: Option<&Path>,
3533        max_depth: usize,
3534    ) -> Result<callgraph::TraceToSymbolResult> {
3535        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
3536    }
3537}
3538
3539fn indexed_file_count(conn: &Connection) -> Result<usize> {
3540    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
3541    Ok(count.max(0) as usize)
3542}
3543
3544fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
3545    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
3546    match candidates.as_slice() {
3547        [candidate] => Ok(candidate.clone()),
3548        [] => Err(AftError::SymbolNotFound {
3549            name: symbol.to_string(),
3550            file: rel_path.to_string(),
3551        }
3552        .into()),
3553        _ => Err(AftError::AmbiguousSymbol {
3554            name: symbol.to_string(),
3555            candidates: candidates
3556                .iter()
3557                .map(|candidate| candidate.symbol.clone())
3558                .collect(),
3559        }
3560        .into()),
3561    }
3562}
3563
3564fn nodes_for_file_matching_symbol(
3565    conn: &Connection,
3566    rel_path: &str,
3567    symbol: &str,
3568) -> Result<Vec<StoreNode>> {
3569    let qualified_query = symbol.contains("::");
3570    let sql = if qualified_query {
3571        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3572                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3573         FROM nodes n JOIN files f ON f.path = n.file_path
3574         WHERE n.file_path = ?1 AND n.scoped_name = ?2
3575         ORDER BY n.scoped_name, n.start_line, n.start_col"
3576    } else {
3577        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3578                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3579         FROM nodes n JOIN files f ON f.path = n.file_path
3580         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
3581         ORDER BY n.scoped_name, n.start_line, n.start_col"
3582    };
3583    let mut stmt = conn.prepare(sql)?;
3584    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
3585    rows.collect::<std::result::Result<Vec<_>, _>>()
3586        .map_err(Into::into)
3587}
3588
3589fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
3590    let qualified_query = symbol.contains("::");
3591    let sql = if qualified_query {
3592        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3593                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3594         FROM nodes n JOIN files f ON f.path = n.file_path
3595         WHERE n.scoped_name = ?1
3596         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3597    } else {
3598        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
3599                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
3600         FROM nodes n JOIN files f ON f.path = n.file_path
3601         WHERE n.scoped_name = ?1 OR n.name = ?1
3602         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
3603    };
3604    let mut stmt = conn.prepare(sql)?;
3605    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
3606    rows.collect::<std::result::Result<Vec<_>, _>>()
3607        .map_err(Into::into)
3608}
3609
3610fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
3611    store_node_from_row_at(row, 0)
3612}
3613
3614fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
3615    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
3616    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
3617    let lang_label_value: String = row.get(offset + 10)?;
3618    Ok(StoreNode {
3619        node_id: row.get(offset)?,
3620        file: row.get(offset + 1)?,
3621        symbol: row.get(offset + 2)?,
3622        name: row.get(offset + 3)?,
3623        kind: row.get(offset + 4)?,
3624        line: start_line.saturating_add(1),
3625        end_line: end_line.saturating_add(1),
3626        signature: row.get(offset + 7)?,
3627        exported: row.get::<_, i64>(offset + 8)? != 0,
3628        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
3629        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
3630    })
3631}
3632
3633fn optional_store_node_from_row_at(
3634    row: &rusqlite::Row<'_>,
3635    offset: usize,
3636) -> rusqlite::Result<Option<StoreNode>> {
3637    if row.get::<_, Option<String>>(offset)?.is_some() {
3638        store_node_from_row_at(row, offset).map(Some)
3639    } else {
3640        Ok(None)
3641    }
3642}
3643
3644#[allow(clippy::too_many_arguments)]
3645fn collect_callers_recursive(
3646    conn: &Connection,
3647    file: &str,
3648    symbol: &str,
3649    max_depth: usize,
3650    current_depth: usize,
3651    visited: &mut HashSet<(String, String)>,
3652    result: &mut Vec<StoreCallSite>,
3653    depth_limited: &mut bool,
3654    truncated: &mut usize,
3655) -> Result<()> {
3656    if current_depth >= max_depth {
3657        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
3658        if omitted > 0 {
3659            *depth_limited = true;
3660            *truncated += omitted;
3661        }
3662        return Ok(());
3663    }
3664
3665    if !visited.insert((file.to_string(), symbol.to_string())) {
3666        return Ok(());
3667    }
3668
3669    let sites = direct_callers_for_tuple(conn, file, symbol)?;
3670    for site in sites {
3671        result.push(site.clone());
3672        if current_depth + 1 < max_depth {
3673            collect_callers_recursive(
3674                conn,
3675                &site.caller.file,
3676                &site.caller.symbol,
3677                max_depth,
3678                current_depth + 1,
3679                visited,
3680                result,
3681                depth_limited,
3682                truncated,
3683            )?;
3684        } else {
3685            let omitted =
3686                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
3687            if omitted > 0 {
3688                *depth_limited = true;
3689                *truncated += omitted;
3690            }
3691        }
3692    }
3693    Ok(())
3694}
3695
3696// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
3697const DIRECT_CALLER_COUNT_BATCH_SIZE: usize = 499;
3698
3699fn direct_caller_counts_for_tuples(
3700    conn: &Connection,
3701    targets: &[(String, String)],
3702) -> Result<HashMap<(String, String), usize>> {
3703    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
3704    let mut counts = unique_targets
3705        .iter()
3706        .cloned()
3707        .map(|target| (target, 0usize))
3708        .collect::<HashMap<_, _>>();
3709
3710    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
3711    for chunk in unique_targets.chunks(DIRECT_CALLER_COUNT_BATCH_SIZE) {
3712        let requested_values = (0..chunk.len())
3713            .map(|_| "(?, ?)")
3714            .collect::<Vec<_>>()
3715            .join(", ");
3716        let sql = format!(
3717            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
3718             deduped AS (
3719                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
3720                 FROM requested requested
3721                 JOIN edges e
3722                   ON e.target_file = requested.target_file
3723                  AND e.target_symbol = requested.target_symbol
3724                  AND e.kind = 'call'
3725                 JOIN refs r ON r.ref_id = e.ref_id
3726                 JOIN nodes src ON src.id = e.source_node
3727                 JOIN files src_file ON src_file.path = src.file_path
3728                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
3729             )
3730             SELECT target_file, target_symbol, COUNT(*)
3731             FROM deduped
3732             GROUP BY target_file, target_symbol"
3733        );
3734        let bindings = chunk
3735            .iter()
3736            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3737        let mut stmt = conn.prepare(&sql)?;
3738        let rows = stmt.query_map(params_from_iter(bindings), |row| {
3739            Ok((
3740                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3741                row.get::<_, i64>(2)?,
3742            ))
3743        })?;
3744        for row in rows {
3745            let (target, count) = row?;
3746            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
3747        }
3748    }
3749
3750    Ok(counts)
3751}
3752
3753fn direct_caller_count_for_tuple(
3754    conn: &Connection,
3755    target_file: &str,
3756    target_symbol: &str,
3757) -> Result<usize> {
3758    let count: i64 = conn.query_row(
3759        "SELECT COUNT(*)
3760         FROM edges e
3761         JOIN refs r ON r.ref_id = e.ref_id
3762         JOIN nodes src ON src.id = e.source_node
3763         JOIN files src_file ON src_file.path = src.file_path
3764         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
3765        params![target_file, target_symbol],
3766        |row| row.get(0),
3767    )?;
3768    Ok(usize::try_from(count).unwrap_or(usize::MAX))
3769}
3770
3771fn direct_callers_for_tuple(
3772    conn: &Connection,
3773    target_file: &str,
3774    target_symbol: &str,
3775) -> Result<Vec<StoreCallSite>> {
3776    let mut stmt = conn.prepare(
3777        "SELECT e.target_file, e.target_symbol, e.line,
3778                r.byte_start, r.byte_end, r.status, e.provenance,
3779                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
3780                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
3781                src_file.lang,
3782                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3783                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3784                tgt_file.lang
3785         FROM edges e
3786         JOIN refs r ON r.ref_id = e.ref_id
3787         JOIN nodes src ON src.id = e.source_node
3788         JOIN files src_file ON src_file.path = src.file_path
3789         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3790             ON tgt.id = e.target_node
3791         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
3792         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
3793    )?;
3794    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
3795        let caller = store_node_from_row_at(row, 7)?;
3796        let target = optional_store_node_from_row_at(row, 18)?;
3797        Ok(StoreCallSite {
3798            caller,
3799            target_file: row.get(0)?,
3800            target_symbol: row.get(1)?,
3801            target,
3802            line: row.get::<_, i64>(2)?.max(0) as u32,
3803            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3804            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3805            resolved: row.get::<_, String>(5)? == "resolved",
3806            provenance: row.get(6)?,
3807        })
3808    })?;
3809    rows.collect::<std::result::Result<Vec<_>, _>>()
3810        .map_err(Into::into)
3811}
3812
3813// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
3814const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
3815// Outgoing-edge batches bind one source node per parameter.
3816const OUTGOING_NODE_BATCH_SIZE: usize = 999;
3817
3818fn outgoing_calls_for_symbol_tuples(
3819    conn: &Connection,
3820    sources: &[(String, String)],
3821) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
3822    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
3823    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
3824    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
3825    let source_nodes = unique_sources
3826        .iter()
3827        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
3828        .cloned()
3829        .collect::<Vec<_>>();
3830    let source_nodes_by_id = source_nodes
3831        .iter()
3832        .cloned()
3833        .map(|node| (node.node_id.clone(), node))
3834        .collect::<HashMap<_, _>>();
3835    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
3836
3837    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
3838        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
3839        let sql = format!(
3840            "SELECT e.source_node,
3841                    e.target_file, e.target_symbol, e.line,
3842                    r.byte_start, r.byte_end, r.status, e.provenance,
3843                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
3844                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3845                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3846                    tgt_file.lang
3847             FROM edges e
3848             JOIN refs r ON r.ref_id = e.ref_id
3849             LEFT JOIN nodes tgt ON tgt.id = e.target_node
3850             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
3851             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
3852             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
3853        );
3854        let bindings = chunk.iter().map(|node| node.node_id.as_str());
3855        let mut stmt = conn.prepare(&sql)?;
3856        let rows = stmt.query_map(params_from_iter(bindings), |row| {
3857            let source_node_id = row.get::<_, String>(0)?;
3858            let caller = source_nodes_by_id
3859                .get(&source_node_id)
3860                .expect("batched outgoing row belongs to a requested source node")
3861                .clone();
3862            let target = optional_store_node_from_row_at(row, 8)?;
3863            Ok((
3864                source_node_id,
3865                StoreCallSite {
3866                    caller,
3867                    target_file: row.get(1)?,
3868                    target_symbol: row.get(2)?,
3869                    target,
3870                    line: row.get::<_, i64>(3)?.max(0) as u32,
3871                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
3872                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
3873                    resolved: row.get::<_, String>(6)? == "resolved",
3874                    provenance: row.get(7)?,
3875                },
3876            ))
3877        })?;
3878        for row in rows {
3879            let (source_node_id, call) = row?;
3880            calls_by_node.entry(source_node_id).or_default().push(call);
3881        }
3882    }
3883
3884    let mut calls_by_source = HashMap::new();
3885    for source in &unique_sources {
3886        let mut calls = Vec::new();
3887        if let Some(nodes) = source_nodes_by_symbol.get(source) {
3888            for node in nodes {
3889                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
3890                    calls.extend(node_calls);
3891                }
3892            }
3893        }
3894        calls_by_source.insert(source.clone(), calls);
3895    }
3896
3897    // Resolve each logical target once for the whole frontier. Keeping this separate
3898    // preserves positional-symbol representatives without a correlated lookup per edge.
3899    let target_tuples = calls_by_source
3900        .values()
3901        .flatten()
3902        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
3903        .collect::<Vec<_>>();
3904    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
3905    for calls in calls_by_source.values_mut() {
3906        for call in calls {
3907            if let Some(target) = target_nodes
3908                .get(&(call.target_file.clone(), call.target_symbol.clone()))
3909                .and_then(|nodes| nodes.first())
3910            {
3911                call.target = Some(target.clone());
3912            }
3913        }
3914    }
3915
3916    Ok(calls_by_source)
3917}
3918
3919fn nodes_for_symbol_tuples(
3920    conn: &Connection,
3921    symbols: &[(String, String)],
3922) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
3923    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
3924    let mut nodes_by_symbol = unique_symbols
3925        .iter()
3926        .cloned()
3927        .map(|symbol| (symbol, Vec::new()))
3928        .collect::<HashMap<_, _>>();
3929    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
3930
3931    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
3932        let requested_values = (0..chunk.len())
3933            .map(|_| "(?, ?)")
3934            .collect::<Vec<_>>()
3935            .join(", ");
3936        let sql = format!(
3937            "WITH requested(file, symbol) AS (VALUES {requested_values})
3938             SELECT requested.file, requested.symbol,
3939                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
3940                    node.start_line, node.end_line, node.signature, node.exported,
3941                    node.is_callgraph_entry_point, node_file.lang
3942             FROM requested
3943             JOIN nodes node INDEXED BY idx_nodes_file
3944               ON node.file_path = requested.file
3945              AND node.scoped_name = requested.symbol
3946             JOIN files node_file ON node_file.path = node.file_path
3947             ORDER BY requested.file, requested.symbol,
3948                      node.scoped_name, node.start_line, node.end_line,
3949                      node.start_col, node.range_ordinal"
3950        );
3951        let bindings = chunk
3952            .iter()
3953            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
3954        let mut stmt = conn.prepare(&sql)?;
3955        let rows = stmt.query_map(params_from_iter(bindings), |row| {
3956            Ok((
3957                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
3958                store_node_from_row_at(row, 2)?,
3959            ))
3960        })?;
3961        for row in rows {
3962            let (symbol, node) = row?;
3963            nodes_by_symbol.entry(symbol).or_default().push(node);
3964        }
3965    }
3966
3967    Ok(nodes_by_symbol)
3968}
3969
3970fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
3971    let mut stmt = conn.prepare(
3972        "SELECT e.target_file, e.target_symbol, e.line,
3973                r.byte_start, r.byte_end, r.status, e.provenance,
3974                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
3975                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
3976                tgt_file.lang
3977         FROM edges e
3978         JOIN refs r ON r.ref_id = e.ref_id
3979         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
3980             ON tgt.id = e.target_node
3981         WHERE e.kind = 'call' AND e.source_node = ?1
3982         ORDER BY r.byte_start, r.line, r.ref_id",
3983    )?;
3984    let rows = stmt.query_map(params![node.node_id], |row| {
3985        let target = optional_store_node_from_row_at(row, 7)?;
3986        Ok(StoreCallSite {
3987            caller: node.clone(),
3988            target_file: row.get(0)?,
3989            target_symbol: row.get(1)?,
3990            target,
3991            line: row.get::<_, i64>(2)?.max(0) as u32,
3992            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
3993            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
3994            resolved: row.get::<_, String>(5)? == "resolved",
3995            provenance: row.get(6)?,
3996        })
3997    })?;
3998    rows.collect::<std::result::Result<Vec<_>, _>>()
3999        .map_err(Into::into)
4000}
4001
4002fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4003    let mut stmt = conn.prepare(
4004        "SELECT r.target_file, r.target_symbol, r.line,
4005                r.byte_start, r.byte_end, r.status, r.provenance,
4006                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
4007                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
4008                tgt_file.lang
4009         FROM refs r
4010         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
4011             ON tgt.id = r.target_node
4012         WHERE r.caller_node = ?1
4013           AND r.kind = 'call'
4014           AND r.status <> 'unresolved'
4015           AND r.target_file = ?2
4016           AND r.target_symbol = ?3
4017           AND r.provenance = ?4
4018           AND NOT EXISTS (
4019               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4020           )
4021         ORDER BY r.byte_start, r.line, r.ref_id",
4022    )?;
4023    let rows = stmt.query_map(
4024        params![
4025            &node.node_id,
4026            &node.file,
4027            &node.symbol,
4028            PROVENANCE_TREESITTER
4029        ],
4030        |row| {
4031            let target = optional_store_node_from_row_at(row, 7)?;
4032            Ok(StoreCallSite {
4033                caller: node.clone(),
4034                target_file: row.get(0)?,
4035                target_symbol: row.get(1)?,
4036                target,
4037                line: row.get::<_, i64>(2)?.max(0) as u32,
4038                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4039                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4040                resolved: row.get::<_, String>(5)? == "resolved",
4041                provenance: row.get(6)?,
4042            })
4043        },
4044    )?;
4045    rows.collect::<std::result::Result<Vec<_>, _>>()
4046        .map_err(Into::into)
4047}
4048
4049fn unresolved_calls_for_node(
4050    conn: &Connection,
4051    node: &StoreNode,
4052) -> Result<Vec<StoreUnresolvedCall>> {
4053    let mut stmt = conn.prepare(
4054        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
4055         FROM refs
4056         WHERE caller_node = ?1
4057           AND kind = 'call'
4058           AND status = 'unresolved'
4059           AND NOT EXISTS (
4060               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4061           )
4062         ORDER BY byte_start, line, ref_id",
4063    )?;
4064    let rows = stmt.query_map(params![node.node_id], |row| {
4065        Ok(StoreUnresolvedCall {
4066            caller: node.clone(),
4067            symbol: row.get(0)?,
4068            full_ref: row.get(1)?,
4069            line: row.get::<_, i64>(2)?.max(0) as u32,
4070            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
4071            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
4072        })
4073    })?;
4074    rows.collect::<std::result::Result<Vec<_>, _>>()
4075        .map_err(Into::into)
4076}
4077
4078fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
4079    let mut calls = Vec::new();
4080    calls.extend(
4081        outgoing_calls_for_node(conn, node)?
4082            .into_iter()
4083            .map(StoreForwardCall::Resolved),
4084    );
4085    calls.extend(
4086        unresolved_calls_for_node(conn, node)?
4087            .into_iter()
4088            .map(StoreForwardCall::Unresolved),
4089    );
4090    calls.sort_by(|left, right| {
4091        left.byte_start()
4092            .cmp(&right.byte_start())
4093            .then(left.line().cmp(&right.line()))
4094    });
4095    Ok(calls)
4096}
4097
4098fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
4099    let resolved_count: i64 = conn.query_row(
4100        "SELECT COUNT(*)
4101         FROM edges e
4102         JOIN refs r ON r.ref_id = e.ref_id
4103         WHERE e.kind = 'call' AND e.source_node = ?1",
4104        params![&node.node_id],
4105        |row| row.get(0),
4106    )?;
4107    let unresolved_count: i64 = conn.query_row(
4108        "SELECT COUNT(*)
4109         FROM refs
4110         WHERE caller_node = ?1
4111           AND kind = 'call'
4112           AND status = 'unresolved'
4113           AND NOT EXISTS (
4114               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
4115           )",
4116        params![&node.node_id],
4117        |row| row.get(0),
4118    )?;
4119    let total = resolved_count.saturating_add(unresolved_count);
4120    Ok(usize::try_from(total).unwrap_or(usize::MAX))
4121}
4122
4123fn call_tree_inner(
4124    conn: &Connection,
4125    node: &StoreNode,
4126    max_depth: usize,
4127    current_depth: usize,
4128    visited: &mut HashSet<(String, String)>,
4129) -> Result<callgraph::CallTreeNode> {
4130    let visit_key = (node.file.clone(), node.symbol.clone());
4131    if visited.contains(&visit_key) {
4132        return Ok(callgraph::CallTreeNode {
4133            name: node.symbol.clone(),
4134            file: node.file.clone(),
4135            line: node.line,
4136            signature: node.signature.clone(),
4137            resolved: true,
4138            children: Vec::new(),
4139            depth_limited: false,
4140            truncated: 0,
4141        });
4142    }
4143    visited.insert(visit_key.clone());
4144
4145    let mut children = Vec::new();
4146    let mut depth_limited = false;
4147    let mut truncated = 0usize;
4148
4149    if current_depth < max_depth {
4150        let calls = forward_calls_for_node(conn, node)?;
4151        for call in calls {
4152            match call {
4153                StoreForwardCall::Resolved(site) => {
4154                    if let Some(target) = site.target {
4155                        let child =
4156                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
4157                        depth_limited |= child.depth_limited;
4158                        truncated += child.truncated;
4159                        children.push(child);
4160                    } else {
4161                        children.push(callgraph::CallTreeNode {
4162                            name: site.target_symbol,
4163                            file: site.target_file,
4164                            line: site.line,
4165                            signature: None,
4166                            resolved: false,
4167                            children: Vec::new(),
4168                            depth_limited: false,
4169                            truncated: 0,
4170                        });
4171                    }
4172                }
4173                StoreForwardCall::Unresolved(call) => {
4174                    children.push(callgraph::CallTreeNode {
4175                        name: call.symbol,
4176                        file: call.caller.file,
4177                        line: call.line,
4178                        signature: None,
4179                        resolved: false,
4180                        children: Vec::new(),
4181                        depth_limited: false,
4182                        truncated: 0,
4183                    });
4184                }
4185            }
4186        }
4187    } else {
4188        truncated = forward_call_count_for_node(conn, node)?;
4189        depth_limited = truncated > 0;
4190    }
4191
4192    visited.remove(&visit_key);
4193    Ok(callgraph::CallTreeNode {
4194        name: node.symbol.clone(),
4195        file: node.file.clone(),
4196        line: node.line,
4197        signature: node.signature.clone(),
4198        resolved: true,
4199        children,
4200        depth_limited,
4201        truncated,
4202    })
4203}
4204
4205fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
4206    callgraph::TraceToSymbolHop {
4207        symbol: node.symbol.clone(),
4208        file: node.file.clone(),
4209        line: node.line,
4210    }
4211}
4212
4213fn trace_to_symbol_matches_target(
4214    node: &StoreNode,
4215    to_symbol: &str,
4216    to_file: Option<&str>,
4217) -> bool {
4218    if !symbol_query_matches(&node.symbol, to_symbol) {
4219        return false;
4220    }
4221    match to_file {
4222        Some(file) => node.file == file,
4223        None => true,
4224    }
4225}
4226
4227fn symbol_query_matches(symbol: &str, query: &str) -> bool {
4228    symbol == query || unqualified_name(symbol) == query
4229}
4230
4231fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
4232    let source = std::fs::read_to_string(path).ok()?;
4233    Some(source.lines().map(|line| line.trim().to_string()).collect())
4234}
4235
4236#[doc(hidden)]
4237pub fn live_callgraph_edge_snapshot(
4238    project_root: &Path,
4239    files: &[PathBuf],
4240) -> Result<BTreeSet<StoredEdge>> {
4241    let files = normalize_file_list(project_root, files)?;
4242    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
4243    let mut file_data = Vec::new();
4244    for file in &files {
4245        let canon = canonicalize_path(file);
4246        let data = graph.build_file(&canon)?.clone();
4247        file_data.push((canon, data));
4248    }
4249
4250    let mut edges = BTreeSet::new();
4251    for (caller_file, data) in &file_data {
4252        for (caller_symbol, call_sites) in &data.calls_by_symbol {
4253            for call_site in call_sites {
4254                let resolution = graph.resolve_cross_file_edge(
4255                    &call_site.full_callee,
4256                    &call_site.callee_name,
4257                    caller_file,
4258                    &data.import_block,
4259                );
4260                let (target_file, target_symbol) = match resolution {
4261                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
4262                    EdgeResolution::Unresolved { callee_name } => {
4263                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
4264                            continue;
4265                        }
4266                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
4267                            data,
4268                            caller_file,
4269                            &callee_name,
4270                        ) else {
4271                            continue;
4272                        };
4273                        (caller_file.clone(), target_symbol)
4274                    }
4275                };
4276                if target_file == *caller_file && target_symbol == *caller_symbol {
4277                    continue;
4278                }
4279                edges.insert(StoredEdge {
4280                    source_file: relative_path(project_root, caller_file),
4281                    source_symbol: caller_symbol.clone(),
4282                    target_file: relative_path(project_root, &target_file),
4283                    target_symbol,
4284                    kind: "call".to_string(),
4285                    line: call_site.line,
4286                });
4287            }
4288        }
4289    }
4290    Ok(edges)
4291}
4292
4293fn acquire_writer_lease(
4294    callgraph_dir: &Path,
4295    project_key: &str,
4296    project_root: &Path,
4297) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
4298    crate::root_cache::WriterLease::acquire_shared(
4299        crate::root_cache::RootCacheDomain::Callgraph,
4300        callgraph_dir,
4301        project_key,
4302        project_root,
4303    )
4304    .map_err(CallGraphStoreError::from)
4305}
4306
4307fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
4308    if lease.verify()? {
4309        Ok(())
4310    } else {
4311        Err(CallGraphStoreError::Unavailable(format!(
4312            "callgraph writer lease for key {} lost epoch {}; aborting write",
4313            lease.key(),
4314            lease.epoch()
4315        )))
4316    }
4317}
4318
4319fn legacy_migration_completion_line(
4320    project_key: &str,
4321    method: &str,
4322    legacy_bytes: u64,
4323    migrated_bytes: u64,
4324) -> String {
4325    format!(
4326        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
4327    )
4328}
4329
4330fn log_legacy_migration_completion(
4331    project_key: &str,
4332    method: &str,
4333    legacy_bytes: u64,
4334    migrated_bytes: u64,
4335) {
4336    crate::slog_info!(
4337        "{}",
4338        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
4339    );
4340}
4341
4342fn try_legacy_migration_or_fallback(
4343    callgraph_dir: &Path,
4344    project_root: &Path,
4345    project_key: &str,
4346    writer_lease: Arc<crate::root_cache::WriterLease>,
4347) -> Result<Option<CallGraphStore>> {
4348    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4349    if partitions.is_empty() {
4350        return Ok(None);
4351    }
4352
4353    for partition in &partitions {
4354        if let Some(source) = newest_superseded_legacy_generation(partition)? {
4355            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4356                return open_legacy_fallback_store(
4357                    callgraph_dir,
4358                    project_root,
4359                    project_key,
4360                    &partitions,
4361                );
4362            }
4363            match publish_generation_copy_migration(
4364                callgraph_dir,
4365                project_key,
4366                &source,
4367                Arc::clone(&writer_lease),
4368            ) {
4369                Ok(published) => {
4370                    log_legacy_migration_completion(
4371                        project_key,
4372                        "generation_copy",
4373                        source.source_bytes,
4374                        published.migrated_bytes,
4375                    );
4376                    return CallGraphStore::open_generation(
4377                        callgraph_dir,
4378                        project_root.to_path_buf(),
4379                        project_key.to_string(),
4380                        published.generation,
4381                        writer_lease,
4382                    )
4383                    .map(Some);
4384                }
4385                Err(error) => {
4386                    crate::slog_warn!(
4387                        "root-keyed callgraph generation-copy migration failed from {}: {}",
4388                        source.sqlite_path.display(),
4389                        error
4390                    );
4391                    return open_legacy_fallback_store(
4392                        callgraph_dir,
4393                        project_root,
4394                        project_key,
4395                        &partitions,
4396                    );
4397                }
4398            }
4399        }
4400
4401        if let Some(source) = current_legacy_generation(partition)? {
4402            if !migration_disk_floor_allows(&source, callgraph_dir)? {
4403                return open_legacy_fallback_store(
4404                    callgraph_dir,
4405                    project_root,
4406                    project_key,
4407                    &partitions,
4408                );
4409            }
4410            match publish_backup_migration(
4411                callgraph_dir,
4412                project_key,
4413                &source,
4414                Arc::clone(&writer_lease),
4415            ) {
4416                Ok(published) => {
4417                    log_legacy_migration_completion(
4418                        project_key,
4419                        "sqlite_backup",
4420                        source.source_bytes,
4421                        published.migrated_bytes,
4422                    );
4423                    return CallGraphStore::open_generation(
4424                        callgraph_dir,
4425                        project_root.to_path_buf(),
4426                        project_key.to_string(),
4427                        published.generation,
4428                        writer_lease,
4429                    )
4430                    .map(Some);
4431                }
4432                Err(error) => {
4433                    crate::slog_warn!(
4434                        "root-keyed callgraph backup migration failed from {}: {}",
4435                        source.sqlite_path.display(),
4436                        error
4437                    );
4438                    return open_legacy_fallback_store(
4439                        callgraph_dir,
4440                        project_root,
4441                        project_key,
4442                        &partitions,
4443                    );
4444                }
4445            }
4446        }
4447    }
4448
4449    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
4450}
4451
4452fn open_legacy_fallback_store(
4453    callgraph_dir: &Path,
4454    project_root: &Path,
4455    project_key: &str,
4456    partitions: &[LegacyCallgraphPartition],
4457) -> Result<Option<CallGraphStore>> {
4458    let Some(target) = first_ready_legacy_target(partitions)? else {
4459        return Ok(None);
4460    };
4461    crate::slog_warn!(
4462        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
4463        target.partition.harness,
4464        target.sqlite_path.display()
4465    );
4466    let conn = open_readonly_connection(&target.sqlite_path)?;
4467    if !database_ready(&conn).unwrap_or(false) {
4468        return Ok(None);
4469    }
4470    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
4471    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
4472    Ok(Some(CallGraphStore::from_connection(
4473        project_root.to_path_buf(),
4474        project_key.to_string(),
4475        target.sqlite_path,
4476        callgraph_dir.to_path_buf(),
4477        true,
4478        target.generation,
4479        None,
4480        Some(read_marker),
4481        conn,
4482    )))
4483}
4484
4485fn migration_disk_floor_allows(
4486    source: &LegacyCallgraphTarget,
4487    callgraph_dir: &Path,
4488) -> Result<bool> {
4489    let available = migration_available_disk(callgraph_dir)?;
4490    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
4491        source.source_bytes,
4492        available,
4493    );
4494    if decision.should_skip_copy() {
4495        crate::slog_warn!(
4496            "{}",
4497            decision.warning_message(&source.sqlite_path, callgraph_dir)
4498        );
4499        return Ok(false);
4500    }
4501    Ok(true)
4502}
4503
4504fn migration_available_disk(path: &Path) -> Result<u64> {
4505    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
4506        return Ok(bytes);
4507    }
4508    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
4509}
4510
4511fn legacy_callgraph_partitions(
4512    callgraph_dir: &Path,
4513    project_key: &str,
4514) -> Result<Vec<LegacyCallgraphPartition>> {
4515    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4516        return Ok(Vec::new());
4517    };
4518    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
4519    let mut partitions = inventory
4520        .into_iter()
4521        .filter(|entry| {
4522            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4523                && entry.key == project_key
4524        })
4525        .map(|entry| {
4526            let dir = if entry.path.is_dir() {
4527                entry.path.clone()
4528            } else {
4529                entry
4530                    .path
4531                    .parent()
4532                    .map(Path::to_path_buf)
4533                    .unwrap_or_else(|| entry.path.clone())
4534            };
4535            LegacyCallgraphPartition {
4536                harness: entry.harness,
4537                dir,
4538                key: entry.key,
4539                bytes: entry.bytes,
4540                freshness: entry.callgraph_pointer_mtime,
4541            }
4542        })
4543        .collect::<Vec<_>>();
4544    partitions.sort_by(|left, right| {
4545        right
4546            .freshness
4547            .cmp(&left.freshness)
4548            .then_with(|| right.bytes.cmp(&left.bytes))
4549            .then_with(|| left.harness.cmp(&right.harness))
4550    });
4551    Ok(partitions)
4552}
4553
4554fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
4555    let domain_dir = callgraph_dir.parent()?;
4556    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
4557        return None;
4558    }
4559    domain_dir.parent().map(Path::to_path_buf)
4560}
4561
4562pub(crate) fn all_legacy_partitions_migrated_for_keys(
4563    callgraph_dir: &Path,
4564    configured_keys: &BTreeSet<String>,
4565) -> Result<bool> {
4566    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
4567        return Ok(false);
4568    };
4569    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
4570        .into_iter()
4571        .filter(|entry| {
4572            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
4573                && configured_keys.contains(&entry.key)
4574        })
4575        .map(|entry| entry.key)
4576        .collect::<BTreeSet<_>>();
4577    if legacy_keys.is_empty() {
4578        return Ok(false);
4579    }
4580
4581    for key in legacy_keys {
4582        let migrated_dir = storage_root.join("callgraph").join(&key);
4583        let Some(generation) = read_pointer(&migrated_dir, &key) else {
4584            return Ok(false);
4585        };
4586        if !migration_generation_requires_manifest(&generation)
4587            || !migration_manifest_valid(&migrated_dir, &generation)
4588        {
4589            return Ok(false);
4590        }
4591    }
4592    Ok(true)
4593}
4594
4595fn newest_superseded_legacy_generation(
4596    partition: &LegacyCallgraphPartition,
4597) -> Result<Option<LegacyCallgraphTarget>> {
4598    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
4599        return Ok(None);
4600    };
4601    let prefix = format!("{}.g", partition.key);
4602    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
4603        return Ok(None);
4604    };
4605    let mut candidates = Vec::new();
4606    for entry in entries.flatten() {
4607        let name = entry.file_name().to_string_lossy().to_string();
4608        if name == current
4609            || name.contains(".tmp.")
4610            || !name.starts_with(&prefix)
4611            || !name.ends_with(".sqlite")
4612        {
4613            continue;
4614        }
4615        let path = entry.path();
4616        if !db_path_ready(&path) {
4617            continue;
4618        }
4619        let modified = entry
4620            .metadata()
4621            .and_then(|metadata| metadata.modified())
4622            .unwrap_or(SystemTime::UNIX_EPOCH);
4623        candidates.push((modified, path, name));
4624    }
4625    candidates.sort_by(|left, right| right.0.cmp(&left.0));
4626    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
4627        return Ok(None);
4628    };
4629    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4630    Ok(Some(LegacyCallgraphTarget {
4631        partition: partition.clone(),
4632        sqlite_path,
4633        generation: Some(generation),
4634        source_bytes,
4635        source_blake3: String::new(),
4636    }))
4637}
4638
4639fn current_legacy_generation(
4640    partition: &LegacyCallgraphPartition,
4641) -> Result<Option<LegacyCallgraphTarget>> {
4642    let Some(target) = ready_legacy_target(partition)? else {
4643        return Ok(None);
4644    };
4645    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
4646    if has_superseded {
4647        return Ok(None);
4648    }
4649    Ok(Some(target))
4650}
4651
4652fn freshest_legacy_fallback_target(
4653    callgraph_dir: &Path,
4654    project_key: &str,
4655) -> Result<Option<LegacyCallgraphTarget>> {
4656    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
4657    first_ready_legacy_target(&partitions)
4658}
4659
4660fn first_ready_legacy_target(
4661    partitions: &[LegacyCallgraphPartition],
4662) -> Result<Option<LegacyCallgraphTarget>> {
4663    for partition in partitions {
4664        if let Some(target) = ready_legacy_target(partition)? {
4665            return Ok(Some(target));
4666        }
4667    }
4668    Ok(None)
4669}
4670
4671fn ready_legacy_target(
4672    partition: &LegacyCallgraphPartition,
4673) -> Result<Option<LegacyCallgraphTarget>> {
4674    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
4675        let sqlite_path = partition.dir.join(&generation);
4676        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4677            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4678            return Ok(Some(LegacyCallgraphTarget {
4679                partition: partition.clone(),
4680                sqlite_path,
4681                generation: Some(generation),
4682                source_bytes,
4683                source_blake3: String::new(),
4684            }));
4685        }
4686    }
4687
4688    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
4689    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
4690        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
4691        return Ok(Some(LegacyCallgraphTarget {
4692            partition: partition.clone(),
4693            sqlite_path,
4694            generation: None,
4695            source_bytes,
4696            source_blake3: String::new(),
4697        }));
4698    }
4699    Ok(None)
4700}
4701
4702fn publish_generation_copy_migration(
4703    callgraph_dir: &Path,
4704    project_key: &str,
4705    source: &LegacyCallgraphTarget,
4706    writer_lease: Arc<crate::root_cache::WriterLease>,
4707) -> Result<PublishedLegacyMigration> {
4708    let generation = migration_generation_file_name(project_key, "copy");
4709    let temp_path = migration_temp_path(callgraph_dir, &generation);
4710    remove_sqlite_file_set(&temp_path);
4711    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
4712    fail_after_temp_copy_for_test()?;
4713
4714    let mut source = source.clone();
4715    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4716    source.source_blake3 = fingerprint.blake3;
4717    let generation = publish_migrated_generation(
4718        callgraph_dir,
4719        project_key,
4720        &generation,
4721        &temp_path,
4722        &source,
4723        fingerprint.bytes,
4724        writer_lease,
4725        "generation_copy",
4726    )?;
4727    Ok(PublishedLegacyMigration {
4728        generation,
4729        migrated_bytes: fingerprint.bytes,
4730    })
4731}
4732
4733fn publish_backup_migration(
4734    callgraph_dir: &Path,
4735    project_key: &str,
4736    source: &LegacyCallgraphTarget,
4737    writer_lease: Arc<crate::root_cache::WriterLease>,
4738) -> Result<PublishedLegacyMigration> {
4739    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
4740        return Err(CallGraphStoreError::Unavailable(
4741            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
4742        ));
4743    }
4744
4745    let generation = migration_generation_file_name(project_key, "backup");
4746    let temp_path = migration_temp_path(callgraph_dir, &generation);
4747    remove_sqlite_file_set(&temp_path);
4748
4749    let source_conn = open_readonly_connection(&source.sqlite_path)?;
4750    let mut destination = Connection::open(&temp_path)?;
4751    destination.busy_timeout(Duration::from_secs(5))?;
4752    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
4753    let started = Instant::now();
4754    let mut retries = 0;
4755    loop {
4756        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
4757            rusqlite::backup::StepResult::Done => break,
4758            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
4759            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
4760                retries += 1;
4761                if retries > MIGRATION_BACKUP_RETRY_BUDGET
4762                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
4763                {
4764                    return Err(CallGraphStoreError::Unavailable(format!(
4765                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
4766                    )));
4767                }
4768                std::thread::sleep(Duration::from_millis(20));
4769            }
4770            _ => {
4771                return Err(CallGraphStoreError::Unavailable(
4772                    "legacy callgraph backup returned an unknown step result".to_string(),
4773                ));
4774            }
4775        }
4776    }
4777    drop(backup);
4778
4779    let integrity: String =
4780        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
4781    if integrity != "ok" {
4782        return Err(CallGraphStoreError::Unavailable(format!(
4783            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
4784        )));
4785    }
4786    if !database_ready(&destination)? {
4787        return Err(CallGraphStoreError::Unavailable(
4788            "legacy callgraph backup produced a database without ready metadata".to_string(),
4789        ));
4790    }
4791    destination.execute_batch("PRAGMA optimize;")?;
4792    drop(destination);
4793    sync_file(&temp_path)?;
4794    fail_after_temp_copy_for_test()?;
4795
4796    let mut source = source.clone();
4797    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
4798    source.source_blake3 = fingerprint.blake3;
4799    let generation = publish_migrated_generation(
4800        callgraph_dir,
4801        project_key,
4802        &generation,
4803        &temp_path,
4804        &source,
4805        fingerprint.bytes,
4806        writer_lease,
4807        "sqlite_backup",
4808    )?;
4809    Ok(PublishedLegacyMigration {
4810        generation,
4811        migrated_bytes: fingerprint.bytes,
4812    })
4813}
4814
4815fn publish_migrated_generation(
4816    callgraph_dir: &Path,
4817    project_key: &str,
4818    generation: &str,
4819    temp_path: &Path,
4820    source: &LegacyCallgraphTarget,
4821    migrated_bytes: u64,
4822    writer_lease: Arc<crate::root_cache::WriterLease>,
4823    method: &str,
4824) -> Result<String> {
4825    let gen_path = callgraph_dir.join(generation);
4826    let publication = publish_if_current(|| {
4827        verify_writer_lease(&writer_lease)?;
4828        remove_sqlite_file_set(&gen_path);
4829        rename_sqlite_file_set(temp_path, &gen_path)?;
4830        crate::fs_lock::sync_parent(&gen_path);
4831
4832        verify_writer_lease(&writer_lease)?;
4833        publish_pointer(callgraph_dir, project_key, generation)?;
4834        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
4835        Ok(generation.to_string())
4836    });
4837    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
4838        remove_sqlite_file_set(temp_path);
4839    }
4840    publication
4841}
4842
4843fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4844    if let Some(parent) = destination.parent() {
4845        std::fs::create_dir_all(parent)?;
4846    }
4847    for suffix in SQLITE_FILE_SET_SUFFIXES {
4848        let source_path = sqlite_file_set_path(source, suffix);
4849        if !source_path.is_file() {
4850            continue;
4851        }
4852        let destination_path = sqlite_file_set_path(destination, suffix);
4853        std::fs::copy(&source_path, &destination_path)?;
4854        sync_file(&destination_path)?;
4855    }
4856    Ok(())
4857}
4858
4859fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
4860    for suffix in SQLITE_FILE_SET_SUFFIXES {
4861        let source_path = sqlite_file_set_path(source, suffix);
4862        if !source_path.exists() {
4863            continue;
4864        }
4865        let destination_path = sqlite_file_set_path(destination, suffix);
4866        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
4867            let _ = std::fs::remove_file(&source_path);
4868            return Err(error.into());
4869        }
4870    }
4871    Ok(())
4872}
4873
4874fn sqlite_file_set_size(path: &Path) -> Result<u64> {
4875    let mut bytes = 0_u64;
4876    for suffix in SQLITE_FILE_SET_SUFFIXES {
4877        let member = sqlite_file_set_path(path, suffix);
4878        if !member.is_file() {
4879            continue;
4880        }
4881        bytes = bytes.saturating_add(member.metadata()?.len());
4882    }
4883    Ok(bytes)
4884}
4885
4886fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
4887    let mut hasher = blake3::Hasher::new();
4888    let mut bytes = 0_u64;
4889    let mut buffer = [0_u8; 64 * 1024];
4890    for suffix in SQLITE_FILE_SET_SUFFIXES {
4891        let member = sqlite_file_set_path(path, suffix);
4892        if !member.is_file() {
4893            continue;
4894        }
4895        hasher.update(suffix.as_bytes());
4896        let mut file = std::fs::File::open(&member)?;
4897        loop {
4898            let read = file.read(&mut buffer)?;
4899            if read == 0 {
4900                break;
4901            }
4902            bytes = bytes.saturating_add(read as u64);
4903            hasher.update(&buffer[..read]);
4904        }
4905    }
4906    Ok(SourceFingerprint {
4907        bytes,
4908        blake3: hash_to_hex(hasher.finalize()),
4909    })
4910}
4911
4912fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
4913    if suffix.is_empty() {
4914        path.to_path_buf()
4915    } else {
4916        PathBuf::from(format!("{}{suffix}", path.display()))
4917    }
4918}
4919
4920fn sync_file(path: &Path) -> Result<()> {
4921    let file = std::fs::OpenOptions::new()
4922        .read(true)
4923        .write(true)
4924        .open(path)?;
4925    file.sync_all()?;
4926    Ok(())
4927}
4928
4929fn fail_after_temp_copy_for_test() -> Result<()> {
4930    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
4931        return Err(CallGraphStoreError::Unavailable(
4932            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
4933        ));
4934    }
4935    Ok(())
4936}
4937
4938fn migration_generation_file_name(project_key: &str, method: &str) -> String {
4939    format!(
4940        "{project_key}.g{}.{}{}{}.sqlite",
4941        now_nanos(),
4942        std::process::id(),
4943        MIGRATION_GENERATION_TAG,
4944        method
4945    )
4946}
4947
4948fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4949    callgraph_dir.join(format!(
4950        "{generation}.tmp.{}.{}",
4951        std::process::id(),
4952        now_nanos()
4953    ))
4954}
4955
4956fn write_migration_manifest(
4957    callgraph_dir: &Path,
4958    generation: &str,
4959    source: &LegacyCallgraphTarget,
4960    migrated_bytes: u64,
4961    method: &str,
4962) -> Result<()> {
4963    let manifest_path = migration_manifest_path(callgraph_dir, generation);
4964    let temp_path = manifest_path.with_extension(format!(
4965        "migration.json.tmp.{}.{}",
4966        std::process::id(),
4967        now_nanos()
4968    ));
4969    let manifest = serde_json::json!({
4970        "version": MIGRATION_MANIFEST_VERSION,
4971        "method": method,
4972        "target_generation": generation,
4973        "source_harness": source.partition.harness,
4974        "source_path": source.sqlite_path.display().to_string(),
4975        "source_generation": source.generation,
4976        "source_bytes": source.source_bytes,
4977        "source_blake3": source.source_blake3,
4978        "migrated_bytes": migrated_bytes,
4979    });
4980    {
4981        use std::io::Write as _;
4982        let mut file = std::fs::File::create(&temp_path)?;
4983        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
4984        file.write_all(b"\n")?;
4985        file.sync_all()?;
4986    }
4987    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
4988        let _ = std::fs::remove_file(&temp_path);
4989        return Err(error.into());
4990    }
4991    crate::fs_lock::sync_parent(&manifest_path);
4992    Ok(())
4993}
4994
4995fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
4996    callgraph_dir.join(format!("{generation}.migration.json"))
4997}
4998
4999fn migration_generation_requires_manifest(generation: &str) -> bool {
5000    generation.contains(MIGRATION_GENERATION_TAG)
5001}
5002
5003fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
5004    if !migration_generation_requires_manifest(generation) {
5005        return true;
5006    }
5007    let path = migration_manifest_path(callgraph_dir, generation);
5008    let Ok(bytes) = std::fs::read(path) else {
5009        return false;
5010    };
5011    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
5012        return false;
5013    };
5014    value.get("version").and_then(serde_json::Value::as_u64)
5015        == Some(MIGRATION_MANIFEST_VERSION as u64)
5016        && value
5017            .get("target_generation")
5018            .and_then(serde_json::Value::as_str)
5019            == Some(generation)
5020        && value
5021            .get("source_bytes")
5022            .and_then(serde_json::Value::as_u64)
5023            .is_some_and(|bytes| bytes > 0)
5024        && value
5025            .get("source_blake3")
5026            .and_then(serde_json::Value::as_str)
5027            .is_some_and(|hash| hash.len() == 64)
5028}
5029
5030fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
5031    let pointer_generation = read_pointer(callgraph_dir, project_key);
5032    if let Some(generation) = pointer_generation.as_deref() {
5033        if migration_generation_requires_manifest(generation)
5034            && !migration_manifest_valid(callgraph_dir, generation)
5035        {
5036            let path = callgraph_dir.join(generation);
5037            remove_sqlite_file_set(&path);
5038            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
5039            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
5040        }
5041    }
5042
5043    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5044        return;
5045    };
5046    for entry in entries.flatten() {
5047        let name = entry.file_name().to_string_lossy().to_string();
5048        let path = entry.path();
5049        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
5050            let _ = std::fs::remove_file(path);
5051            continue;
5052        }
5053        if name.starts_with(&format!("{project_key}.g"))
5054            && name.ends_with(".sqlite")
5055            && name.contains(MIGRATION_GENERATION_TAG)
5056            && pointer_generation.as_deref() != Some(&name)
5057            && !migration_manifest_valid(callgraph_dir, &name)
5058        {
5059            remove_sqlite_file_set(&path);
5060            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
5061        }
5062    }
5063    crate::fs_lock::sync_parent(callgraph_dir);
5064}
5065
5066fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
5067    let mut hasher = blake3::Hasher::new();
5068    hasher.update(path.to_string_lossy().as_bytes());
5069    if let Some(generation) = generation {
5070        hasher.update(generation.as_bytes());
5071    }
5072    let digest = hash_to_hex(hasher.finalize());
5073    format!("legacy-{}", &digest[..16])
5074}
5075
5076fn open_readonly_connection(path: &Path) -> Result<Connection> {
5077    let uri = sqlite_readonly_uri(path);
5078    let conn = Connection::open_with_flags(
5079        &uri,
5080        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
5081    )?;
5082    conn.busy_timeout(reader_busy_timeout())?;
5083    conn.execute_batch("PRAGMA query_only=ON;")?;
5084    Ok(conn)
5085}
5086
5087fn reader_busy_timeout() -> Duration {
5088    let jitter = (now_nanos() % 500) as u64;
5089    Duration::from_millis(250 + jitter)
5090}
5091
5092fn sqlite_readonly_uri(path: &Path) -> String {
5093    let raw = path.to_string_lossy().replace('\\', "/");
5094    let encoded = percent_encode_sqlite_uri_path(&raw);
5095    if raw.starts_with('/') {
5096        format!("file://{encoded}?mode=ro")
5097    } else if raw.as_bytes().get(1) == Some(&b':') {
5098        format!("file:///{encoded}?mode=ro")
5099    } else {
5100        format!("file:{encoded}?mode=ro")
5101    }
5102}
5103
5104fn percent_encode_sqlite_uri_path(path: &str) -> String {
5105    let mut encoded = String::with_capacity(path.len());
5106    for byte in path.bytes() {
5107        match byte {
5108            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
5109                encoded.push(byte as char)
5110            }
5111            _ => encoded.push_str(&format!("%{byte:02X}")),
5112        }
5113    }
5114    encoded
5115}
5116
5117fn configure_connection(conn: &Connection) -> Result<()> {
5118    conn.pragma_update(None, "journal_mode", "WAL")?;
5119    conn.pragma_update(None, "busy_timeout", 5_000)?;
5120    Ok(())
5121}
5122
5123fn configure_build_connection(conn: &Connection) -> Result<()> {
5124    conn.pragma_update(None, "journal_mode", "DELETE")?;
5125    conn.pragma_update(None, "busy_timeout", 5_000)?;
5126    Ok(())
5127}
5128
5129fn initialize_schema(conn: &Connection) -> Result<()> {
5130    conn.execute_batch(
5131        "CREATE TABLE IF NOT EXISTS files (
5132            path                TEXT PRIMARY KEY,
5133            content_hash        TEXT NOT NULL,
5134            mtime_ns            INTEGER NOT NULL,
5135            size                INTEGER NOT NULL,
5136            lang                TEXT NOT NULL,
5137            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
5138            is_public_api       INTEGER NOT NULL DEFAULT 0,
5139            surface_fingerprint TEXT NOT NULL,
5140            indexed_at          INTEGER NOT NULL
5141        );
5142
5143        CREATE TABLE IF NOT EXISTS nodes (
5144            id                         TEXT PRIMARY KEY,
5145            file_path                  TEXT NOT NULL,
5146            name                       TEXT NOT NULL,
5147            scoped_name                TEXT NOT NULL,
5148            kind                       TEXT NOT NULL,
5149            start_line                 INTEGER NOT NULL,
5150            start_col                  INTEGER NOT NULL,
5151            end_line                   INTEGER NOT NULL,
5152            end_col                    INTEGER NOT NULL,
5153            range_ordinal              INTEGER NOT NULL,
5154            signature                  TEXT,
5155            exported                   INTEGER NOT NULL,
5156            is_default_export          INTEGER NOT NULL,
5157            is_type_like               INTEGER NOT NULL,
5158            is_callgraph_entry_point   INTEGER NOT NULL,
5159            provenance                 TEXT NOT NULL,
5160            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
5161        );
5162        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5163        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5164        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5165
5166        CREATE TABLE IF NOT EXISTS refs (
5167            ref_id          TEXT PRIMARY KEY,
5168            caller_node     TEXT,
5169            caller_file     TEXT NOT NULL,
5170            kind            TEXT NOT NULL,
5171            short_name      TEXT,
5172            full_ref        TEXT,
5173            module_path     TEXT,
5174            import_kind     TEXT,
5175            local_name      TEXT,
5176            requested_name  TEXT,
5177            namespace_alias TEXT,
5178            wildcard        INTEGER NOT NULL DEFAULT 0,
5179            line            INTEGER NOT NULL,
5180            byte_start      INTEGER NOT NULL,
5181            byte_end        INTEGER NOT NULL,
5182            status          TEXT NOT NULL,
5183            target_node     TEXT,
5184            target_file     TEXT,
5185            target_symbol   TEXT,
5186            provenance      TEXT NOT NULL
5187        );
5188        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5189        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5190        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5191        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5192        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5193
5194        CREATE TABLE IF NOT EXISTS file_dependencies (
5195            file_path   TEXT NOT NULL,
5196            dep_file    TEXT NOT NULL,
5197            PRIMARY KEY(file_path, dep_file)
5198        );
5199        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5200
5201        CREATE TABLE IF NOT EXISTS edges (
5202            edge_id       TEXT PRIMARY KEY,
5203            ref_id        TEXT NOT NULL,
5204            source_node   TEXT NOT NULL,
5205            target_node   TEXT,
5206            target_file   TEXT NOT NULL,
5207            target_symbol TEXT NOT NULL,
5208            kind          TEXT NOT NULL,
5209            line          INTEGER NOT NULL,
5210            provenance    TEXT NOT NULL
5211        );
5212        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5213        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5214        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5215        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5216
5217        CREATE TABLE IF NOT EXISTS dispatch_hints (
5218            id           TEXT PRIMARY KEY,
5219            method_name  TEXT NOT NULL,
5220            caller_node  TEXT NOT NULL,
5221            file         TEXT NOT NULL,
5222            line         INTEGER NOT NULL,
5223            byte_start   INTEGER NOT NULL,
5224            byte_end     INTEGER NOT NULL,
5225            provenance   TEXT NOT NULL
5226        );
5227        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5228
5229        CREATE TABLE IF NOT EXISTS type_ref_names (
5230            name TEXT PRIMARY KEY
5231        );
5232
5233        CREATE TABLE IF NOT EXISTS backend_file_state (
5234            backend        TEXT NOT NULL,
5235            workspace_root TEXT NOT NULL,
5236            file_path      TEXT NOT NULL,
5237            content_hash   TEXT NOT NULL,
5238            status         TEXT NOT NULL,
5239            updated_at     INTEGER NOT NULL,
5240            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
5241        );
5242        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
5243
5244        CREATE TABLE IF NOT EXISTS meta (
5245            k TEXT PRIMARY KEY,
5246            v TEXT NOT NULL
5247        );",
5248    )?;
5249    insert_meta(conn)?;
5250    Ok(())
5251}
5252
5253fn insert_meta(conn: &Connection) -> Result<()> {
5254    conn.execute(
5255        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
5256        params![SCHEMA_VERSION.to_string()],
5257    )?;
5258    conn.execute(
5259        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
5260        params![schema_fingerprint()],
5261    )?;
5262    Ok(())
5263}
5264
5265fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
5266    conn.execute(
5267        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
5268        params![if ready { "1" } else { "0" }],
5269    )?;
5270    Ok(())
5271}
5272
5273fn database_ready(conn: &Connection) -> Result<bool> {
5274    let schema_version: Option<String> = conn
5275        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
5276            row.get(0)
5277        })
5278        .optional()?;
5279    let fingerprint: Option<String> = conn
5280        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
5281            row.get(0)
5282        })
5283        .optional()?;
5284    let ready: Option<String> = conn
5285        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
5286        .optional()?;
5287
5288    let expected_schema = SCHEMA_VERSION.to_string();
5289    let expected_fingerprint = schema_fingerprint();
5290    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
5291        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
5292        && ready.as_deref() == Some("1"))
5293}
5294
5295fn ensure_database_ready(conn: &Connection) -> Result<()> {
5296    if database_ready(conn)? {
5297        Ok(())
5298    } else {
5299        Err(CallGraphStoreError::Unavailable(
5300            "database is missing, stale, or mid-build".to_string(),
5301        ))
5302    }
5303}
5304
5305fn schema_fingerprint() -> String {
5306    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
5307    // edge sources, broader call extraction) even if the table SHAPE is
5308    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
5309    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
5310    let input =
5311        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
5312    hash_to_hex(blake3::hash(input.as_bytes()))
5313}
5314
5315fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
5316    tx.execute_batch(
5317        "DELETE FROM edges;
5318         DELETE FROM file_dependencies;
5319         DELETE FROM refs;
5320         DELETE FROM dispatch_hints;
5321         DELETE FROM type_ref_names;
5322         DELETE FROM backend_file_state;
5323         DELETE FROM nodes;
5324         DELETE FROM files;",
5325    )?;
5326    Ok(())
5327}
5328
5329fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5330    tx.execute_batch(
5331        "DROP INDEX IF EXISTS idx_nodes_file;
5332         DROP INDEX IF EXISTS idx_nodes_name;
5333         DROP INDEX IF EXISTS idx_nodes_scoped;
5334         DROP INDEX IF EXISTS idx_refs_short_name;
5335         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
5336         DROP INDEX IF EXISTS idx_refs_caller_file;
5337         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
5338         DROP INDEX IF EXISTS idx_refs_target_file;
5339         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
5340         DROP INDEX IF EXISTS idx_edges_source_kind;
5341         DROP INDEX IF EXISTS idx_edges_target_kind;
5342         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
5343         DROP INDEX IF EXISTS idx_edges_ref_id;
5344         DROP INDEX IF EXISTS idx_dispatch_hints_method;
5345         DROP INDEX IF EXISTS idx_backend_file_state_file;",
5346    )?;
5347    Ok(())
5348}
5349
5350fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
5351    tx.execute_batch(
5352        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
5353         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
5354         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
5355         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
5356         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
5357         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
5358         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
5359         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
5360         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
5361         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
5362         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
5363         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
5364         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
5365         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
5366         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
5367    )?;
5368    Ok(())
5369}
5370
5371const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
5372    ("files", "path"),
5373    ("nodes", "file_path"),
5374    ("refs", "caller_file"),
5375    ("refs", "target_file"),
5376    ("file_dependencies", "file_path"),
5377    ("file_dependencies", "dep_file"),
5378    ("edges", "target_file"),
5379    ("dispatch_hints", "file"),
5380    ("backend_file_state", "file_path"),
5381];
5382
5383/// Reconcile `backend_file_state.workspace_root` when the opener's project root
5384/// differs from what is stored. The store key is the git-root commit hash, so
5385/// multiple live checkouts/clones share one on-disk generation.
5386///
5387/// Cheap in-place re-root is only safe when every previously stored root path is
5388/// gone from disk (true move/rename). If any stale root still exists, another
5389/// clone is still alive and rewriting metadata would ping-pong relative rows
5390/// between trees (possibly on different branches). We then return
5391/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
5392/// opener. That can make each clone rebuild on open when they alternate — bounded
5393/// by open frequency — but each rebuild is correct for its opener, unlike silent
5394/// cross-clone corruption.
5395fn reconcile_workspace_roots(
5396    conn: &mut Connection,
5397    project_root: &Path,
5398    allow_repair: bool,
5399) -> Result<OpenRootRepair> {
5400    let roots = stored_workspace_roots(conn)?;
5401    let current_root = project_root.display().to_string();
5402    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
5403        return Ok(OpenRootRepair::None);
5404    }
5405
5406    if let Some(sample) = sample_absolute_data_path(conn)? {
5407        return Ok(OpenRootRepair::NeedsRebuild {
5408            previous_roots: roots,
5409            current_root,
5410            reason: format!("absolute store data path row {sample}"),
5411        });
5412    }
5413
5414    for stored_root in roots.iter() {
5415        if stored_root == &current_root {
5416            continue;
5417        }
5418        if Path::new(stored_root).exists() {
5419            let reason = format!(
5420                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
5421            );
5422            return Ok(OpenRootRepair::NeedsRebuild {
5423                previous_roots: roots,
5424                current_root,
5425                reason,
5426            });
5427        }
5428    }
5429
5430    if !allow_repair {
5431        return Ok(OpenRootRepair::NeedsRebuild {
5432            previous_roots: roots,
5433            current_root,
5434            reason: "workspace root metadata requires deferred repair".to_string(),
5435        });
5436    }
5437
5438    publish_if_current(|| {
5439        let tx = conn.transaction()?;
5440        tx.execute(
5441            "UPDATE OR IGNORE backend_file_state
5442             SET workspace_root = ?1
5443             WHERE workspace_root <> ?1",
5444            params![&current_root],
5445        )?;
5446        tx.execute(
5447            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
5448            params![&current_root],
5449        )?;
5450        tx.commit()?;
5451        Ok(())
5452    })?;
5453
5454    crate::slog_info!(
5455        "callgraph store re-rooted from {} to {}",
5456        roots.join(", "),
5457        current_root
5458    );
5459    Ok(OpenRootRepair::ReRooted)
5460}
5461
5462fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
5463    let mut stmt = conn.prepare(
5464        "SELECT DISTINCT workspace_root
5465         FROM backend_file_state
5466         ORDER BY workspace_root",
5467    )?;
5468    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
5469    rows.collect::<std::result::Result<Vec<_>, _>>()
5470        .map_err(Into::into)
5471}
5472
5473fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
5474    for (table, column) in STORE_DATA_PATH_COLUMNS {
5475        let sql = format!(
5476            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
5477        );
5478        let mut stmt = conn.prepare(&sql)?;
5479        let mut rows = stmt.query([])?;
5480        while let Some(row) = rows.next()? {
5481            let value: String = row.get(0)?;
5482            if stored_path_is_absolute(&value) {
5483                return Ok(Some(format!("{table}.{column}={value}")));
5484            }
5485        }
5486    }
5487    Ok(None)
5488}
5489
5490fn stored_path_is_absolute(value: &str) -> bool {
5491    if value.is_empty() {
5492        return false;
5493    }
5494    if Path::new(value).is_absolute() || value.starts_with('/') {
5495        return true;
5496    }
5497    let bytes = value.as_bytes();
5498    if bytes.len() >= 3
5499        && bytes[1] == b':'
5500        && (bytes[2] == b'/' || bytes[2] == b'\\')
5501        && bytes[0].is_ascii_alphabetic()
5502    {
5503        return true;
5504    }
5505    value.starts_with("\\\\") || value.starts_with("//")
5506}
5507
5508fn log_root_repair_rebuild(repair: &OpenRootRepair) {
5509    if let OpenRootRepair::NeedsRebuild {
5510        previous_roots,
5511        current_root,
5512        reason,
5513    } = repair
5514    {
5515        crate::slog_info!(
5516            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
5517            previous_roots.join(", "),
5518            current_root,
5519            reason
5520        );
5521    }
5522}
5523
5524/// Nanosecond clock used to make temp/generation file names unique.
5525fn now_nanos() -> u128 {
5526    SystemTime::now()
5527        .duration_since(UNIX_EPOCH)
5528        .unwrap_or(Duration::ZERO)
5529        .as_nanos()
5530}
5531
5532/// The pointer file `<dir>/<key>.current`. Its single line names the current
5533/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
5534/// can always be atomically replaced via rename even on Windows — Rust opens
5535/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
5536fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5537    callgraph_dir.join(format!("{project_key}.current"))
5538}
5539
5540/// The legacy single-file DB path used before the generation scheme. Still read
5541/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
5542/// build publishes a generation.
5543fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
5544    callgraph_dir.join(format!("{project_key}.sqlite"))
5545}
5546
5547/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
5548/// cold build writes a brand-new generation file, so publishing NEVER replaces
5549/// a file another process holds open (the root Windows fix).
5550fn generation_file_name(project_key: &str) -> String {
5551    format!(
5552        "{project_key}.g{}.{}.sqlite",
5553        now_nanos(),
5554        std::process::id()
5555    )
5556}
5557
5558/// Read the pointer; returns the generation file name if present and non-empty.
5559fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
5560    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
5561    let name = text.trim();
5562    if name.is_empty() {
5563        None
5564    } else {
5565        Some(name.to_string())
5566    }
5567}
5568
5569/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
5570/// `ready` flag). Uses a throwaway read-only connection.
5571fn db_path_ready(path: &Path) -> bool {
5572    (|| -> Result<bool> {
5573        let conn = open_readonly_connection(path)?;
5574        database_ready(&conn)
5575    })()
5576    .unwrap_or(false)
5577}
5578
5579/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
5580/// where `generation` is `Some(name)` for a pointer-published generation or
5581/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
5582/// published (caller treats that as "needs cold build").
5583///
5584/// Handles the GC race (the pointer names a generation that was just deleted) by
5585/// re-reading the pointer and retrying a few times.
5586fn resolve_ready_target(
5587    callgraph_dir: &Path,
5588    project_key: &str,
5589) -> Option<(PathBuf, Option<String>)> {
5590    for _ in 0..5 {
5591        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
5592            let gen_path = callgraph_dir.join(&generation);
5593            if gen_path.is_file() {
5594                return (migration_manifest_valid(callgraph_dir, &generation)
5595                    && db_path_ready(&gen_path))
5596                .then_some((gen_path, Some(generation)));
5597            }
5598            // Pointer names a missing generation (a GC/publish race): re-read the
5599            // pointer and retry rather than failing the reader.
5600            std::thread::sleep(Duration::from_millis(5));
5601            continue;
5602        }
5603        // No pointer: fall back to the legacy single-file DB if it is ready.
5604        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
5605        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
5606    }
5607    None
5608}
5609
5610/// Atomically publish `generation` as the current store by flipping the pointer
5611/// file. Writes a temp file, fsyncs, then renames over the pointer — never
5612/// replacing an open DB file, so it succeeds cross-platform.
5613fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
5614    let pointer = pointer_path(callgraph_dir, project_key);
5615    let tmp = callgraph_dir.join(format!(
5616        "{project_key}.current.tmp.{}.{}",
5617        std::process::id(),
5618        now_nanos()
5619    ));
5620    {
5621        use std::io::Write as _;
5622        let mut file = std::fs::File::create(&tmp)?;
5623        file.write_all(generation.as_bytes())?;
5624        file.write_all(b"\n")?;
5625        file.sync_all()?;
5626    }
5627    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
5628        let _ = std::fs::remove_file(&tmp);
5629        return Err(error.into());
5630    }
5631    crate::fs_lock::sync_parent(&pointer);
5632    Ok(())
5633}
5634
5635#[derive(Clone, Debug)]
5636struct GenerationGcCandidate {
5637    name: String,
5638    path: PathBuf,
5639    modified: SystemTime,
5640}
5641
5642/// Best-effort GC of superseded generation files. The current pointer target and
5643/// newest previous generation are always retained. Older generations are removed
5644/// when they have no protected read marker, or after the absolute retention TTL
5645/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
5646/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
5647fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
5648    let temp_grace = Duration::from_secs(60);
5649    let now = SystemTime::now();
5650    let pointer_current =
5651        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
5652    let gen_prefix = format!("{project_key}.g");
5653    let tmp_prefixes = [
5654        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
5655        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
5656        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
5657    ];
5658    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5659        return;
5660    };
5661    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
5662    for entry in entries.flatten() {
5663        let name = entry.file_name();
5664        let name = name.to_string_lossy().to_string();
5665        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
5666        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
5667
5668        // Orphaned temp files from a crashed build/publish: remove once aged out.
5669        if name.contains(".tmp.") {
5670            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
5671                let _ = std::fs::remove_file(entry.path());
5672            }
5673            continue;
5674        }
5675
5676        // Superseded legacy single-file DB: best-effort delete once a generation
5677        // is published (ignored if another process still holds it open).
5678        if name == format!("{project_key}.sqlite") {
5679            remove_sqlite_file_set(&entry.path());
5680            continue;
5681        }
5682
5683        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
5684            gens.push(GenerationGcCandidate {
5685                name,
5686                path: entry.path(),
5687                modified: mtime,
5688            });
5689        }
5690    }
5691
5692    let mut superseded = gens
5693        .iter()
5694        .filter(|generation| generation.name != pointer_current)
5695        .collect::<Vec<_>>();
5696    superseded.sort_by(|left, right| {
5697        right
5698            .modified
5699            .cmp(&left.modified)
5700            .then_with(|| right.name.cmp(&left.name))
5701    });
5702    let previous = superseded.first().map(|generation| generation.name.clone());
5703
5704    for generation in gens {
5705        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
5706        if generation.name == pointer_current
5707            || Some(generation.name.as_str()) == previous.as_deref()
5708        {
5709            continue;
5710        }
5711
5712        let age = now
5713            .duration_since(generation.modified)
5714            .unwrap_or(Duration::ZERO);
5715        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
5716            continue;
5717        }
5718
5719        remove_sqlite_file_set(&generation.path);
5720        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
5721        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
5722            callgraph_dir,
5723            &generation.name,
5724        ));
5725    }
5726}
5727
5728fn remove_sqlite_file_set(path: &Path) {
5729    let _ = std::fs::remove_file(path);
5730    remove_sqlite_sidecars(path);
5731}
5732
5733fn remove_sqlite_sidecars(path: &Path) {
5734    let path_text = path.to_string_lossy();
5735    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
5736    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
5737    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
5738}
5739
5740/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
5741///
5742/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
5743/// place on success; a build that dies (process kill, crash, host restart) leaves
5744/// the temporary behind. The largest observed cold build finishes well under a
5745/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
5746/// never rename. A live build's temporary is minutes old at most.
5747///
5748/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
5749/// liveness check read false-positive on exactly the oldest files — the ones most
5750/// worth deleting: in production an orphan's embedded pid had been recycled to an
5751/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
5752/// cannot lie that way, so it is the honest orphan predicate.
5753const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
5754
5755/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
5756/// same cadence as [`gc_old_generations`] (after a generation is published) but,
5757/// unlike it, is not scoped to the building root: it covers every directory in the
5758/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
5759///
5760/// That last case is the production hole this fixes. The per-root cleanup in
5761/// [`gc_old_generations`] only fires when a root actually builds, so when activity
5762/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
5763/// store's orphans become permanent — gigabytes accumulated in a legacy store
5764/// whose roots no longer built there, while the active store stayed clean. A
5765/// sibling root that still builds triggers this pass and cleans both layouts.
5766fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
5767    sweep_orphaned_build_temps(callgraph_dir);
5768    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
5769        return;
5770    };
5771    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
5772
5773    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
5774    if let Ok(entries) = std::fs::read_dir(storage_root.join(domain)) {
5775        for entry in entries.flatten() {
5776            if entry.path().is_dir() {
5777                sweep_orphaned_build_temps(&entry.path());
5778            }
5779        }
5780    }
5781
5782    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
5783    if let Ok(entries) = std::fs::read_dir(&storage_root) {
5784        for entry in entries.flatten() {
5785            let legacy_dir = entry.path().join(domain);
5786            if legacy_dir.is_dir() {
5787                sweep_orphaned_build_temps(&legacy_dir);
5788            }
5789        }
5790    }
5791}
5792
5793/// Sweep one callgraph directory, removing build temporaries older than
5794/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
5795fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
5796    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
5797}
5798
5799/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
5800/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
5801fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
5802    let now = SystemTime::now();
5803    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
5804        return;
5805    };
5806    let mut removed_any = false;
5807    for entry in entries.flatten() {
5808        let name = entry.file_name().to_string_lossy().to_string();
5809        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
5810        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
5811        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
5812        // without that substring — a completed `.sqlite` generation, a pointer, a
5813        // read-marker dir — is left alone: those belong to generation GC.
5814        if !name.contains(".sqlite.tmp.") {
5815            continue;
5816        }
5817        let mtime = entry
5818            .metadata()
5819            .and_then(|meta| meta.modified())
5820            .unwrap_or(now);
5821        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
5822            continue;
5823        }
5824        // Deletion races a concurrent build finishing: that build renames the temp
5825        // into place, so the file is gone by the time we unlink. The 24h age makes
5826        // this overlap practically impossible, but treat a missing file as success
5827        // (the rename won) rather than an error, and never touch a path that does
5828        // not match the temporary shape above.
5829        match std::fs::remove_file(entry.path()) {
5830            Ok(()) => removed_any = true,
5831            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
5832            Err(_) => {}
5833        }
5834    }
5835    if removed_any {
5836        crate::fs_lock::sync_parent(callgraph_dir);
5837    }
5838}
5839
5840/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
5841/// the global all-cores rayon pool. The store cold-build is the heaviest
5842/// background pass (parse-dominated) and runs on a separate thread off the
5843/// single-threaded request loop; left unbounded it monopolizes every core and
5844/// starves the bridge so interactive tools time out (the same starvation the
5845/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
5846/// match the main thread, since the extract walks tree-sitter ASTs.
5847fn build_pool_size() -> usize {
5848    std::thread::available_parallelism()
5849        .map(|parallelism| parallelism.get())
5850        .unwrap_or(1)
5851        .div_ceil(2)
5852        .clamp(1, 8)
5853}
5854
5855fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
5856    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
5857        Ok(extract) => Ok(extract),
5858        Err(error) => {
5859            let abs_path =
5860                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
5861            let rel_path = relative_path(project_root, &abs_path);
5862            let freshness = cache_freshness::collect(&abs_path).ok();
5863            log::debug!(
5864                "callgraph store: skipping {} during cold build: {}",
5865                abs_path.display(),
5866                error
5867            );
5868            Err(ExtractFailure {
5869                rel_path,
5870                freshness,
5871            })
5872        }
5873    };
5874
5875    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
5876        files.par_iter().map(extract_one).collect()
5877    };
5878
5879    // Run inside a dedicated bounded pool when one builds; fall back to the
5880    // global pool only if the bounded pool can't be constructed.
5881    let results = match rayon::ThreadPoolBuilder::new()
5882        .num_threads(build_pool_size())
5883        .thread_name(|index| format!("aft-callgraph-build-{index}"))
5884        .stack_size(8 * 1024 * 1024)
5885        .build()
5886    {
5887        Ok(pool) => pool.install(run),
5888        Err(error) => {
5889            log::warn!(
5890                "callgraph store: bounded build pool unavailable ({error}); using global pool"
5891            );
5892            run()
5893        }
5894    };
5895
5896    let mut extracts = Vec::new();
5897    let mut failures = Vec::new();
5898    for result in results {
5899        match result {
5900            Ok(extract) => extracts.push(extract),
5901            Err(failure) => failures.push(failure),
5902        }
5903    }
5904    BuildExtractsResult { extracts, failures }
5905}
5906
5907fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
5908    let metadata = std::fs::metadata(path)?;
5909    let size = metadata.len();
5910    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
5911        cache_freshness::zero_hash()
5912    } else if source.len() as u64 == size {
5913        cache_freshness::hash_bytes(source.as_bytes())
5914    } else {
5915        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
5916    };
5917    Ok(FileFreshness {
5918        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
5919        size,
5920        content_hash,
5921    })
5922}
5923
5924fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
5925    let abs_path = normalize_file_path(project_root, path)?;
5926    let rel_path = relative_path(project_root, &abs_path);
5927    let source = std::fs::read_to_string(&abs_path)?;
5928    let freshness = collect_source_freshness(&abs_path, &source)?;
5929    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
5930    let lang = data.lang;
5931    if lang == LangId::Rust {
5932        extend_rust_imports_with_nested_uses(&source, &mut data);
5933    }
5934    let mut nodes = build_node_records(&rel_path, &source, &data)?;
5935    let node_by_scoped: HashMap<String, String> = nodes
5936        .iter()
5937        .map(|node| (node.scoped_name.clone(), node.id.clone()))
5938        .collect();
5939    let import_dependencies =
5940        import_dependencies(project_root, &abs_path, &data.import_block.imports);
5941    let line_index = LineIndex::new(&source);
5942    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
5943    let rust_reexports = if lang == LangId::Rust {
5944        collect_rust_pub_use_reexport_refs(
5945            project_root,
5946            &abs_path,
5947            &rel_path,
5948            &data.import_block.imports,
5949            &line_index,
5950        )
5951    } else {
5952        ReexportRefs {
5953            raw_refs: Vec::new(),
5954            surface_parts: Vec::new(),
5955        }
5956    };
5957    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
5958    let mut raw_refs = Vec::new();
5959    raw_refs.extend(build_call_refs(
5960        &rel_path,
5961        &data,
5962        &node_by_scoped,
5963        &import_dependencies,
5964    ));
5965    raw_refs.extend(build_import_refs(
5966        project_root,
5967        &abs_path,
5968        &rel_path,
5969        &data.import_block.imports,
5970        &line_index,
5971    ));
5972    let mut surface_parts = reexports.surface_parts;
5973    surface_parts.extend(rust_reexports.surface_parts);
5974    surface_parts.extend(source_less_exports.surface_parts);
5975    raw_refs.extend(reexports.raw_refs);
5976    raw_refs.extend(rust_reexports.raw_refs);
5977    raw_refs.extend(source_less_exports.raw_refs);
5978    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
5979    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
5980
5981    Ok(FileExtract {
5982        rel_path,
5983        freshness,
5984        lang,
5985        data,
5986        nodes,
5987        raw_refs,
5988        dispatch_hints,
5989        surface_fingerprint,
5990    })
5991}
5992
5993fn build_node_records(
5994    rel_path: &str,
5995    source: &str,
5996    data: &FileCallData,
5997) -> Result<Vec<NodeRecord>> {
5998    let mut records = Vec::new();
5999    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
6000    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
6001    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
6002
6003    for (scoped_name, meta) in metadata {
6004        let name = unqualified_name(scoped_name).to_string();
6005        let range = selection_range(source, scoped_name, &name, &meta.range);
6006        let range_key = (
6007            range.start_line,
6008            range.start_col,
6009            range.end_line,
6010            range.end_col,
6011        );
6012        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
6013        let range_ordinal = *ordinal;
6014        *ordinal += 1;
6015        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
6016        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
6017        let is_default_export = data
6018            .default_export_symbol
6019            .as_deref()
6020            .map(|default| default == scoped_name || default == name)
6021            .unwrap_or(false);
6022        records.push(NodeRecord {
6023            id,
6024            file_path: rel_path.to_string(),
6025            name: name.clone(),
6026            scoped_name: scoped_name.clone(),
6027            kind: symbol_kind_label(&meta.kind).to_string(),
6028            range,
6029            range_ordinal,
6030            signature: meta.signature.clone(),
6031            exported,
6032            is_default_export,
6033            is_type_like: is_type_like(&meta.kind),
6034            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
6035                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
6036        });
6037    }
6038
6039    Ok(records)
6040}
6041
6042fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
6043    if scoped_name == TOP_LEVEL_SYMBOL {
6044        return Range {
6045            start_line: 0,
6046            start_col: 0,
6047            end_line: 0,
6048            end_col: 0,
6049        };
6050    }
6051    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
6052        return fallback.clone();
6053    };
6054    let start_col = fallback.start_col as usize;
6055    let search_start = start_col.min(line.len());
6056    if let Some(offset) = line[search_start..].find(name) {
6057        let col = search_start + offset;
6058        return Range {
6059            start_line: fallback.start_line,
6060            start_col: col as u32,
6061            end_line: fallback.start_line,
6062            end_col: (col + name.len()) as u32,
6063        };
6064    }
6065    if let Some(offset) = line.find(name) {
6066        return Range {
6067            start_line: fallback.start_line,
6068            start_col: offset as u32,
6069            end_line: fallback.start_line,
6070            end_col: (offset + name.len()) as u32,
6071        };
6072    }
6073    Range {
6074        start_line: fallback.start_line,
6075        start_col: fallback.start_col,
6076        end_line: fallback.start_line,
6077        end_col: fallback.start_col.saturating_add(name.len() as u32),
6078    }
6079}
6080
6081fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
6082    if scoped_name == TOP_LEVEL_SYMBOL {
6083        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
6084    }
6085    let input = format!(
6086        "{rel_path}:{}:{}:{}:{}:{ordinal}",
6087        range.start_line, range.start_col, range.end_line, range.end_col
6088    );
6089    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
6090}
6091
6092fn build_call_refs(
6093    rel_path: &str,
6094    data: &FileCallData,
6095    node_by_scoped: &HashMap<String, String>,
6096    import_dependencies: &BTreeSet<String>,
6097) -> Vec<RawRef> {
6098    let mut refs = Vec::new();
6099    let mut ordinal = 0usize;
6100    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
6101    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
6102    for (caller_symbol, call_sites) in symbols {
6103        let caller_node = node_by_scoped.get(caller_symbol).cloned();
6104        for call_site in call_sites {
6105            ordinal += 1;
6106            let ref_id = ref_id(&[
6107                rel_path,
6108                "call",
6109                caller_symbol,
6110                &call_site.line.to_string(),
6111                &call_site.byte_start.to_string(),
6112                &call_site.byte_end.to_string(),
6113                &call_site.full_callee,
6114                &ordinal.to_string(),
6115            ]);
6116            refs.push(RawRef {
6117                ref_id,
6118                caller_node: caller_node.clone(),
6119                caller_symbol: Some(caller_symbol.clone()),
6120                caller_file: rel_path.to_string(),
6121                kind: "call".to_string(),
6122                short_name: Some(call_site.callee_name.clone()),
6123                full_ref: Some(call_site.full_callee.clone()),
6124                module_path: None,
6125                import_kind: None,
6126                local_name: Some(call_site.callee_name.clone()),
6127                requested_name: Some(call_site.callee_name.clone()),
6128                namespace_alias: namespace_alias(&call_site.full_callee),
6129                wildcard: false,
6130                line: call_site.line,
6131                byte_start: call_site.byte_start,
6132                byte_end: call_site.byte_end,
6133                dependencies: import_dependencies.clone(),
6134            });
6135        }
6136    }
6137    refs
6138}
6139
6140fn build_import_refs(
6141    project_root: &Path,
6142    abs_path: &Path,
6143    rel_path: &str,
6144    imports: &[ImportStatement],
6145    line_index: &LineIndex,
6146) -> Vec<RawRef> {
6147    let mut refs = Vec::new();
6148    for (index, import) in imports.iter().enumerate() {
6149        let import_kind = import_kind_label(import.kind).to_string();
6150        let local_name = import_local_names(import).join(",");
6151        let requested_name = import_requested_names(import).join(",");
6152        let ref_id = ref_id(&[
6153            rel_path,
6154            "import",
6155            &import.byte_range.start.to_string(),
6156            &import.byte_range.end.to_string(),
6157            &import.module_path,
6158            &index.to_string(),
6159        ]);
6160        refs.push(RawRef {
6161            ref_id,
6162            caller_node: None,
6163            caller_symbol: None,
6164            caller_file: rel_path.to_string(),
6165            kind: "import".to_string(),
6166            short_name: None,
6167            full_ref: Some(import.raw_text.clone()),
6168            module_path: Some(import.module_path.clone()),
6169            import_kind: Some(import_kind),
6170            local_name: empty_to_none(local_name),
6171            requested_name: empty_to_none(requested_name),
6172            namespace_alias: import.namespace_import.clone(),
6173            wildcard: import_is_wildcard(import),
6174            line: line_index.byte_to_line(import.byte_range.start),
6175            byte_start: import.byte_range.start,
6176            byte_end: import.byte_range.end,
6177            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
6178        });
6179    }
6180    refs
6181}
6182
6183fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
6184    let grammar = grammar_for(LangId::Rust);
6185    let mut parser = Parser::new();
6186    if parser.set_language(&grammar).is_err() {
6187        return;
6188    }
6189    let Some(tree) = parser.parse(source, None) else {
6190        return;
6191    };
6192
6193    let mut seen = data
6194        .import_block
6195        .imports
6196        .iter()
6197        .map(|import| (import.byte_range.start, import.byte_range.end))
6198        .collect::<HashSet<_>>();
6199    let mut nested_imports = Vec::new();
6200    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
6201    if nested_imports.is_empty() {
6202        return;
6203    }
6204
6205    data.import_block.imports.extend(nested_imports);
6206    data.import_block
6207        .imports
6208        .sort_by_key(|import| import.byte_range.start);
6209    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
6210}
6211
6212fn collect_rust_use_imports(
6213    source: &str,
6214    node: Node<'_>,
6215    seen: &mut HashSet<(usize, usize)>,
6216    imports: &mut Vec<ImportStatement>,
6217) {
6218    if node.kind() == "use_declaration" {
6219        let range = node.byte_range();
6220        if seen.insert((range.start, range.end)) {
6221            if let Some(import) = rust_import_from_use_node(source, node) {
6222                imports.push(import);
6223            }
6224        }
6225    }
6226
6227    let mut cursor = node.walk();
6228    if !cursor.goto_first_child() {
6229        return;
6230    }
6231    loop {
6232        collect_rust_use_imports(source, cursor.node(), seen, imports);
6233        if !cursor.goto_next_sibling() {
6234            break;
6235        }
6236    }
6237}
6238
6239fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
6240    let raw_text = source[node.byte_range()].to_string();
6241    let body = rust_use_body(&raw_text)?.to_string();
6242    let visibility = rust_use_visibility(&raw_text);
6243    let names = rust_use_list_names(&body);
6244    let group = classify_rust_import_group(&body);
6245    let byte_range = node.byte_range();
6246
6247    Some(ImportStatement {
6248        module_path: body,
6249        names: names.clone(),
6250        default_import: visibility.clone(),
6251        namespace_import: None,
6252        kind: ImportKind::Value,
6253        group,
6254        byte_range,
6255        raw_text,
6256        form: ImportForm::RustUse {
6257            visibility,
6258            named: names,
6259        },
6260    })
6261}
6262
6263fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
6264    let start = imports.iter().map(|import| import.byte_range.start).min()?;
6265    let end = imports.iter().map(|import| import.byte_range.end).max()?;
6266    Some(start..end)
6267}
6268
6269fn rust_use_visibility(raw_text: &str) -> Option<String> {
6270    let use_pos = raw_text.find("use ")?;
6271    let prefix = raw_text[..use_pos].trim();
6272    if prefix.is_empty() {
6273        None
6274    } else {
6275        Some(prefix.to_string())
6276    }
6277}
6278
6279fn rust_use_body(raw_text: &str) -> Option<&str> {
6280    let use_pos = raw_text.find("use ")?;
6281    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
6282}
6283
6284fn rust_use_list_names(body: &str) -> Vec<String> {
6285    let Some(open) = body.find("::{") else {
6286        return Vec::new();
6287    };
6288    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
6289        return Vec::new();
6290    };
6291    body[open + 3..close]
6292        .split(',')
6293        .filter_map(|spec| {
6294            let spec = spec.trim();
6295            if spec.is_empty() {
6296                None
6297            } else {
6298                Some(spec.to_string())
6299            }
6300        })
6301        .collect()
6302}
6303
6304fn classify_rust_import_group(body: &str) -> ImportGroup {
6305    let first = body
6306        .split("::")
6307        .next()
6308        .unwrap_or(body)
6309        .split_whitespace()
6310        .next()
6311        .unwrap_or(body);
6312    match first.trim() {
6313        "std" | "core" | "alloc" => ImportGroup::Stdlib,
6314        "crate" | "self" | "super" => ImportGroup::Internal,
6315        _ => ImportGroup::External,
6316    }
6317}
6318
6319#[derive(Debug, Clone)]
6320struct ReexportRefs {
6321    raw_refs: Vec<RawRef>,
6322    surface_parts: Vec<String>,
6323}
6324
6325fn collect_reexport_refs(
6326    project_root: &Path,
6327    abs_path: &Path,
6328    rel_path: &str,
6329    source: &str,
6330) -> ReexportRefs {
6331    let mut raw_refs = Vec::new();
6332    let mut surface_parts = Vec::new();
6333    let mut search_start = 0usize;
6334    let mut ordinal = 0usize;
6335    while let Some(export_offset) = source[search_start..].find("export") {
6336        let start = search_start + export_offset;
6337        let Some(statement_end_offset) = source[start..].find(';') else {
6338            break;
6339        };
6340        let end = start + statement_end_offset + 1;
6341        let statement = &source[start..end];
6342        search_start = end;
6343        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
6344            continue;
6345        }
6346        let Some(module_path) = quoted_module_path(statement) else {
6347            continue;
6348        };
6349        ordinal += 1;
6350        let wildcard = statement.contains('*');
6351        let line = source[..start]
6352            .bytes()
6353            .filter(|byte| *byte == b'\n')
6354            .count() as u32
6355            + 1;
6356        let ref_id = ref_id(&[
6357            rel_path,
6358            "reexport",
6359            &start.to_string(),
6360            &end.to_string(),
6361            &module_path,
6362            &ordinal.to_string(),
6363        ]);
6364        surface_parts.push(format!("reexport\t{statement}"));
6365        raw_refs.push(RawRef {
6366            ref_id,
6367            caller_node: None,
6368            caller_symbol: None,
6369            caller_file: rel_path.to_string(),
6370            kind: "reexport".to_string(),
6371            short_name: None,
6372            full_ref: Some(statement.to_string()),
6373            module_path: Some(module_path.clone()),
6374            import_kind: Some("reexport".to_string()),
6375            local_name: None,
6376            requested_name: None,
6377            namespace_alias: None,
6378            wildcard,
6379            line,
6380            byte_start: start,
6381            byte_end: end,
6382            dependencies: module_dependencies(project_root, abs_path, &module_path),
6383        });
6384    }
6385    ReexportRefs {
6386        raw_refs,
6387        surface_parts,
6388    }
6389}
6390
6391fn collect_rust_pub_use_reexport_refs(
6392    project_root: &Path,
6393    abs_path: &Path,
6394    rel_path: &str,
6395    imports: &[ImportStatement],
6396    line_index: &LineIndex,
6397) -> ReexportRefs {
6398    let mut raw_refs = Vec::new();
6399    let mut surface_parts = Vec::new();
6400    let mut ordinal = 0usize;
6401
6402    for import in imports {
6403        let Some(visibility) = &import.default_import else {
6404            continue;
6405        };
6406        if !visibility.starts_with("pub") {
6407            continue;
6408        }
6409        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
6410            continue;
6411        };
6412        ordinal += 1;
6413        let ref_id = ref_id(&[
6414            rel_path,
6415            "rust_reexport",
6416            &import.byte_range.start.to_string(),
6417            &import.byte_range.end.to_string(),
6418            &module_path,
6419            &ordinal.to_string(),
6420        ]);
6421        surface_parts.push(format!("reexport\t{}", import.raw_text));
6422        raw_refs.push(RawRef {
6423            ref_id,
6424            caller_node: None,
6425            caller_symbol: None,
6426            caller_file: rel_path.to_string(),
6427            kind: "reexport".to_string(),
6428            short_name: None,
6429            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
6430            module_path: Some(module_path.clone()),
6431            import_kind: Some("reexport".to_string()),
6432            local_name: None,
6433            requested_name: None,
6434            namespace_alias: None,
6435            wildcard,
6436            line: line_index.byte_to_line(import.byte_range.start),
6437            byte_start: import.byte_range.start,
6438            byte_end: import.byte_range.end,
6439            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
6440        });
6441    }
6442
6443    ReexportRefs {
6444        raw_refs,
6445        surface_parts,
6446    }
6447}
6448
6449fn rust_pub_use_reexport_parts(
6450    import: &ImportStatement,
6451) -> Option<(String, HashMap<String, String>, bool)> {
6452    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
6453    let body = body.trim();
6454    if let Some(module_path) = body.strip_suffix("::*") {
6455        return Some((module_path.trim().to_string(), HashMap::new(), true));
6456    }
6457
6458    if let Some(brace_start) = body.find("::{") {
6459        let module_path = body[..brace_start].trim().to_string();
6460        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
6461        if names.is_empty() {
6462            return None;
6463        }
6464        return Some((module_path, names, false));
6465    }
6466
6467    let (module_path, spec) = body.rsplit_once("::")?;
6468    let names = rust_reexport_names_from_specs(spec);
6469    if names.is_empty() {
6470        return None;
6471    }
6472    Some((module_path.trim().to_string(), names, false))
6473}
6474
6475fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
6476    let mut names = HashMap::new();
6477    for spec in specs.split(',') {
6478        let spec = spec.trim();
6479        if spec.is_empty() || spec == "self" {
6480            continue;
6481        }
6482        if let Some((source, local)) = spec.split_once(" as ") {
6483            let source = source.trim();
6484            let local = local.trim();
6485            if !source.is_empty() && !local.is_empty() && source != "self" {
6486                names.insert(local.to_string(), source.to_string());
6487            }
6488        } else {
6489            names.insert(spec.to_string(), spec.to_string());
6490        }
6491    }
6492    names
6493}
6494
6495fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
6496    if named.is_empty() {
6497        return fallback.to_string();
6498    }
6499    let mut specs = named
6500        .iter()
6501        .map(|(local, source)| {
6502            if local == source {
6503                source.clone()
6504            } else {
6505                format!("{source} as {local}")
6506            }
6507        })
6508        .collect::<Vec<_>>();
6509    specs.sort();
6510    format!("pub use {{{}}};", specs.join(", "))
6511}
6512
6513fn quoted_module_path(statement: &str) -> Option<String> {
6514    let quote = match (statement.find('\''), statement.find('"')) {
6515        (Some(single), Some(double)) if single < double => '\'',
6516        (Some(_), Some(_)) => '"',
6517        (Some(_), None) => '\'',
6518        (None, Some(_)) => '"',
6519        (None, None) => return None,
6520    };
6521    let start = statement.find(quote)? + 1;
6522    let end = statement[start..].find(quote)? + start;
6523    Some(statement[start..end].to_string())
6524}
6525
6526#[derive(Debug, Clone)]
6527struct SourceLessExportRefs {
6528    raw_refs: Vec<RawRef>,
6529    surface_parts: Vec<String>,
6530}
6531
6532fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
6533    let mut raw_refs = Vec::new();
6534    let mut surface_parts = Vec::new();
6535    let mut search_start = 0usize;
6536    let mut ordinal = 0usize;
6537    while let Some(export_offset) = source[search_start..].find("export") {
6538        let start = search_start + export_offset;
6539        let Some(statement_end_offset) = source[start..].find(';') else {
6540            break;
6541        };
6542        let end = start + statement_end_offset + 1;
6543        let statement = &source[start..end];
6544        search_start = end;
6545        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
6546            continue;
6547        }
6548        let aliases = parse_reexport_names(statement);
6549        if aliases.is_empty() {
6550            continue;
6551        }
6552        let line = source[..start]
6553            .bytes()
6554            .filter(|byte| *byte == b'\n')
6555            .count() as u32
6556            + 1;
6557        for (exported, source_symbol) in aliases {
6558            ordinal += 1;
6559            let ref_id = ref_id(&[
6560                rel_path,
6561                "export_alias",
6562                &start.to_string(),
6563                &end.to_string(),
6564                &exported,
6565                &source_symbol,
6566                &ordinal.to_string(),
6567            ]);
6568            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
6569            raw_refs.push(RawRef {
6570                ref_id,
6571                caller_node: None,
6572                caller_symbol: None,
6573                caller_file: rel_path.to_string(),
6574                kind: "export_alias".to_string(),
6575                short_name: None,
6576                full_ref: Some(statement.to_string()),
6577                module_path: None,
6578                import_kind: Some("export_alias".to_string()),
6579                local_name: Some(exported),
6580                requested_name: Some(source_symbol),
6581                namespace_alias: None,
6582                wildcard: false,
6583                line,
6584                byte_start: start,
6585                byte_end: end,
6586                dependencies: BTreeSet::new(),
6587            });
6588        }
6589    }
6590    SourceLessExportRefs {
6591        raw_refs,
6592        surface_parts,
6593    }
6594}
6595
6596fn build_dispatch_hints(
6597    rel_path: &str,
6598    data: &FileCallData,
6599    node_by_scoped: &HashMap<String, String>,
6600) -> Vec<DispatchHint> {
6601    let mut hints = Vec::new();
6602    let mut ordinal = 0usize;
6603    for (caller_symbol, call_sites) in &data.calls_by_symbol {
6604        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
6605            continue;
6606        };
6607        for call_site in call_sites {
6608            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
6609                continue;
6610            }
6611            ordinal += 1;
6612            hints.push(DispatchHint {
6613                id: ref_id(&[
6614                    rel_path,
6615                    "dispatch",
6616                    caller_symbol,
6617                    &call_site.line.to_string(),
6618                    &call_site.byte_start.to_string(),
6619                    &call_site.byte_end.to_string(),
6620                    &ordinal.to_string(),
6621                ]),
6622                method_name: call_site.callee_name.clone(),
6623                caller_node: caller_node.clone(),
6624                file: rel_path.to_string(),
6625                line: call_site.line,
6626                byte_start: call_site.byte_start,
6627                byte_end: call_site.byte_end,
6628            });
6629        }
6630    }
6631    hints
6632}
6633
6634fn surface_fingerprint(
6635    nodes: &mut [NodeRecord],
6636    data: &FileCallData,
6637    reexport_parts: &[String],
6638) -> String {
6639    nodes.sort_by(|left, right| {
6640        (left.file_path.as_str(), left.scoped_name.as_str())
6641            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
6642    });
6643    let mut parts = Vec::new();
6644    for node in nodes.iter() {
6645        parts.push(format!(
6646            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
6647            node.scoped_name,
6648            node.name,
6649            node.kind,
6650            node.exported,
6651            node.range.start_line,
6652            node.range.start_col,
6653            node.range.end_line,
6654            node.range.end_col,
6655            node.range_ordinal,
6656            node.signature.as_deref().unwrap_or("")
6657        ));
6658    }
6659    let mut exports = data.exported_symbols.clone();
6660    exports.sort();
6661    for export in exports {
6662        parts.push(format!("export\t{export}"));
6663    }
6664    if let Some(default_export) = &data.default_export_symbol {
6665        parts.push(format!("default\t{default_export}"));
6666    }
6667    let mut imports: Vec<String> = data
6668        .import_block
6669        .imports
6670        .iter()
6671        .map(|import| {
6672            format!(
6673                "import\t{}\t{:?}\t{}",
6674                import.module_path, import.form, import.raw_text
6675            )
6676        })
6677        .collect();
6678    imports.sort();
6679    parts.extend(imports);
6680    parts.extend(reexport_parts.iter().cloned());
6681    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
6682}
6683
6684fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
6685    if raw.kind != "call" {
6686        return Ok(ResolvedRef {
6687            dependencies: raw.dependencies.clone(),
6688            raw,
6689            status: "unresolved".to_string(),
6690            target_node: None,
6691            target_file: None,
6692            target_symbol: None,
6693            edge: None,
6694        });
6695    }
6696
6697    let caller_file = raw.caller_file.clone();
6698    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
6699        CallGraphStoreError::MissingCallerData {
6700            file: caller_file.clone(),
6701        }
6702    })?;
6703    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
6704    let short_name = raw.short_name.as_deref().unwrap_or_default();
6705    let mut dependencies = raw.dependencies.clone();
6706
6707    let resolved = match index.lang_for(&caller_file) {
6708        Some(LangId::Rust) => {
6709            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
6710        }
6711        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
6712            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
6713        }
6714        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
6715    };
6716
6717    let Some((status, target_file, target_symbol)) = resolved else {
6718        return Ok(ResolvedRef {
6719            raw,
6720            status: "unresolved".to_string(),
6721            target_node: None,
6722            target_file: None,
6723            target_symbol: None,
6724            dependencies,
6725            edge: None,
6726        });
6727    };
6728
6729    dependencies.insert(target_file.clone());
6730    let target_node = index.node_for_symbol(&target_file, &target_symbol);
6731    let source_node = raw.caller_node.clone();
6732    let edge = if let Some(source_node) = source_node {
6733        if target_file == caller_file
6734            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
6735        {
6736            None
6737        } else {
6738            Some(EdgeRecord {
6739                edge_id: ref_id(&[&raw.ref_id, "edge"]),
6740                source_node,
6741                target_node: target_node.clone(),
6742                target_file: target_file.clone(),
6743                target_symbol: target_symbol.clone(),
6744                kind: "call".to_string(),
6745                line: raw.line,
6746            })
6747        }
6748    } else {
6749        None
6750    };
6751
6752    Ok(ResolvedRef {
6753        raw,
6754        status,
6755        target_node,
6756        target_file: Some(target_file),
6757        target_symbol: Some(target_symbol),
6758        dependencies,
6759        edge,
6760    })
6761}
6762
6763fn resolve_js_ts_target(
6764    index: &ProjectIndex<'_>,
6765    caller_file: &str,
6766    full_ref: &str,
6767    short_name: &str,
6768    caller_data: &FileCallData,
6769) -> Option<(String, String, String)> {
6770    if let Some((namespace, member)) = full_ref.split_once('.') {
6771        for import in &caller_data.import_block.imports {
6772            if import.namespace_import.as_deref() == Some(namespace) {
6773                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6774                    if let Some((file, symbol)) =
6775                        resolve_exported_symbol(index, &target_file, member, 0)
6776                    {
6777                        return Some(("resolved".to_string(), file, symbol));
6778                    }
6779                }
6780            }
6781        }
6782    }
6783
6784    for import in &caller_data.import_block.imports {
6785        for spec in &import.names {
6786            if crate::imports::specifier_local_name(spec) == short_name {
6787                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6788                    let requested = crate::imports::specifier_imported_name(spec);
6789                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
6790                        .unwrap_or_else(|| (target_file, requested.to_string()));
6791                    return Some(("resolved".to_string(), file, symbol));
6792                }
6793            }
6794        }
6795
6796        if import.default_import.as_deref() == Some(short_name) {
6797            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6798                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
6799                    .or_else(|| {
6800                        index
6801                            .files
6802                            .get(&target_file)
6803                            .and_then(|file| file.default_export.clone())
6804                            .map(|symbol| (target_file.clone(), symbol))
6805                    })
6806                    .unwrap_or_else(|| {
6807                        let file_name = Path::new(&target_file)
6808                            .file_name()
6809                            .and_then(|name| name.to_str())
6810                            .unwrap_or("unknown")
6811                            .to_string();
6812                        (target_file, format!("<default:{file_name}>"))
6813                    });
6814                return Some(("resolved".to_string(), file, symbol));
6815            }
6816        }
6817    }
6818
6819    for import in &caller_data.import_block.imports {
6820        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
6821            if index
6822                .files
6823                .get(&target_file)
6824                .map(|file| file.exports.contains(short_name))
6825                .unwrap_or(false)
6826            {
6827                return Some(("resolved".to_string(), target_file, short_name.to_string()));
6828            }
6829        }
6830    }
6831
6832    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6833}
6834
6835fn resolve_exported_symbol(
6836    index: &ProjectIndex<'_>,
6837    file: &str,
6838    requested: &str,
6839    depth: usize,
6840) -> Option<(String, String)> {
6841    let mut visited = std::collections::HashMap::new();
6842    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
6843}
6844
6845/// Re-export graphs are frequently cyclic (barrel files re-exporting each
6846/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
6847/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
6848/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
6849/// (file, symbol) pair — but only when the earlier visit had at least as much
6850/// remaining depth budget (a shallower re-visit can reach leaves the deeper
6851/// first visit had to cut off at the cap, so plain visited-set pruning would
6852/// lose resolutions the capped walk finds).
6853fn resolve_exported_symbol_inner(
6854    index: &ProjectIndex<'_>,
6855    file: &str,
6856    requested: &str,
6857    depth: usize,
6858    visited: &mut std::collections::HashMap<(String, String), usize>,
6859) -> Option<(String, String)> {
6860    if depth > 16 {
6861        return None;
6862    }
6863    if requested != "default" {
6864        if let Some(source_symbol) = index
6865            .files
6866            .get(file)
6867            .and_then(|item| item.export_aliases.get(requested))
6868        {
6869            return Some((file.to_string(), source_symbol.clone()));
6870        }
6871        if index
6872            .files
6873            .get(file)
6874            .map(|item| item.exports.contains(requested))
6875            .unwrap_or(false)
6876        {
6877            return Some((file.to_string(), requested.to_string()));
6878        }
6879    } else if let Some(default) = index
6880        .files
6881        .get(file)
6882        .and_then(|item| item.default_export.clone())
6883    {
6884        return Some((file.to_string(), default));
6885    }
6886
6887    // Memo check sits after the local-export fast paths: the common direct
6888    // hit never allocates the key, and a hit through the memo would have
6889    // returned above anyway.
6890    match visited.entry((file.to_string(), requested.to_string())) {
6891        std::collections::hash_map::Entry::Occupied(mut seen) => {
6892            if *seen.get() <= depth {
6893                return None;
6894            }
6895            seen.insert(depth);
6896        }
6897        std::collections::hash_map::Entry::Vacant(slot) => {
6898            slot.insert(depth);
6899        }
6900    }
6901
6902    for reexport in index.reexports_for(file) {
6903        let mut next_requested = requested.to_string();
6904        let matches = if reexport.wildcard {
6905            true
6906        } else if let Some(source_name) = reexport.named.get(requested) {
6907            next_requested = source_name.clone();
6908            true
6909        } else {
6910            false
6911        };
6912        if !matches {
6913            continue;
6914        }
6915        if let Some(target_file) = &reexport.target_file {
6916            if let Some(target) = resolve_exported_symbol_inner(
6917                index,
6918                target_file,
6919                &next_requested,
6920                depth + 1,
6921                visited,
6922            ) {
6923                return Some(target);
6924            }
6925        }
6926    }
6927    None
6928}
6929
6930fn resolve_rust_target(
6931    index: &ProjectIndex<'_>,
6932    caller_file: &str,
6933    full_ref: &str,
6934    short_name: &str,
6935    caller_data: &FileCallData,
6936    raw: &RawRef,
6937) -> Option<(String, String, String)> {
6938    if full_ref.contains("::") {
6939        if let Some((target_file, target_symbol)) =
6940            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
6941        {
6942            return Some(("resolved".to_string(), target_file, target_symbol));
6943        }
6944    }
6945
6946    for import in &caller_data.import_block.imports {
6947        if let Some((target_file, target_symbol)) =
6948            rust_target_for_use(index, caller_file, import, short_name)
6949        {
6950            return Some(("resolved".to_string(), target_file, target_symbol));
6951        }
6952    }
6953
6954    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
6955}
6956
6957fn rust_target_for_qualified(
6958    index: &ProjectIndex<'_>,
6959    caller_file: &str,
6960    full_ref: &str,
6961    short_name: &str,
6962    caller_data: &FileCallData,
6963    raw: &RawRef,
6964) -> Option<(String, String)> {
6965    let mut segments: Vec<&str> = full_ref.split("::").collect();
6966    if segments.len() < 2 {
6967        return None;
6968    }
6969    segments.pop();
6970    let requested_symbol = rust_target_symbol(full_ref, short_name);
6971
6972    for path in rust_module_path_candidates(&segments, caller_data, raw) {
6973        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
6974        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
6975            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
6976                return Some(rust_resolve_reexport_if_symbol_missing(
6977                    index,
6978                    target_file,
6979                    requested_symbol.clone(),
6980                ));
6981            }
6982        }
6983
6984        let module_segments = rust_resolve_segments(caller_file, &path_refs)?;
6985        if let Some(target) =
6986            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
6987        {
6988            return Some(target);
6989        }
6990        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
6991            return Some(rust_resolve_reexport_if_symbol_missing(
6992                index,
6993                target_file,
6994                requested_symbol.clone(),
6995            ));
6996        }
6997    }
6998    None
6999}
7000
7001fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
7002    full_ref
7003        .rsplit("::")
7004        .next()
7005        .filter(|name| !name.is_empty())
7006        .unwrap_or(short_name)
7007        .to_string()
7008}
7009
7010fn rust_resolve_reexport_if_symbol_missing(
7011    index: &ProjectIndex<'_>,
7012    target_file: String,
7013    target_symbol: String,
7014) -> (String, String) {
7015    if index
7016        .node_for_symbol(&target_file, &target_symbol)
7017        .is_some()
7018    {
7019        return (target_file, target_symbol);
7020    }
7021    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
7022        resolved
7023    } else {
7024        (target_file, target_symbol)
7025    }
7026}
7027
7028fn rust_module_path_candidates(
7029    segments: &[&str],
7030    caller_data: &FileCallData,
7031    raw: &RawRef,
7032) -> Vec<Vec<String>> {
7033    let mut candidates = Vec::new();
7034    if let Some(first) = segments.first().copied() {
7035        for import in &caller_data.import_block.imports {
7036            if !rust_import_is_visible_to_call(import, raw) {
7037                continue;
7038            }
7039            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
7040                continue;
7041            };
7042            if local_name == first {
7043                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
7044                rust_push_unique_path_candidate(&mut candidates, path_segments);
7045            }
7046        }
7047    }
7048    rust_push_unique_path_candidate(
7049        &mut candidates,
7050        segments
7051            .iter()
7052            .map(|segment| (*segment).to_string())
7053            .collect(),
7054    );
7055    candidates
7056}
7057
7058fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
7059    if !candidates.iter().any(|existing| existing == &candidate) {
7060        candidates.push(candidate);
7061    }
7062}
7063
7064fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
7065    import.byte_range.start <= raw.byte_start
7066}
7067
7068fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
7069    let path = import.module_path.trim().trim_end_matches(';').trim();
7070    if path.contains("::{") || path.contains('{') || path.contains('*') {
7071        return None;
7072    }
7073    let (path_without_alias, alias) = path
7074        .split_once(" as ")
7075        .map(|(left, right)| (left.trim(), Some(right.trim())))
7076        .unwrap_or((path, None));
7077    let segments = path_without_alias
7078        .split("::")
7079        .map(str::trim)
7080        .filter(|segment| !segment.is_empty())
7081        .collect::<Vec<_>>();
7082    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
7083    if local_name.chars().next().is_some_and(char::is_uppercase) {
7084        return None;
7085    }
7086    Some((
7087        local_name,
7088        segments
7089            .into_iter()
7090            .map(|segment| segment.to_string())
7091            .collect(),
7092    ))
7093}
7094
7095fn rust_inline_scoped_target(
7096    index: &ProjectIndex<'_>,
7097    caller_file: &str,
7098    module_segments: &[String],
7099    short_name: &str,
7100) -> Option<(String, String)> {
7101    let src_prefix = rust_src_prefix(caller_file);
7102    let mut file_paths = index.files.keys().cloned().collect::<Vec<_>>();
7103    file_paths.sort();
7104    if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
7105        let caller = file_paths.remove(position);
7106        file_paths.insert(0, caller);
7107    }
7108
7109    for file_path in file_paths {
7110        if index.lang_for(&file_path) != Some(LangId::Rust)
7111            || rust_src_prefix(&file_path) != src_prefix
7112        {
7113            continue;
7114        }
7115        let file_module_segments = rust_module_segments_for_rel(&file_path);
7116        if !module_segments.starts_with(&file_module_segments) {
7117            continue;
7118        }
7119        let scoped_segments = &module_segments[file_module_segments.len()..];
7120        if scoped_segments.is_empty() {
7121            continue;
7122        }
7123        let mut scoped_symbol = scoped_segments.join("::");
7124        scoped_symbol.push_str("::");
7125        scoped_symbol.push_str(short_name);
7126        if index.node_for_symbol(&file_path, &scoped_symbol).is_some() {
7127            return Some((file_path, scoped_symbol));
7128        }
7129    }
7130    None
7131}
7132
7133fn rust_target_for_use(
7134    index: &ProjectIndex<'_>,
7135    caller_file: &str,
7136    import: &ImportStatement,
7137    short_name: &str,
7138) -> Option<(String, String)> {
7139    let path = import.module_path.trim().trim_end_matches(';');
7140    if let Some(brace_start) = path.find("::{") {
7141        let prefix = &path[..brace_start];
7142        if import.names.iter().any(|name| name == short_name) {
7143            let prefix_segments: Vec<&str> = prefix.split("::").collect();
7144            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
7145            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7146            return Some((file, short_name.to_string()));
7147        }
7148        return None;
7149    }
7150
7151    let (path_without_alias, alias) = path
7152        .split_once(" as ")
7153        .map(|(left, right)| (left.trim(), Some(right.trim())))
7154        .unwrap_or((path, None));
7155    let segments: Vec<&str> = path_without_alias.split("::").collect();
7156    let imported = alias.or_else(|| segments.last().copied())?;
7157    if imported != short_name {
7158        return None;
7159    }
7160    if segments.len() < 2 {
7161        return None;
7162    }
7163    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
7164    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
7165    Some((file, segments.last().unwrap_or(&short_name).to_string()))
7166}
7167
7168fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
7169    let crate_name = segments.first().copied()?;
7170    let src_prefix = index.crate_src_prefix(crate_name)?;
7171    let module_segments = segments[1..]
7172        .iter()
7173        .map(|segment| segment.to_string())
7174        .collect::<Vec<_>>();
7175    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
7176}
7177
7178#[cfg(test)]
7179static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
7180    OnceLock::new();
7181
7182#[cfg(test)]
7183fn note_workspace_crate_prefix_build(project_root: &Path) {
7184    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7185        .get_or_init(|| Mutex::new(HashMap::new()))
7186        .lock()
7187        .expect("workspace crate prefix build counts mutex poisoned");
7188    *counts.entry(project_root.to_path_buf()).or_default() += 1;
7189}
7190
7191#[cfg(not(test))]
7192fn note_workspace_crate_prefix_build(_project_root: &Path) {}
7193
7194#[cfg(test)]
7195fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
7196    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7197        .get_or_init(|| Mutex::new(HashMap::new()))
7198        .lock()
7199        .expect("workspace crate prefix build counts mutex poisoned")
7200        .remove(project_root);
7201}
7202
7203#[cfg(test)]
7204fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
7205    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
7206        .get_or_init(|| Mutex::new(HashMap::new()))
7207        .lock()
7208        .expect("workspace crate prefix build counts mutex poisoned")
7209        .get(project_root)
7210        .copied()
7211        .unwrap_or(0)
7212}
7213
7214/// Walk the project tree once and map every Rust crate name (package name with
7215/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
7216/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
7217/// longer re-walks the filesystem once per ref.
7218fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
7219    note_workspace_crate_prefix_build(project_root);
7220    let mut prefixes = HashMap::new();
7221    let mut stack = vec![project_root.to_path_buf()];
7222    while let Some(dir) = stack.pop() {
7223        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
7224        if matches!(name, "target" | "node_modules" | ".git") {
7225            continue;
7226        }
7227        let manifest = dir.join("Cargo.toml");
7228        if manifest.is_file() {
7229            let crate_names = rust_manifest_crate_names(&manifest);
7230            if !crate_names.is_empty() {
7231                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
7232                for crate_name in crate_names {
7233                    prefixes
7234                        .entry(crate_name)
7235                        .or_insert_with(|| src_prefix.clone());
7236                }
7237            }
7238        }
7239        let Ok(entries) = std::fs::read_dir(&dir) else {
7240            continue;
7241        };
7242        for entry in entries.flatten() {
7243            let path = entry.path();
7244            if path.is_dir() {
7245                stack.push(path);
7246            }
7247        }
7248    }
7249    prefixes
7250}
7251
7252/// Extract the crate names a manifest defines: the normalized package name
7253/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
7254/// reachable by either spelling, matching the previous match semantics.
7255fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
7256    let Ok(source) = std::fs::read_to_string(manifest) else {
7257        return Vec::new();
7258    };
7259    let mut in_lib = false;
7260    let mut package_name = None;
7261    let mut lib_name = None;
7262    for line in source.lines() {
7263        let trimmed = line.trim();
7264        if trimmed.starts_with('[') {
7265            in_lib = trimmed == "[lib]";
7266            continue;
7267        }
7268        let Some((key, value)) = trimmed.split_once('=') else {
7269            continue;
7270        };
7271        let key = key.trim();
7272        let value = value.trim().trim_matches('"');
7273        if in_lib && key == "name" {
7274            lib_name = Some(value.to_string());
7275        } else if !in_lib && key == "name" && package_name.is_none() {
7276            package_name = Some(value.to_string());
7277        }
7278    }
7279    let mut names = Vec::new();
7280    if let Some(lib) = lib_name {
7281        names.push(lib);
7282    }
7283    if let Some(package) = package_name {
7284        let normalized = package.replace('-', "_");
7285        if !names.contains(&normalized) {
7286            names.push(normalized);
7287        }
7288    }
7289    names
7290}
7291
7292fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
7293    if segments.is_empty() {
7294        return Some(Vec::new());
7295    }
7296    let caller_segments = rust_module_segments_for_rel(caller_file);
7297    match segments[0] {
7298        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
7299        "self" => {
7300            let mut resolved = caller_segments;
7301            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7302            Some(resolved)
7303        }
7304        "super" => {
7305            let mut resolved = caller_segments;
7306            resolved.pop();
7307            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
7308            Some(resolved)
7309        }
7310        _ => {
7311            let mut resolved = caller_segments;
7312            resolved.pop();
7313            resolved.extend(segments.iter().map(|item| item.to_string()));
7314            Some(resolved)
7315        }
7316    }
7317}
7318
7319fn rust_file_for_segments(
7320    index: &ProjectIndex<'_>,
7321    caller_file: &str,
7322    segments: &[String],
7323) -> Option<String> {
7324    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
7325}
7326
7327fn rust_file_for_src_prefix(
7328    index: &ProjectIndex<'_>,
7329    src_prefix: &str,
7330    segments: &[String],
7331) -> Option<String> {
7332    let candidate = if segments.is_empty() {
7333        [src_prefix, "lib.rs"].join("/")
7334    } else {
7335        format!("{}/{}.rs", src_prefix, segments.join("/"))
7336    };
7337    if index.files.contains_key(&candidate) {
7338        return Some(candidate);
7339    }
7340    if !segments.is_empty() {
7341        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
7342        if index.files.contains_key(&mod_candidate) {
7343            return Some(mod_candidate);
7344        }
7345    }
7346    None
7347}
7348
7349fn rust_src_prefix(rel_path: &str) -> String {
7350    rel_path
7351        .split_once("/src/")
7352        .map(|(prefix, _)| format!("{prefix}/src"))
7353        .unwrap_or_else(|| "src".to_string())
7354}
7355
7356fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
7357    let after_src = rel_path
7358        .split_once("/src/")
7359        .map(|(_, rest)| rest)
7360        .or_else(|| rel_path.strip_prefix("src/"))
7361        .unwrap_or(rel_path);
7362    if matches!(after_src, "lib.rs" | "main.rs") {
7363        return Vec::new();
7364    }
7365    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
7366        return prefix.split('/').map(|item| item.to_string()).collect();
7367    }
7368    after_src
7369        .strip_suffix(".rs")
7370        .unwrap_or(after_src)
7371        .split('/')
7372        .map(|item| item.to_string())
7373        .collect()
7374}
7375
7376fn resolve_local_target(
7377    _index: &ProjectIndex<'_>,
7378    caller_file: &str,
7379    full_ref: &str,
7380    short_name: &str,
7381    caller_data: &FileCallData,
7382) -> Option<(String, String, String)> {
7383    if !callgraph::is_bare_callee(full_ref, short_name) {
7384        return None;
7385    }
7386    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
7387        .ok()
7388        .map(|symbol| {
7389            (
7390                "resolved_local".to_string(),
7391                caller_file.to_string(),
7392                symbol,
7393            )
7394        })
7395}
7396
7397impl<'a> ProjectIndex<'a> {
7398    fn from_parts(
7399        project_root: &Path,
7400        files: HashMap<String, DbFileIndex>,
7401        caller_data: HashMap<String, &'a FileCallData>,
7402        workspace_crate_prefixes: WorkspaceCratePrefixCache,
7403    ) -> Self {
7404        Self {
7405            project_root: project_root.to_path_buf(),
7406            files,
7407            caller_data,
7408            workspace_crate_prefixes,
7409        }
7410    }
7411
7412    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
7413        let mut files = HashMap::new();
7414        let mut caller_data = HashMap::new();
7415        for extract in extracts {
7416            let index = DbFileIndex::from_extract(project_root, extract);
7417            caller_data.insert(extract.rel_path.clone(), &extract.data);
7418            files.insert(extract.rel_path.clone(), index);
7419        }
7420        Self::from_parts(
7421            project_root,
7422            files,
7423            caller_data,
7424            WorkspaceCratePrefixCache::default(),
7425        )
7426    }
7427
7428    fn from_db_and_callers(
7429        tx: &Transaction<'_>,
7430        project_root: &Path,
7431        caller_extracts: &'a HashMap<String, FileExtract>,
7432        workspace_crate_prefixes: WorkspaceCratePrefixCache,
7433    ) -> Result<Self> {
7434        let mut files = load_db_file_indexes(tx, project_root)?;
7435        let mut caller_data = HashMap::new();
7436        for (rel_path, extract) in caller_extracts {
7437            files.insert(
7438                rel_path.clone(),
7439                DbFileIndex::from_extract(project_root, extract),
7440            );
7441            caller_data.insert(rel_path.clone(), &extract.data);
7442        }
7443        Ok(Self::from_parts(
7444            project_root,
7445            files,
7446            caller_data,
7447            workspace_crate_prefixes,
7448        ))
7449    }
7450
7451    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
7452        self.files.get(rel_path).and_then(|file| file.lang)
7453    }
7454
7455    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
7456        self.files
7457            .get(caller_file)
7458            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
7459    }
7460
7461    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
7462        self.files
7463            .get(rel_path)
7464            .map(|file| file.reexports.as_slice())
7465            .unwrap_or(&[])
7466    }
7467
7468    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
7469        self.files.get(rel_path).and_then(|file| {
7470            file.node_by_scoped
7471                .get(symbol)
7472                .cloned()
7473                .or_else(|| file.node_by_bare.get(symbol).cloned())
7474        })
7475    }
7476}
7477
7478impl DbFileIndex {
7479    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
7480        let mut node_by_scoped = HashMap::new();
7481        let mut node_by_bare = HashMap::new();
7482        for node in &extract.nodes {
7483            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
7484            node_by_bare
7485                .entry(node.name.clone())
7486                .or_insert(node.id.clone());
7487        }
7488        let mut export_aliases = HashMap::new();
7489        for raw_ref in &extract.raw_refs {
7490            if raw_ref.kind == "export_alias" {
7491                if let (Some(exported), Some(source_symbol)) =
7492                    (&raw_ref.local_name, &raw_ref.requested_name)
7493                {
7494                    export_aliases.insert(exported.clone(), source_symbol.clone());
7495                }
7496            }
7497        }
7498        let mut module_targets = HashMap::new();
7499        let mut reexports = Vec::new();
7500        for raw_ref in &extract.raw_refs {
7501            if !matches!(raw_ref.kind.as_str(), "import" | "reexport") {
7502                continue;
7503            }
7504            let Some(module_path) = &raw_ref.module_path else {
7505                continue;
7506            };
7507            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
7508            module_targets
7509                .entry(module_path.clone())
7510                .or_insert_with(|| target_file.clone());
7511            if raw_ref.kind == "reexport" {
7512                reexports.push(reexport_index_from_raw(raw_ref, target_file));
7513            }
7514        }
7515        Self {
7516            lang: Some(extract.lang),
7517            exports: extract.data.exported_symbols.iter().cloned().collect(),
7518            default_export: extract.data.default_export_symbol.clone(),
7519            export_aliases,
7520            node_by_scoped,
7521            node_by_bare,
7522            module_targets,
7523            reexports,
7524        }
7525    }
7526}
7527
7528fn load_db_file_indexes(
7529    tx: &Transaction<'_>,
7530    project_root: &Path,
7531) -> Result<HashMap<String, DbFileIndex>> {
7532    let mut files = HashMap::new();
7533    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
7534    let rows = stmt.query_map([], |row| {
7535        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7536    })?;
7537    for row in rows {
7538        let (rel_path, lang) = row?;
7539        files.insert(
7540            rel_path.clone(),
7541            DbFileIndex {
7542                lang: lang_from_label(&lang),
7543                exports: HashSet::new(),
7544                default_export: None,
7545                export_aliases: HashMap::new(),
7546                node_by_scoped: HashMap::new(),
7547                node_by_bare: HashMap::new(),
7548                module_targets: HashMap::new(),
7549                reexports: Vec::new(),
7550            },
7551        );
7552    }
7553
7554    let mut node_stmt = tx.prepare(
7555        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
7556    )?;
7557    let nodes = node_stmt.query_map([], |row| {
7558        Ok((
7559            row.get::<_, String>(0)?,
7560            row.get::<_, String>(1)?,
7561            row.get::<_, String>(2)?,
7562            row.get::<_, String>(3)?,
7563            row.get::<_, i64>(4)? != 0,
7564            row.get::<_, i64>(5)? != 0,
7565        ))
7566    })?;
7567    for row in nodes {
7568        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
7569        let file = files
7570            .entry(file_path.clone())
7571            .or_insert_with(|| DbFileIndex {
7572                lang: None,
7573                exports: HashSet::new(),
7574                default_export: None,
7575                export_aliases: HashMap::new(),
7576                node_by_scoped: HashMap::new(),
7577                node_by_bare: HashMap::new(),
7578                module_targets: HashMap::new(),
7579                reexports: Vec::new(),
7580            });
7581        if exported {
7582            file.exports.insert(name.clone());
7583            file.exports.insert(scoped_name.clone());
7584        }
7585        if is_default_export {
7586            file.default_export = Some(scoped_name.clone());
7587        }
7588        file.node_by_scoped.insert(scoped_name, id.clone());
7589        file.node_by_bare.entry(name).or_insert(id);
7590    }
7591    let file_keys: HashSet<String> = files.keys().cloned().collect();
7592    // Persisted caller extracts supply import targets. Only reexports from other
7593    // files need dependency reconstruction, and their caller dependencies are
7594    // loaded once instead of issuing repeated SQLite queries per reference.
7595    let dependencies_by_file = load_file_dependencies_index(tx)?;
7596    let mut ref_stmt = tx.prepare(
7597        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
7598         FROM refs WHERE kind IN ('reexport', 'export_alias')",
7599    )?;
7600    let ref_rows = ref_stmt.query_map([], |row| {
7601        Ok((
7602            row.get::<_, String>(0)?,
7603            row.get::<_, String>(1)?,
7604            row.get::<_, String>(2)?,
7605            row.get::<_, Option<String>>(3)?,
7606            row.get::<_, Option<String>>(4)?,
7607            row.get::<_, i64>(5)? != 0,
7608            row.get::<_, Option<String>>(6)?,
7609            row.get::<_, Option<String>>(7)?,
7610        ))
7611    })?;
7612    for row in ref_rows {
7613        let (
7614            ref_id,
7615            caller_file,
7616            kind,
7617            module_path,
7618            full_ref,
7619            wildcard,
7620            local_name,
7621            requested_name,
7622        ) = row?;
7623        if kind == "export_alias" {
7624            if let (Some(exported), Some(source_symbol), Some(file)) =
7625                (local_name, requested_name, files.get_mut(&caller_file))
7626            {
7627                file.export_aliases.insert(exported, source_symbol);
7628            }
7629            continue;
7630        }
7631        let Some(module_path) = module_path else {
7632            continue;
7633        };
7634        let file_deps = dependencies_by_file
7635            .get(&caller_file)
7636            .cloned()
7637            .unwrap_or_default();
7638        let deps = stored_dependencies_for_module(
7639            project_root,
7640            &caller_file,
7641            &module_path,
7642            &file_deps,
7643            &file_keys,
7644        );
7645        let target_file = deps
7646            .iter()
7647            .find(|dep| file_keys.contains(*dep))
7648            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
7649        if let Some(file) = files.get_mut(&caller_file) {
7650            file.module_targets
7651                .entry(module_path.clone())
7652                .or_insert_with(|| target_file.clone());
7653            if kind == "reexport" {
7654                let raw = RawRef {
7655                    ref_id,
7656                    caller_node: None,
7657                    caller_symbol: None,
7658                    caller_file,
7659                    kind,
7660                    short_name: None,
7661                    full_ref,
7662                    module_path: Some(module_path),
7663                    import_kind: Some("reexport".to_string()),
7664                    local_name: None,
7665                    requested_name: None,
7666                    namespace_alias: None,
7667                    wildcard,
7668                    line: 0,
7669                    byte_start: 0,
7670                    byte_end: 0,
7671                    dependencies: deps,
7672                };
7673                file.reexports
7674                    .push(reexport_index_from_raw(&raw, target_file));
7675            }
7676        }
7677    }
7678
7679    Ok(files)
7680}
7681
7682fn stored_dependencies_for_module(
7683    project_root: &Path,
7684    caller_file: &str,
7685    module_path: &str,
7686    caller_dependencies: &BTreeSet<String>,
7687    indexed_files: &HashSet<String>,
7688) -> BTreeSet<String> {
7689    let caller_path = project_root.join(caller_file);
7690    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
7691    if module_path.starts_with('.') {
7692        let caller_dir = caller_path.parent().unwrap_or(project_root);
7693        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
7694            let normalized = if candidate.is_file() {
7695                canonicalize_path(&candidate)
7696            } else {
7697                candidate
7698            };
7699            candidates.insert(relative_path(project_root, &normalized));
7700        }
7701    }
7702    let exact = candidates
7703        .intersection(caller_dependencies)
7704        .filter(|dependency| indexed_files.contains(*dependency))
7705        .cloned()
7706        .collect::<BTreeSet<_>>();
7707    if !exact.is_empty() || module_path.starts_with('.') {
7708        return exact;
7709    }
7710
7711    let module_path = rust_module_path_without_alias_or_use_list(module_path)
7712        .trim_matches(|character| matches!(character, '\'' | '"'));
7713    let package_name = module_path
7714        .split('/')
7715        .next_back()
7716        .unwrap_or(module_path)
7717        .replace('_', "-");
7718    let matched = caller_dependencies
7719        .iter()
7720        .filter(|dependency| indexed_files.contains(*dependency))
7721        .filter(|dependency| {
7722            dependency.as_str() == module_path
7723                || dependency.ends_with(&format!("/{module_path}"))
7724                || Path::new(dependency).components().any(|component| {
7725                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
7726                })
7727        })
7728        .cloned()
7729        .collect::<BTreeSet<_>>();
7730    if matched.len() == 1 {
7731        matched
7732    } else {
7733        BTreeSet::new()
7734    }
7735}
7736
7737fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
7738    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
7739    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
7740    let rows = stmt.query_map([], |row| {
7741        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7742    })?;
7743    for row in rows {
7744        let (file_path, dependency) = row?;
7745        by_file.entry(file_path).or_default().insert(dependency);
7746    }
7747    Ok(by_file)
7748}
7749
7750struct ColdBuildInsertStatements<'stmt> {
7751    file: Statement<'stmt>,
7752    node: Statement<'stmt>,
7753    file_dependency: Statement<'stmt>,
7754    dispatch_hint: Statement<'stmt>,
7755    backend_state: Statement<'stmt>,
7756    reference: Statement<'stmt>,
7757    edge: Statement<'stmt>,
7758}
7759
7760impl<'stmt> ColdBuildInsertStatements<'stmt> {
7761    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
7762        Ok(Self {
7763            file: tx.prepare(
7764                "INSERT OR REPLACE INTO files(
7765                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7766                    is_public_api, surface_fingerprint, indexed_at
7767                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7768            )?,
7769            node: tx.prepare(
7770                "INSERT OR REPLACE INTO nodes(
7771                    id, file_path, name, scoped_name, kind, start_line, start_col,
7772                    end_line, end_col, range_ordinal, signature, exported,
7773                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
7774                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7775            )?,
7776            file_dependency: tx.prepare(
7777                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
7778            )?,
7779            dispatch_hint: tx.prepare(
7780                "INSERT OR REPLACE INTO dispatch_hints(
7781                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7782                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7783            )?,
7784            backend_state: tx.prepare(
7785                "INSERT OR REPLACE INTO backend_file_state(
7786                    backend, workspace_root, file_path, content_hash, status, updated_at
7787                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
7788            )?,
7789            reference: tx.prepare(
7790                "INSERT OR REPLACE INTO refs(
7791                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
7792                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
7793                    byte_start, byte_end, status, target_node, target_file, target_symbol,
7794                    provenance
7795                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
7796            )?,
7797            edge: tx.prepare(
7798                "INSERT OR REPLACE INTO edges(
7799                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
7800                    kind, line, provenance
7801                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7802            )?,
7803        })
7804    }
7805}
7806
7807fn insert_file_extract_prepared(
7808    statements: &mut ColdBuildInsertStatements<'_>,
7809    workspace_root: &str,
7810    extract: &FileExtract,
7811) -> Result<()> {
7812    statements.file.execute(params![
7813        extract.rel_path,
7814        hash_to_hex(extract.freshness.content_hash),
7815        system_time_to_ns(extract.freshness.mtime),
7816        extract.freshness.size as i64,
7817        lang_label(extract.lang),
7818        extract.surface_fingerprint,
7819        unix_seconds_now(),
7820    ])?;
7821    for node in &extract.nodes {
7822        statements.node.execute(params![
7823            node.id,
7824            node.file_path,
7825            node.name,
7826            node.scoped_name,
7827            node.kind,
7828            node.range.start_line as i64,
7829            node.range.start_col as i64,
7830            node.range.end_line as i64,
7831            node.range.end_col as i64,
7832            node.range_ordinal as i64,
7833            node.signature,
7834            bool_int(node.exported),
7835            bool_int(node.is_default_export),
7836            bool_int(node.is_type_like),
7837            bool_int(node.is_callgraph_entry_point),
7838            PROVENANCE_TREESITTER,
7839        ])?;
7840    }
7841
7842    let mut dependencies = BTreeSet::new();
7843    for raw_ref in &extract.raw_refs {
7844        dependencies.extend(raw_ref.dependencies.iter().cloned());
7845    }
7846    for dep_file in &dependencies {
7847        statements
7848            .file_dependency
7849            .execute(params![extract.rel_path, dep_file])?;
7850    }
7851
7852    for hint in &extract.dispatch_hints {
7853        statements.dispatch_hint.execute(params![
7854            hint.id,
7855            hint.method_name,
7856            hint.caller_node,
7857            hint.file,
7858            hint.line as i64,
7859            hint.byte_start as i64,
7860            hint.byte_end as i64,
7861            PROVENANCE_TREESITTER,
7862        ])?;
7863    }
7864    insert_backend_state_prepared(
7865        &mut statements.backend_state,
7866        workspace_root,
7867        &extract.rel_path,
7868        Some(&extract.freshness.content_hash),
7869        "fresh",
7870    )?;
7871    Ok(())
7872}
7873
7874fn insert_backend_state_prepared(
7875    stmt: &mut Statement<'_>,
7876    workspace_root: &str,
7877    rel_path: &str,
7878    content_hash: Option<&blake3::Hash>,
7879    status: &str,
7880) -> Result<()> {
7881    let hash = content_hash
7882        .map(|hash| hash_to_hex(*hash))
7883        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
7884    stmt.execute(params![
7885        BACKEND_TREESITTER,
7886        workspace_root,
7887        rel_path,
7888        hash,
7889        status,
7890        unix_seconds_now(),
7891    ])?;
7892    Ok(())
7893}
7894
7895fn insert_resolved_ref_prepared(
7896    statements: &mut ColdBuildInsertStatements<'_>,
7897    resolved: &ResolvedRef,
7898) -> Result<()> {
7899    let raw = &resolved.raw;
7900    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
7901    statements.reference.execute(params![
7902        raw.ref_id,
7903        raw.caller_node,
7904        raw.caller_file,
7905        raw.kind,
7906        raw.short_name,
7907        raw.full_ref,
7908        raw.module_path,
7909        raw.import_kind,
7910        raw.local_name,
7911        raw.requested_name,
7912        raw.namespace_alias,
7913        bool_int(raw.wildcard),
7914        raw.line as i64,
7915        raw.byte_start as i64,
7916        raw.byte_end as i64,
7917        resolved.status,
7918        resolved.target_node,
7919        resolved.target_file,
7920        resolved.target_symbol,
7921        PROVENANCE_TREESITTER,
7922    ])?;
7923    if let Some(edge) = &resolved.edge {
7924        statements.edge.execute(params![
7925            edge.edge_id,
7926            raw.ref_id,
7927            edge.source_node,
7928            edge.target_node,
7929            edge.target_file,
7930            edge.target_symbol,
7931            edge.kind,
7932            edge.line as i64,
7933            PROVENANCE_TREESITTER,
7934        ])?;
7935    }
7936    Ok(())
7937}
7938
7939fn insert_file_extract(
7940    tx: &Transaction<'_>,
7941    project_root: &Path,
7942    extract: &FileExtract,
7943) -> Result<()> {
7944    tx.execute(
7945        "INSERT OR REPLACE INTO files(
7946            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
7947            is_public_api, surface_fingerprint, indexed_at
7948        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
7949        params![
7950            extract.rel_path,
7951            hash_to_hex(extract.freshness.content_hash),
7952            system_time_to_ns(extract.freshness.mtime),
7953            extract.freshness.size as i64,
7954            lang_label(extract.lang),
7955            extract.surface_fingerprint,
7956            unix_seconds_now(),
7957        ],
7958    )?;
7959    for node in &extract.nodes {
7960        tx.execute(
7961            "INSERT OR REPLACE INTO nodes(
7962                id, file_path, name, scoped_name, kind, start_line, start_col,
7963                end_line, end_col, range_ordinal, signature, exported,
7964                is_default_export, is_type_like, is_callgraph_entry_point, provenance
7965            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
7966            params![
7967                node.id,
7968                node.file_path,
7969                node.name,
7970                node.scoped_name,
7971                node.kind,
7972                node.range.start_line as i64,
7973                node.range.start_col as i64,
7974                node.range.end_line as i64,
7975                node.range.end_col as i64,
7976                node.range_ordinal as i64,
7977                node.signature,
7978                bool_int(node.exported),
7979                bool_int(node.is_default_export),
7980                bool_int(node.is_type_like),
7981                bool_int(node.is_callgraph_entry_point),
7982                PROVENANCE_TREESITTER,
7983            ],
7984        )?;
7985    }
7986    let mut dependencies = BTreeSet::new();
7987    for raw_ref in &extract.raw_refs {
7988        dependencies.extend(raw_ref.dependencies.iter().cloned());
7989    }
7990    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
7991
7992    for hint in &extract.dispatch_hints {
7993        tx.execute(
7994            "INSERT OR REPLACE INTO dispatch_hints(
7995                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
7996            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
7997            params![
7998                hint.id,
7999                hint.method_name,
8000                hint.caller_node,
8001                hint.file,
8002                hint.line as i64,
8003                hint.byte_start as i64,
8004                hint.byte_end as i64,
8005                PROVENANCE_TREESITTER,
8006            ],
8007        )?;
8008    }
8009    mark_backend_state(
8010        tx,
8011        project_root,
8012        &extract.rel_path,
8013        Some(&extract.freshness.content_hash),
8014        "fresh",
8015    )?;
8016    Ok(())
8017}
8018
8019fn insert_file_dependencies(
8020    tx: &Transaction<'_>,
8021    file_path: &str,
8022    dependencies: &BTreeSet<String>,
8023) -> Result<()> {
8024    for dep_file in dependencies {
8025        tx.execute(
8026            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
8027            params![file_path, dep_file],
8028        )?;
8029    }
8030    Ok(())
8031}
8032
8033fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
8034    let raw = &resolved.raw;
8035    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
8036    tx.execute(
8037        "INSERT OR REPLACE INTO refs(
8038            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
8039            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
8040            byte_start, byte_end, status, target_node, target_file, target_symbol,
8041            provenance
8042        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
8043        params![
8044            raw.ref_id,
8045            raw.caller_node,
8046            raw.caller_file,
8047            raw.kind,
8048            raw.short_name,
8049            raw.full_ref,
8050            raw.module_path,
8051            raw.import_kind,
8052            raw.local_name,
8053            raw.requested_name,
8054            raw.namespace_alias,
8055            bool_int(raw.wildcard),
8056            raw.line as i64,
8057            raw.byte_start as i64,
8058            raw.byte_end as i64,
8059            resolved.status,
8060            resolved.target_node,
8061            resolved.target_file,
8062            resolved.target_symbol,
8063            PROVENANCE_TREESITTER,
8064        ],
8065    )?;
8066    if let Some(edge) = &resolved.edge {
8067        tx.execute(
8068            "INSERT OR REPLACE INTO edges(
8069                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8070                kind, line, provenance
8071            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
8072            params![
8073                edge.edge_id,
8074                raw.ref_id,
8075                edge.source_node,
8076                edge.target_node,
8077                edge.target_file,
8078                edge.target_symbol,
8079                edge.kind,
8080                edge.line as i64,
8081                PROVENANCE_TREESITTER,
8082            ],
8083        )?;
8084    }
8085    Ok(())
8086}
8087
8088fn insert_method_dispatch_edges(
8089    tx: &Transaction<'_>,
8090    project_root: &Path,
8091    caller_files: Option<&BTreeSet<String>>,
8092) -> Result<usize> {
8093    let references = load_name_match_refs(tx, caller_files)?;
8094    if references.is_empty() {
8095        return Ok(0);
8096    }
8097
8098    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
8099    let mut source_cache: DispatchSourceCache = HashMap::new();
8100    let mut inserted = 0usize;
8101    for reference in references {
8102        let key = (reference.method_name.clone(), reference.lang.clone());
8103        let candidates = match candidates_by_name.entry(key) {
8104            Entry::Occupied(entry) => entry.into_mut(),
8105            Entry::Vacant(entry) => {
8106                let candidates =
8107                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
8108                entry.insert(candidates)
8109            }
8110        };
8111
8112        if let Some(receiver_type) =
8113            infer_receiver_type(project_root, &reference, &mut source_cache)
8114        {
8115            let Some(candidate) =
8116                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
8117            else {
8118                continue;
8119            };
8120            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
8121            inserted += 1;
8122            continue;
8123        }
8124
8125        if method_name_match_denylisted(&reference.method_name) {
8126            continue;
8127        }
8128
8129        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
8130            continue;
8131        };
8132        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
8133        inserted += 1;
8134    }
8135    Ok(inserted)
8136}
8137
8138fn insert_method_dispatch_edges_chunked(
8139    tx: &Transaction<'_>,
8140    project_root: &Path,
8141    caller_files: &BTreeSet<String>,
8142    chunk_size: usize,
8143) -> Result<usize> {
8144    if caller_files.is_empty() {
8145        return Ok(0);
8146    }
8147    if chunk_size == 0 || caller_files.len() <= chunk_size {
8148        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
8149    }
8150
8151    let mut inserted = 0usize;
8152    let mut batch = BTreeSet::new();
8153    for caller_file in caller_files {
8154        batch.insert(caller_file.clone());
8155        if batch.len() == chunk_size {
8156            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8157            batch.clear();
8158        }
8159    }
8160    if !batch.is_empty() {
8161        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
8162    }
8163    Ok(inserted)
8164}
8165
8166fn insert_method_dispatch_edge(
8167    tx: &Transaction<'_>,
8168    reference: &NameMatchRef,
8169    candidate: &NameMatchCandidate,
8170    provenance: &str,
8171) -> Result<()> {
8172    tx.execute(
8173        "INSERT OR REPLACE INTO edges(
8174            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
8175            kind, line, provenance
8176        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
8177        params![
8178            ref_id(&[&reference.ref_id, provenance, "edge"]),
8179            &reference.ref_id,
8180            &reference.caller_node,
8181            &candidate.node_id,
8182            &candidate.file_path,
8183            &candidate.scoped_name,
8184            reference.line as i64,
8185            provenance,
8186        ],
8187    )?;
8188    Ok(())
8189}
8190
8191fn delete_method_dispatch_edges_for_callers(
8192    tx: &Transaction<'_>,
8193    caller_files: &BTreeSet<String>,
8194) -> Result<()> {
8195    if caller_files.is_empty() {
8196        return Ok(());
8197    }
8198
8199    let mut stmt = tx.prepare(
8200        "DELETE FROM edges
8201         WHERE provenance IN (?1, ?2)
8202           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
8203    )?;
8204    for caller_file in caller_files {
8205        stmt.execute(params![
8206            PROVENANCE_NAME_MATCH,
8207            PROVENANCE_TYPE_MATCH,
8208            caller_file
8209        ])?;
8210    }
8211    Ok(())
8212}
8213
8214fn load_name_match_refs(
8215    tx: &Transaction<'_>,
8216    caller_files: Option<&BTreeSet<String>>,
8217) -> Result<Vec<NameMatchRef>> {
8218    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
8219                           n.signature, r.short_name, r.full_ref, r.line, f.lang
8220                    FROM refs r
8221                    JOIN files f ON f.path = r.caller_file
8222                    JOIN nodes n ON n.id = r.caller_node
8223                    WHERE r.kind = 'call'
8224                      AND r.status = 'unresolved'
8225                      AND r.caller_node IS NOT NULL
8226                      AND r.full_ref IS NOT NULL
8227                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
8228                      AND NOT EXISTS (
8229                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
8230                      )";
8231    let mut references = Vec::new();
8232
8233    if let Some(caller_files) = caller_files {
8234        if caller_files.is_empty() {
8235            return Ok(references);
8236        }
8237        let sql = format!(
8238            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
8239        );
8240        let mut stmt = tx.prepare(&sql)?;
8241        for caller_file in caller_files {
8242            let rows = stmt.query_map(params![caller_file], |row| {
8243                Ok((
8244                    row.get::<_, String>(0)?,
8245                    row.get::<_, Option<String>>(1)?,
8246                    row.get::<_, String>(2)?,
8247                    row.get::<_, String>(3)?,
8248                    row.get::<_, Option<String>>(4)?,
8249                    row.get::<_, Option<String>>(5)?,
8250                    row.get::<_, Option<String>>(6)?,
8251                    row.get::<_, i64>(7)?,
8252                    row.get::<_, String>(8)?,
8253                ))
8254            })?;
8255            for row in rows {
8256                let (
8257                    ref_id,
8258                    caller_node,
8259                    caller_file,
8260                    caller_symbol,
8261                    caller_signature,
8262                    short_name,
8263                    full_ref,
8264                    line,
8265                    lang,
8266                ) = row?;
8267                if let Some(reference) = name_match_ref_from_parts(
8268                    ref_id,
8269                    caller_node,
8270                    caller_file,
8271                    caller_symbol,
8272                    caller_signature,
8273                    short_name,
8274                    full_ref,
8275                    line,
8276                    lang,
8277                ) {
8278                    references.push(reference);
8279                }
8280            }
8281        }
8282        return Ok(references);
8283    }
8284
8285    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
8286    let mut stmt = tx.prepare(&sql)?;
8287    let rows = stmt.query_map([], |row| {
8288        Ok((
8289            row.get::<_, String>(0)?,
8290            row.get::<_, Option<String>>(1)?,
8291            row.get::<_, String>(2)?,
8292            row.get::<_, String>(3)?,
8293            row.get::<_, Option<String>>(4)?,
8294            row.get::<_, Option<String>>(5)?,
8295            row.get::<_, Option<String>>(6)?,
8296            row.get::<_, i64>(7)?,
8297            row.get::<_, String>(8)?,
8298        ))
8299    })?;
8300    for row in rows {
8301        let (
8302            ref_id,
8303            caller_node,
8304            caller_file,
8305            caller_symbol,
8306            caller_signature,
8307            short_name,
8308            full_ref,
8309            line,
8310            lang,
8311        ) = row?;
8312        if let Some(reference) = name_match_ref_from_parts(
8313            ref_id,
8314            caller_node,
8315            caller_file,
8316            caller_symbol,
8317            caller_signature,
8318            short_name,
8319            full_ref,
8320            line,
8321            lang,
8322        ) {
8323            references.push(reference);
8324        }
8325    }
8326    Ok(references)
8327}
8328
8329#[allow(clippy::too_many_arguments)]
8330fn name_match_ref_from_parts(
8331    ref_id: String,
8332    caller_node: Option<String>,
8333    caller_file: String,
8334    caller_symbol: String,
8335    caller_signature: Option<String>,
8336    short_name: Option<String>,
8337    full_ref: Option<String>,
8338    line: i64,
8339    lang: String,
8340) -> Option<NameMatchRef> {
8341    let caller_node = caller_node?;
8342    let full_ref = full_ref?;
8343    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
8344    let method_name = if member.is_empty() {
8345        short_name.as_deref()?.to_string()
8346    } else {
8347        member
8348    };
8349    Some(NameMatchRef {
8350        ref_id,
8351        caller_node,
8352        caller_file,
8353        caller_symbol,
8354        caller_signature,
8355        receiver,
8356        method_name,
8357        colon_dispatch,
8358        line: line.max(0) as u32,
8359        lang,
8360    })
8361}
8362
8363fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
8364    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
8365    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
8366    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
8367    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
8368        .into_iter()
8369        .flatten()
8370        .max_by_key(|(index, _, _)| *index)?;
8371    if delimiter == 0 {
8372        return None;
8373    }
8374    let member_start = delimiter + delimiter_len;
8375    if member_start >= full_ref.len() {
8376        return None;
8377    }
8378    let receiver = last_name_segment(&full_ref[..delimiter]);
8379    let member = &full_ref[member_start..];
8380    if receiver.is_empty() || member.is_empty() {
8381        return None;
8382    }
8383    Some((receiver.to_string(), member.to_string(), colon_dispatch))
8384}
8385
8386fn last_name_segment(value: &str) -> &str {
8387    value
8388        .rsplit(['.', ':', '/', '\\', '-', '>'])
8389        .find(|segment| !segment.is_empty())
8390        .unwrap_or(value)
8391}
8392
8393fn load_name_match_candidates(
8394    tx: &Transaction<'_>,
8395    method_name: &str,
8396    lang: &str,
8397) -> Result<Vec<NameMatchCandidate>> {
8398    let mut stmt = tx.prepare(
8399        "SELECT n.id, n.file_path, n.scoped_name, n.kind
8400         FROM nodes n JOIN files f ON f.path = n.file_path
8401         WHERE n.name = ?1
8402           AND f.lang = ?2
8403           AND n.kind IN ('method', 'function')
8404         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
8405    )?;
8406    let rows = stmt.query_map(params![method_name, lang], |row| {
8407        Ok(NameMatchCandidate {
8408            node_id: row.get(0)?,
8409            file_path: row.get(1)?,
8410            scoped_name: row.get(2)?,
8411            kind: row.get(3)?,
8412        })
8413    })?;
8414    rows.collect::<std::result::Result<Vec<_>, _>>()
8415        .map_err(Into::into)
8416}
8417
8418struct ParsedDispatchSource {
8419    source: String,
8420    tree: tree_sitter::Tree,
8421}
8422
8423type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
8424
8425fn infer_receiver_type(
8426    project_root: &Path,
8427    reference: &NameMatchRef,
8428    source_cache: &mut DispatchSourceCache,
8429) -> Option<String> {
8430    match reference.lang.as_str() {
8431        "rust" => infer_rust_receiver_type(reference),
8432        "java" => {
8433            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
8434        }
8435        "kotlin" => {
8436            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
8437        }
8438        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
8439        _ => None,
8440    }
8441}
8442
8443fn parse_dispatch_source(
8444    project_root: &Path,
8445    caller_file: &str,
8446    lang: LangId,
8447) -> Option<ParsedDispatchSource> {
8448    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
8449    let grammar = crate::parser::grammar_for(lang);
8450    let mut parser = tree_sitter::Parser::new();
8451    parser.set_language(&grammar).ok()?;
8452    let tree = parser.parse(&source, None)?;
8453    Some(ParsedDispatchSource { source, tree })
8454}
8455
8456fn parsed_dispatch_source<'a>(
8457    project_root: &Path,
8458    reference: &NameMatchRef,
8459    lang: LangId,
8460    source_cache: &'a mut DispatchSourceCache,
8461) -> Option<&'a ParsedDispatchSource> {
8462    let key = (reference.caller_file.clone(), reference.lang.clone());
8463    source_cache
8464        .entry(key)
8465        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
8466        .as_ref()
8467}
8468
8469fn infer_java_like_receiver_type(
8470    project_root: &Path,
8471    reference: &NameMatchRef,
8472    lang: LangId,
8473    source_cache: &mut DispatchSourceCache,
8474) -> Option<String> {
8475    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8476        return None;
8477    }
8478
8479    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
8480    let root = parsed.tree.root_node();
8481    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
8482
8483    let callable_scope = type_node
8484        .and_then(|node| {
8485            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
8486        })
8487        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
8488
8489    if let Some(callable_scope) = callable_scope {
8490        if let Some(receiver_type) = infer_java_like_local_receiver_type(
8491            callable_scope,
8492            &parsed.source,
8493            &reference.receiver,
8494            reference.line.max(1),
8495            lang,
8496        ) {
8497            return Some(receiver_type);
8498        }
8499    }
8500
8501    type_node.and_then(|node| {
8502        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
8503    })
8504}
8505
8506fn infer_cpp_receiver_type(
8507    project_root: &Path,
8508    reference: &NameMatchRef,
8509    source_cache: &mut DispatchSourceCache,
8510) -> Option<String> {
8511    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
8512        return None;
8513    }
8514
8515    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
8516    let root = parsed.tree.root_node();
8517    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
8518    infer_cpp_receiver_type_from_scope(
8519        scope,
8520        &parsed.source,
8521        &reference.receiver,
8522        reference.line.max(1),
8523    )
8524}
8525
8526fn find_enclosing_java_like_type_node<'tree>(
8527    root: tree_sitter::Node<'tree>,
8528    source: &str,
8529    reference: &NameMatchRef,
8530    lang: LangId,
8531) -> Option<tree_sitter::Node<'tree>> {
8532    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
8533        .and_then(|name| simple_type_name(&name));
8534    let line = reference.line.max(1);
8535    let mut best = None;
8536    let mut stack = vec![root];
8537    while let Some(node) = stack.pop() {
8538        if !node_contains_line(node, line) {
8539            continue;
8540        }
8541        if is_java_like_type_kind(node.kind(), lang) {
8542            let name = declaration_name(node, source);
8543            if expected_type
8544                .as_deref()
8545                .is_none_or(|expected| name == Some(expected))
8546            {
8547                best = tighter_node(best, node);
8548            }
8549        }
8550        push_named_children(node, &mut stack);
8551    }
8552    best
8553}
8554
8555fn find_enclosing_java_like_callable_node<'tree>(
8556    root: tree_sitter::Node<'tree>,
8557    source: &str,
8558    reference: &NameMatchRef,
8559    lang: LangId,
8560) -> Option<tree_sitter::Node<'tree>> {
8561    let expected_name = reference.caller_symbol.rsplit("::").next();
8562    let line = reference.line.max(1);
8563    let mut best = None;
8564    let mut stack = vec![root];
8565    while let Some(node) = stack.pop() {
8566        if !node_contains_line(node, line) {
8567            continue;
8568        }
8569        if is_java_like_callable_kind(node.kind(), lang) {
8570            let name = declaration_name(node, source);
8571            if expected_name.is_none_or(|expected| name == Some(expected)) {
8572                best = tighter_node(best, node);
8573            }
8574        }
8575        push_named_children(node, &mut stack);
8576    }
8577    best
8578}
8579
8580fn find_enclosing_cpp_callable_node<'tree>(
8581    root: tree_sitter::Node<'tree>,
8582    _source: &str,
8583    reference: &NameMatchRef,
8584) -> Option<tree_sitter::Node<'tree>> {
8585    let line = reference.line.max(1);
8586    let mut best = None;
8587    let mut stack = vec![root];
8588    while let Some(node) = stack.pop() {
8589        if !node_contains_line(node, line) {
8590            continue;
8591        }
8592        if node.kind() == "function_definition" {
8593            best = tighter_node(best, node);
8594        }
8595        push_named_children(node, &mut stack);
8596    }
8597    best
8598}
8599
8600fn tighter_node<'tree>(
8601    current: Option<tree_sitter::Node<'tree>>,
8602    candidate: tree_sitter::Node<'tree>,
8603) -> Option<tree_sitter::Node<'tree>> {
8604    match current {
8605        Some(current)
8606            if current.start_byte() > candidate.start_byte()
8607                || (current.start_byte() == candidate.start_byte()
8608                    && current.end_byte() <= candidate.end_byte()) =>
8609        {
8610            Some(current)
8611        }
8612        _ => Some(candidate),
8613    }
8614}
8615
8616fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
8617    let start = node.start_position().row as u32 + 1;
8618    let end = node.end_position().row as u32 + 1;
8619    start <= line && line <= end
8620}
8621
8622fn push_named_children<'tree>(
8623    node: tree_sitter::Node<'tree>,
8624    stack: &mut Vec<tree_sitter::Node<'tree>>,
8625) {
8626    for index in 0..node.named_child_count() {
8627        if let Some(child) = node.named_child(index as u32) {
8628            stack.push(child);
8629        }
8630    }
8631}
8632
8633fn declaration_name<'source>(
8634    node: tree_sitter::Node<'_>,
8635    source: &'source str,
8636) -> Option<&'source str> {
8637    node.child_by_field_name("name")
8638        .map(|name| node_text(name, source))
8639        .or_else(|| {
8640            first_named_child_text(
8641                node,
8642                source,
8643                &["identifier", "type_identifier", "simple_identifier"],
8644            )
8645        })
8646}
8647
8648fn first_named_child_text<'source>(
8649    node: tree_sitter::Node<'_>,
8650    source: &'source str,
8651    kinds: &[&str],
8652) -> Option<&'source str> {
8653    for index in 0..node.named_child_count() {
8654        let child = node.named_child(index as u32)?;
8655        if kinds.contains(&child.kind()) {
8656            return Some(node_text(child, source));
8657        }
8658    }
8659    None
8660}
8661
8662fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
8663    &source[node.byte_range()]
8664}
8665
8666fn infer_java_like_field_receiver_type(
8667    type_node: tree_sitter::Node<'_>,
8668    source: &str,
8669    receiver: &str,
8670    lang: LangId,
8671) -> Option<String> {
8672    let mut stack = Vec::new();
8673    push_named_children(type_node, &mut stack);
8674    while let Some(node) = stack.pop() {
8675        if is_java_like_field_kind(node.kind(), lang) {
8676            if let Some(receiver_type) =
8677                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8678            {
8679                return Some(receiver_type);
8680            }
8681        }
8682        if is_java_like_type_kind(node.kind(), lang)
8683            || is_java_like_callable_kind(node.kind(), lang)
8684        {
8685            continue;
8686        }
8687        push_named_children(node, &mut stack);
8688    }
8689    None
8690}
8691
8692fn infer_java_like_local_receiver_type(
8693    callable_node: tree_sitter::Node<'_>,
8694    source: &str,
8695    receiver: &str,
8696    call_line: u32,
8697    lang: LangId,
8698) -> Option<String> {
8699    let mut best: Option<(u32, String)> = None;
8700    let mut stack = Vec::new();
8701    push_named_children(callable_node, &mut stack);
8702    while let Some(node) = stack.pop() {
8703        let start_line = node.start_position().row as u32 + 1;
8704        if start_line > call_line {
8705            continue;
8706        }
8707        if is_java_like_local_kind(node.kind(), lang) {
8708            if let Some(receiver_type) =
8709                extract_java_like_declared_type(node_text(node, source), receiver, lang)
8710            {
8711                if best
8712                    .as_ref()
8713                    .is_none_or(|(best_line, _)| start_line >= *best_line)
8714                {
8715                    best = Some((start_line, receiver_type));
8716                }
8717            }
8718        }
8719        if is_java_like_type_kind(node.kind(), lang)
8720            || is_java_like_callable_kind(node.kind(), lang)
8721        {
8722            continue;
8723        }
8724        push_named_children(node, &mut stack);
8725    }
8726    best.map(|(_, receiver_type)| receiver_type)
8727}
8728
8729fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
8730    match lang {
8731        LangId::Java => matches!(
8732            kind,
8733            "class_declaration"
8734                | "interface_declaration"
8735                | "enum_declaration"
8736                | "record_declaration"
8737                | "annotation_type_declaration"
8738        ),
8739        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
8740        _ => false,
8741    }
8742}
8743
8744fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
8745    match lang {
8746        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
8747        LangId::Kotlin => kind == "function_declaration",
8748        _ => false,
8749    }
8750}
8751
8752fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
8753    match lang {
8754        LangId::Java => kind == "field_declaration",
8755        LangId::Kotlin => kind == "property_declaration",
8756        _ => false,
8757    }
8758}
8759
8760fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
8761    match lang {
8762        LangId::Java => kind == "local_variable_declaration",
8763        LangId::Kotlin => kind == "property_declaration",
8764        _ => false,
8765    }
8766}
8767
8768fn extract_java_like_declared_type(
8769    declaration: &str,
8770    receiver: &str,
8771    lang: LangId,
8772) -> Option<String> {
8773    match lang {
8774        LangId::Java => extract_java_declared_type(declaration, receiver),
8775        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
8776        _ => None,
8777    }
8778}
8779
8780fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8781    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8782    let after = declaration[receiver_start + receiver.len()..].trim_start();
8783    if after
8784        .chars()
8785        .next()
8786        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
8787    {
8788        return None;
8789    }
8790
8791    let before = declaration[..receiver_start].trim_end();
8792    if before.contains(',') {
8793        return None;
8794    }
8795    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
8796}
8797
8798fn strip_java_declaration_prefixes(mut value: &str) -> &str {
8799    loop {
8800        value = value.trim_start();
8801        if let Some(stripped) = strip_leading_java_annotation(value) {
8802            value = stripped;
8803            continue;
8804        }
8805        if let Some(stripped) = strip_leading_java_modifier(value) {
8806            value = stripped;
8807            continue;
8808        }
8809        return value.trim();
8810    }
8811}
8812
8813fn strip_leading_java_annotation(value: &str) -> Option<&str> {
8814    let value = value.trim_start();
8815    let mut chars = value.char_indices();
8816    let (_, first) = chars.next()?;
8817    if first != '@' {
8818        return None;
8819    }
8820    let mut end = first.len_utf8();
8821    for (index, ch) in chars {
8822        if !(is_code_ident_char(ch) || ch == '.') {
8823            end = index;
8824            break;
8825        }
8826        end = index + ch.len_utf8();
8827    }
8828    let rest = value[end..].trim_start();
8829    if let Some(stripped) = rest.strip_prefix('(') {
8830        let mut depth = 1usize;
8831        for (index, ch) in stripped.char_indices() {
8832            match ch {
8833                '(' => depth += 1,
8834                ')' => {
8835                    depth = depth.saturating_sub(1);
8836                    if depth == 0 {
8837                        return Some(stripped[index + ch.len_utf8()..].trim_start());
8838                    }
8839                }
8840                _ => {}
8841            }
8842        }
8843        return Some("");
8844    }
8845    Some(rest)
8846}
8847
8848fn strip_leading_java_modifier(value: &str) -> Option<&str> {
8849    const MODIFIERS: &[&str] = &[
8850        "public",
8851        "protected",
8852        "private",
8853        "abstract",
8854        "static",
8855        "final",
8856        "transient",
8857        "volatile",
8858        "synchronized",
8859        "native",
8860        "strictfp",
8861    ];
8862    MODIFIERS
8863        .iter()
8864        .find_map(|modifier| strip_leading_word(value, modifier))
8865}
8866
8867fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
8868    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
8869    let before = &declaration[..receiver_start];
8870    if find_identifier_occurrence(before, "val").is_none()
8871        && find_identifier_occurrence(before, "var").is_none()
8872    {
8873        return None;
8874    }
8875
8876    let after = declaration[receiver_start + receiver.len()..].trim_start();
8877    if let Some(type_text) = after.strip_prefix(':') {
8878        return normalize_receiver_type_name(read_type_prefix(type_text));
8879    }
8880    after
8881        .strip_prefix('=')
8882        .and_then(infer_kotlin_constructor_type)
8883}
8884
8885fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
8886    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
8887    if rest.trim_start().starts_with('(') {
8888        normalize_receiver_type_name(head)
8889    } else {
8890        None
8891    }
8892}
8893
8894fn read_type_prefix(value: &str) -> &str {
8895    let mut angle_depth = 0usize;
8896    for (index, ch) in value.char_indices() {
8897        match ch {
8898            '<' => angle_depth += 1,
8899            '>' => angle_depth = angle_depth.saturating_sub(1),
8900            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
8901                return value[..index].trim();
8902            }
8903            _ => {}
8904        }
8905    }
8906    value.trim()
8907}
8908
8909fn infer_cpp_receiver_type_from_scope(
8910    scope: tree_sitter::Node<'_>,
8911    source: &str,
8912    receiver: &str,
8913    call_line: u32,
8914) -> Option<String> {
8915    let lines = source.lines().collect::<Vec<_>>();
8916    if lines.is_empty() {
8917        return None;
8918    }
8919    let scope_start = scope.start_position().row as usize;
8920    let call_index = (call_line as usize)
8921        .saturating_sub(1)
8922        .min(lines.len().saturating_sub(1));
8923    for index in (scope_start..=call_index).rev() {
8924        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
8925            return Some(receiver_type);
8926        }
8927    }
8928    None
8929}
8930
8931fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
8932    for receiver_start in identifier_occurrences(line, receiver) {
8933        let after = line[receiver_start + receiver.len()..].trim_start();
8934        if after
8935            .chars()
8936            .next()
8937            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
8938        {
8939            continue;
8940        }
8941        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
8942        let normalized = normalize_cpp_type_name(type_text)?;
8943        if normalized == "auto" {
8944            if let Some(rhs) = after.strip_prefix('=') {
8945                return infer_cpp_auto_receiver_type(rhs);
8946            }
8947            continue;
8948        }
8949        return Some(normalized);
8950    }
8951    None
8952}
8953
8954fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
8955    let candidate = prefix
8956        .rsplit([';', '{', '}', '('])
8957        .next()
8958        .unwrap_or(prefix)
8959        .trim();
8960    if candidate.is_empty() || candidate.ends_with(',') {
8961        None
8962    } else {
8963        Some(candidate)
8964    }
8965}
8966
8967fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
8968    let without_templates = strip_angle_groups(type_text);
8969    let mut cleaned = String::with_capacity(without_templates.len());
8970    for token in without_templates.split_whitespace() {
8971        if matches!(
8972            token,
8973            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
8974        ) {
8975            continue;
8976        }
8977        if !cleaned.is_empty() {
8978            cleaned.push(' ');
8979        }
8980        cleaned.push_str(token);
8981    }
8982    let token = cleaned
8983        .split_whitespace()
8984        .last()
8985        .unwrap_or(cleaned.trim())
8986        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
8987        .trim_matches(['*', '&']);
8988    let simple = token.rsplit("::").next().unwrap_or(token).trim();
8989    if simple.is_empty() || cpp_non_type_token(simple) {
8990        None
8991    } else {
8992        Some(simple.to_string())
8993    }
8994}
8995
8996fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
8997    let rhs = rhs.trim_start();
8998    if let Some(after_new) = rhs.strip_prefix("new ") {
8999        return infer_cpp_constructor_type(after_new);
9000    }
9001    infer_cpp_make_template_type(rhs)
9002        .or_else(|| infer_cpp_constructor_type(rhs))
9003        .or_else(|| infer_cpp_factory_type(rhs))
9004}
9005
9006fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
9007    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9008    let normalized = normalize_cpp_type_name(head)?;
9009    if !normalized
9010        .chars()
9011        .next()
9012        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9013    {
9014        return None;
9015    }
9016    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
9017        Some(normalized)
9018    } else {
9019        None
9020    }
9021}
9022
9023fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
9024    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9025    if !rest.trim_start().starts_with('(') {
9026        return None;
9027    }
9028    let base = head.split('<').next().unwrap_or(head);
9029    let base_simple = base.rsplit("::").next().unwrap_or(base);
9030    if !matches!(base_simple, "make_unique" | "make_shared") {
9031        return None;
9032    }
9033    first_angle_arg(head).and_then(normalize_cpp_type_name)
9034}
9035
9036fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
9037    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
9038    if !rest.trim_start().starts_with('(') {
9039        return None;
9040    }
9041    let simple = head
9042        .split('<')
9043        .next()
9044        .unwrap_or(head)
9045        .rsplit("::")
9046        .next()
9047        .unwrap_or(head);
9048    for prefix in ["make", "create", "build"] {
9049        if let Some(suffix) = simple.strip_prefix(prefix) {
9050            if suffix
9051                .chars()
9052                .next()
9053                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9054            {
9055                return normalize_cpp_type_name(suffix);
9056            }
9057        }
9058    }
9059    None
9060}
9061
9062#[derive(Debug, Clone, Copy)]
9063enum JavaLikeInvocation {
9064    Kotlin,
9065    Cpp,
9066}
9067
9068fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
9069    let value = value.trim_start();
9070    let mut end = 0usize;
9071    for (index, ch) in value.char_indices() {
9072        let allowed_separator = match flavor {
9073            JavaLikeInvocation::Kotlin => ch == '.',
9074            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
9075        };
9076        if is_code_ident_char(ch) || allowed_separator {
9077            end = index + ch.len_utf8();
9078            continue;
9079        }
9080        break;
9081    }
9082    if end == 0 {
9083        return None;
9084    }
9085    let mut rest = &value[end..];
9086    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
9087        let skipped = skip_balanced_angle(stripped)?;
9088        let rest_start = rest.len() - rest.trim_start().len();
9089        let angle_len = 1 + skipped;
9090        end += rest_start + angle_len;
9091        rest = &value[end..];
9092    }
9093    Some((value[..end].trim(), rest))
9094}
9095
9096fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
9097    let mut depth = 1usize;
9098    for (index, ch) in value_after_open.char_indices() {
9099        match ch {
9100            '<' => depth += 1,
9101            '>' => {
9102                depth = depth.saturating_sub(1);
9103                if depth == 0 {
9104                    return Some(index + ch.len_utf8());
9105                }
9106            }
9107            _ => {}
9108        }
9109    }
9110    None
9111}
9112
9113fn first_angle_arg(value: &str) -> Option<&str> {
9114    let open = value.find('<')?;
9115    let inner_len = skip_balanced_angle(&value[open + 1..])?;
9116    let inner = &value[open + 1..open + inner_len];
9117    split_top_level_commas(inner).into_iter().next()
9118}
9119
9120fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
9121    let without_generics = strip_angle_groups(type_text);
9122    let cleaned = without_generics
9123        .replace("[]", " ")
9124        .replace("...", " ")
9125        .replace(['?', '&', '*'], " ");
9126    let token = cleaned
9127        .split_whitespace()
9128        .last()
9129        .unwrap_or(cleaned.trim())
9130        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
9131    let token = token.rsplit("::").next().unwrap_or(token);
9132    let simple = token.rsplit('.').next().unwrap_or(token).trim();
9133    if simple.is_empty()
9134        || java_like_primitive_type(simple)
9135        || !simple
9136            .chars()
9137            .next()
9138            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
9139    {
9140        None
9141    } else {
9142        Some(simple.to_string())
9143    }
9144}
9145
9146fn simple_type_name(scoped_name: &str) -> Option<String> {
9147    scoped_name
9148        .rsplit("::")
9149        .find(|segment| !segment.is_empty())
9150        .and_then(normalize_receiver_type_name)
9151}
9152
9153fn strip_angle_groups(value: &str) -> String {
9154    let mut output = String::with_capacity(value.len());
9155    let mut depth = 0usize;
9156    for ch in value.chars() {
9157        match ch {
9158            '<' => {
9159                if depth == 0 {
9160                    output.push(' ');
9161                }
9162                depth += 1;
9163            }
9164            '>' => depth = depth.saturating_sub(1),
9165            _ if depth == 0 => output.push(ch),
9166            _ => {}
9167        }
9168    }
9169    output
9170}
9171
9172fn java_like_primitive_type(value: &str) -> bool {
9173    matches!(
9174        value,
9175        "boolean"
9176            | "byte"
9177            | "char"
9178            | "double"
9179            | "float"
9180            | "int"
9181            | "long"
9182            | "short"
9183            | "void"
9184            | "Boolean"
9185            | "Byte"
9186            | "Char"
9187            | "Double"
9188            | "Float"
9189            | "Int"
9190            | "Long"
9191            | "Short"
9192            | "Unit"
9193    )
9194}
9195
9196fn cpp_non_type_token(value: &str) -> bool {
9197    matches!(
9198        value,
9199        "return"
9200            | "if"
9201            | "else"
9202            | "for"
9203            | "while"
9204            | "do"
9205            | "switch"
9206            | "case"
9207            | "default"
9208            | "break"
9209            | "continue"
9210            | "goto"
9211            | "throw"
9212            | "new"
9213            | "delete"
9214            | "co_await"
9215            | "co_yield"
9216            | "co_return"
9217            | "static_cast"
9218            | "const_cast"
9219            | "dynamic_cast"
9220            | "reinterpret_cast"
9221            | "sizeof"
9222            | "alignof"
9223            | "typeid"
9224            | "and"
9225            | "or"
9226            | "not"
9227            | "xor"
9228    )
9229}
9230
9231fn receiver_is_bare_identifier(value: &str) -> bool {
9232    let mut chars = value.chars();
9233    let Some(first) = chars.next() else {
9234        return false;
9235    };
9236    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
9237}
9238
9239fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
9240    identifier_occurrences(value, needle).into_iter().next()
9241}
9242
9243fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
9244    value
9245        .match_indices(needle)
9246        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
9247        .collect()
9248}
9249
9250fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
9251    let before = value[..start].chars().next_back();
9252    let after = value[start + len..].chars().next();
9253    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
9254}
9255
9256fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
9257    let stripped = value.strip_prefix(word)?;
9258    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
9259        Some(stripped.trim_start())
9260    } else {
9261        None
9262    }
9263}
9264
9265fn is_code_ident_char(ch: char) -> bool {
9266    ch == '_' || ch.is_ascii_alphanumeric()
9267}
9268
9269fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
9270    if matches!(reference.receiver.as_str(), "self" | "Self") {
9271        return enclosing_type_from_scoped_name(&reference.caller_symbol);
9272    }
9273
9274    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
9275        return Some(reference.receiver.clone());
9276    }
9277
9278    reference
9279        .caller_signature
9280        .as_deref()
9281        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
9282}
9283
9284fn rust_receiver_looks_type_like(receiver: &str) -> bool {
9285    receiver
9286        .chars()
9287        .next()
9288        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
9289}
9290
9291fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
9292    scoped_name
9293        .rsplit_once("::")
9294        .map(|(enclosing, _)| enclosing)
9295        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
9296        .map(ToString::to_string)
9297}
9298
9299fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
9300    let params = signature_parameter_text(signature)?;
9301    for param in split_top_level_commas(params) {
9302        let Some((pattern, type_text)) = param.split_once(':') else {
9303            continue;
9304        };
9305        let Some(name) = rust_parameter_name(pattern) else {
9306            continue;
9307        };
9308        if name == receiver {
9309            return normalize_rust_receiver_type(type_text);
9310        }
9311    }
9312    None
9313}
9314
9315fn signature_parameter_text(signature: &str) -> Option<&str> {
9316    let open = signature.find('(')?;
9317    let mut depth = 0usize;
9318    for (offset, ch) in signature[open..].char_indices() {
9319        match ch {
9320            '(' => depth += 1,
9321            ')' => {
9322                depth = depth.saturating_sub(1);
9323                if depth == 0 {
9324                    return Some(&signature[open + 1..open + offset]);
9325                }
9326            }
9327            _ => {}
9328        }
9329    }
9330    None
9331}
9332
9333fn split_top_level_commas(value: &str) -> Vec<&str> {
9334    let mut parts = Vec::new();
9335    let mut start = 0usize;
9336    let mut angle_depth = 0usize;
9337    let mut paren_depth = 0usize;
9338    let mut bracket_depth = 0usize;
9339    for (index, ch) in value.char_indices() {
9340        match ch {
9341            '<' => angle_depth += 1,
9342            '>' => angle_depth = angle_depth.saturating_sub(1),
9343            '(' => paren_depth += 1,
9344            ')' => paren_depth = paren_depth.saturating_sub(1),
9345            '[' => bracket_depth += 1,
9346            ']' => bracket_depth = bracket_depth.saturating_sub(1),
9347            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
9348                let part = value[start..index].trim();
9349                if !part.is_empty() {
9350                    parts.push(part);
9351                }
9352                start = index + ch.len_utf8();
9353            }
9354            _ => {}
9355        }
9356    }
9357    let part = value[start..].trim();
9358    if !part.is_empty() {
9359        parts.push(part);
9360    }
9361    parts
9362}
9363
9364fn rust_parameter_name(pattern: &str) -> Option<&str> {
9365    let mut pattern = pattern.trim();
9366    if let Some(stripped) = pattern.strip_prefix("mut ") {
9367        pattern = stripped.trim_start();
9368    }
9369    pattern
9370        .rsplit(|ch: char| !is_rust_ident_char(ch))
9371        .find(|part| !part.is_empty())
9372}
9373
9374fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
9375    let mut ty = strip_leading_rust_type_modifiers(type_text);
9376    let owned_inner;
9377    if let Some(inner) = single_outer_generic_arg(ty) {
9378        owned_inner = inner.trim().to_string();
9379        ty = strip_leading_rust_type_modifiers(&owned_inner);
9380    }
9381    rust_base_type_ident(ty)
9382}
9383
9384fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
9385    loop {
9386        ty = ty.trim_start();
9387        if let Some(stripped) = ty.strip_prefix('&') {
9388            ty = stripped.trim_start();
9389            if let Some(stripped) = strip_leading_lifetime(ty) {
9390                ty = stripped.trim_start();
9391            }
9392            if let Some(stripped) = ty.strip_prefix("mut ") {
9393                ty = stripped.trim_start();
9394            }
9395            continue;
9396        }
9397        if let Some(stripped) = ty.strip_prefix("mut ") {
9398            ty = stripped.trim_start();
9399            continue;
9400        }
9401        if let Some(stripped) = ty.strip_prefix("dyn ") {
9402            ty = stripped.trim_start();
9403            continue;
9404        }
9405        if let Some(stripped) = ty.strip_prefix("impl ") {
9406            ty = stripped.trim_start();
9407            continue;
9408        }
9409        break ty.trim();
9410    }
9411}
9412
9413fn strip_leading_lifetime(value: &str) -> Option<&str> {
9414    let mut chars = value.char_indices();
9415    let (_, first) = chars.next()?;
9416    if first != '\'' {
9417        return None;
9418    }
9419    for (index, ch) in chars {
9420        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
9421            return Some(&value[index..]);
9422        }
9423    }
9424    Some("")
9425}
9426
9427fn single_outer_generic_arg(ty: &str) -> Option<&str> {
9428    let ty = ty.trim();
9429    let open = ty.find('<')?;
9430    let mut depth = 0usize;
9431    let mut close = None;
9432    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
9433        match ch {
9434            '<' => depth += 1,
9435            '>' => {
9436                depth = depth.saturating_sub(1);
9437                if depth == 0 {
9438                    close = Some(index);
9439                    break;
9440                }
9441            }
9442            _ => {}
9443        }
9444    }
9445    let close = close?;
9446    if !ty[close + 1..].trim().is_empty() {
9447        return None;
9448    }
9449    let inner = &ty[open + 1..close];
9450    let args = split_top_level_commas(inner);
9451    match args.as_slice() {
9452        [arg] => Some(*arg),
9453        _ => None,
9454    }
9455}
9456
9457fn rust_base_type_ident(ty: &str) -> Option<String> {
9458    let ty = ty.trim();
9459    let head = ty
9460        .split([' ', '+', '='])
9461        .find(|part| !part.is_empty())
9462        .unwrap_or(ty);
9463    let head = head.split('<').next().unwrap_or(head).trim();
9464    let ident = head
9465        .rsplit("::")
9466        .next()
9467        .unwrap_or(head)
9468        .trim_matches(|ch: char| !is_rust_ident_char(ch));
9469    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
9470        None
9471    } else {
9472        Some(ident.to_string())
9473    }
9474}
9475
9476fn is_rust_ident_char(ch: char) -> bool {
9477    ch == '_' || ch.is_ascii_alphanumeric()
9478}
9479
9480fn select_type_match_candidate(
9481    reference: &NameMatchRef,
9482    candidates: &[NameMatchCandidate],
9483    receiver_type: &str,
9484) -> Option<NameMatchCandidate> {
9485    let candidates = candidates
9486        .iter()
9487        .filter(|candidate| candidate.node_id != reference.caller_node)
9488        .filter(|candidate| {
9489            type_candidate_matches(candidate, receiver_type, &reference.method_name)
9490        })
9491        .collect::<Vec<_>>();
9492    match candidates.as_slice() {
9493        [candidate] => Some((**candidate).clone()),
9494        _ => None,
9495    }
9496}
9497
9498fn type_candidate_matches(
9499    candidate: &NameMatchCandidate,
9500    receiver_type: &str,
9501    method_name: &str,
9502) -> bool {
9503    let normalized_type = receiver_type.replace('.', "::");
9504    let suffix = format!("{normalized_type}::{method_name}");
9505    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
9506}
9507
9508fn select_name_match_candidate(
9509    reference: &NameMatchRef,
9510    candidates: &[NameMatchCandidate],
9511) -> Option<NameMatchCandidate> {
9512    let candidates = candidates
9513        .iter()
9514        .filter(|candidate| candidate.node_id != reference.caller_node)
9515        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
9516        .collect::<Vec<_>>();
9517    match candidates.as_slice() {
9518        [] => None,
9519        [candidate] => Some((**candidate).clone()),
9520        _ => select_scored_name_match_candidate(reference, &candidates),
9521    }
9522}
9523
9524fn candidate_allowed_for_reference(
9525    reference: &NameMatchRef,
9526    candidate: &NameMatchCandidate,
9527) -> bool {
9528    if !reference.colon_dispatch {
9529        return true;
9530    }
9531
9532    candidate.kind == "method"
9533        && candidate
9534            .scoped_name
9535            .split("::")
9536            .any(|segment| segment == reference.receiver)
9537}
9538
9539fn select_scored_name_match_candidate(
9540    reference: &NameMatchRef,
9541    candidates: &[&NameMatchCandidate],
9542) -> Option<NameMatchCandidate> {
9543    let receiver_words = split_camel_case(&reference.receiver);
9544    if receiver_words.is_empty() {
9545        return None;
9546    }
9547
9548    let mut best: Option<(&NameMatchCandidate, f64)> = None;
9549    let mut tied_best = false;
9550    for candidate in candidates {
9551        let candidate_words = split_camel_case(&candidate.scoped_name);
9552        let overlap = receiver_words
9553            .iter()
9554            .filter(|receiver_word| {
9555                candidate_words
9556                    .iter()
9557                    .any(|candidate_word| candidate_word == *receiver_word)
9558            })
9559            .count() as f64;
9560        let score =
9561            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
9562        match best {
9563            None => {
9564                best = Some((*candidate, score));
9565                tied_best = false;
9566            }
9567            Some((_, best_score)) if score > best_score => {
9568                best = Some((*candidate, score));
9569                tied_best = false;
9570            }
9571            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
9572                tied_best = true;
9573            }
9574            _ => {}
9575        }
9576    }
9577
9578    let (candidate, score) = best?;
9579    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
9580        Some(candidate.clone())
9581    } else {
9582        None
9583    }
9584}
9585
9586fn method_name_match_denylisted(method_name: &str) -> bool {
9587    matches!(
9588        method_name,
9589        "and_then"
9590            | "as_bytes"
9591            | "as_deref"
9592            | "as_mut"
9593            | "as_ref"
9594            | "as_str"
9595            | "borrow"
9596            | "borrow_mut"
9597            | "clear"
9598            | "clone"
9599            | "collect"
9600            | "contains"
9601            | "contains_key"
9602            | "count"
9603            | "dedup"
9604            | "default"
9605            | "drain"
9606            | "ends_with"
9607            | "entry"
9608            | "err"
9609            | "expect"
9610            | "extend"
9611            | "filter"
9612            | "filter_map"
9613            | "find"
9614            | "from"
9615            | "get"
9616            | "get_mut"
9617            | "insert"
9618            | "into"
9619            | "into_iter"
9620            | "is_empty"
9621            | "is_err"
9622            | "is_none"
9623            | "is_ok"
9624            | "is_some"
9625            | "iter"
9626            | "iter_mut"
9627            | "join"
9628            | "len"
9629            | "lock"
9630            | "map"
9631            | "map_err"
9632            | "max"
9633            | "min"
9634            | "new"
9635            | "next"
9636            | "ok"
9637            | "or_default"
9638            | "or_else"
9639            | "or_insert"
9640            | "or_insert_with"
9641            | "parse"
9642            | "pop"
9643            | "position"
9644            | "push"
9645            | "read"
9646            | "recv"
9647            | "remove"
9648            | "replace"
9649            | "retain"
9650            | "send"
9651            | "sort"
9652            | "sort_by"
9653            | "split"
9654            | "starts_with"
9655            | "sum"
9656            | "take"
9657            | "to_owned"
9658            | "to_string"
9659            | "trim"
9660            | "try_from"
9661            | "try_into"
9662            | "unwrap"
9663            | "unwrap_or"
9664            | "unwrap_or_default"
9665            | "unwrap_or_else"
9666            | "with_capacity"
9667            | "write"
9668    )
9669}
9670
9671fn split_camel_case(value: &str) -> Vec<String> {
9672    let chars = value.chars().collect::<Vec<_>>();
9673    let mut normalized = String::with_capacity(value.len() + 8);
9674    for (index, ch) in chars.iter().enumerate() {
9675        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
9676        let next = chars.get(index + 1);
9677        let is_separator = ch.is_whitespace()
9678            || matches!(
9679                ch,
9680                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
9681            );
9682        if is_separator {
9683            normalized.push(' ');
9684            continue;
9685        }
9686        let camel_boundary = previous.is_some_and(|prev| {
9687            (prev.is_lowercase() && ch.is_uppercase())
9688                || (prev.is_ascii_digit() && ch.is_alphabetic())
9689                || (prev.is_uppercase()
9690                    && ch.is_uppercase()
9691                    && next.is_some_and(|next| next.is_lowercase()))
9692        });
9693        if camel_boundary {
9694            normalized.push(' ');
9695        }
9696        normalized.push(*ch);
9697    }
9698
9699    normalized
9700        .split_whitespace()
9701        .filter(|word| word.len() > 1)
9702        .map(|word| word.to_ascii_lowercase())
9703        .collect()
9704}
9705
9706fn compute_path_proximity(left: &str, right: &str) -> f64 {
9707    let left_dirs = left
9708        .rsplit_once('/')
9709        .map(|(dir, _)| dir)
9710        .unwrap_or_default()
9711        .split('/')
9712        .filter(|part| !part.is_empty());
9713    let right_dirs = right
9714        .rsplit_once('/')
9715        .map(|(dir, _)| dir)
9716        .unwrap_or_default()
9717        .split('/')
9718        .filter(|part| !part.is_empty());
9719
9720    let shared = left_dirs
9721        .zip(right_dirs)
9722        .take_while(|(left, right)| left == right)
9723        .count();
9724    ((shared as f64) * 0.05).min(0.5)
9725}
9726
9727fn mark_backend_state(
9728    tx: &Transaction<'_>,
9729    project_root: &Path,
9730    rel_path: &str,
9731    content_hash: Option<&blake3::Hash>,
9732    status: &str,
9733) -> Result<()> {
9734    clear_backend_state_for_file(tx, project_root, rel_path)?;
9735    let hash = content_hash
9736        .map(|hash| hash_to_hex(*hash))
9737        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
9738    tx.execute(
9739        "INSERT OR REPLACE INTO backend_file_state(
9740            backend, workspace_root, file_path, content_hash, status, updated_at
9741        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
9742        params![
9743            BACKEND_TREESITTER,
9744            project_root.display().to_string(),
9745            rel_path,
9746            hash,
9747            status,
9748            unix_seconds_now(),
9749        ],
9750    )?;
9751    Ok(())
9752}
9753
9754fn clear_backend_state_for_file(
9755    tx: &Transaction<'_>,
9756    project_root: &Path,
9757    rel_path: &str,
9758) -> Result<()> {
9759    tx.execute(
9760        "DELETE FROM backend_file_state
9761         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
9762        params![
9763            BACKEND_TREESITTER,
9764            project_root.display().to_string(),
9765            rel_path
9766        ],
9767    )?;
9768    Ok(())
9769}
9770
9771fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
9772    tx.query_row(
9773        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
9774        params![rel_path],
9775        |row| {
9776            let hash_text: String = row.get(1)?;
9777            Ok(FileRow {
9778                surface_fingerprint: row.get(0)?,
9779                freshness: FileFreshness {
9780                    content_hash: hash_from_hex(&hash_text)
9781                        .unwrap_or_else(cache_freshness::zero_hash),
9782                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
9783                    size: row.get::<_, i64>(3)? as u64,
9784                },
9785            })
9786        },
9787    )
9788    .optional()
9789    .map_err(CallGraphStoreError::from)
9790}
9791
9792fn stored_node_ids_match_extract(
9793    tx: &Transaction<'_>,
9794    rel_path: &str,
9795    extract: &FileExtract,
9796) -> Result<bool> {
9797    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
9798    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9799    let mut stored = BTreeSet::new();
9800    for row in rows {
9801        stored.insert(row?);
9802    }
9803    let extracted = extract
9804        .nodes
9805        .iter()
9806        .map(|node| node.id.clone())
9807        .collect::<BTreeSet<_>>();
9808    Ok(stored == extracted)
9809}
9810
9811fn update_file_fresh_metadata(
9812    tx: &Transaction<'_>,
9813    rel_path: &str,
9814    hash: &blake3::Hash,
9815    mtime: SystemTime,
9816    size: u64,
9817) -> Result<()> {
9818    tx.execute(
9819        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
9820        params![
9821            rel_path,
9822            system_time_to_ns(mtime),
9823            size as i64,
9824            unix_seconds_now()
9825        ],
9826    )?;
9827    tx.execute(
9828        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
9829         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
9830        params![
9831            BACKEND_TREESITTER,
9832            rel_path,
9833            hash_to_hex(*hash),
9834            unix_seconds_now(),
9835        ],
9836    )?;
9837    Ok(())
9838}
9839
9840#[derive(Debug, Clone, PartialEq, Eq)]
9841struct DependentRefSelection {
9842    ref_id: String,
9843    caller_file: String,
9844}
9845
9846fn ref_ids_depending_on(
9847    tx: &Transaction<'_>,
9848    project_root: &Path,
9849    rel_path: &str,
9850) -> Result<Vec<DependentRefSelection>> {
9851    let mut stmt = tx.prepare(
9852        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
9853         FROM refs r
9854         WHERE r.caller_file IN (
9855             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
9856         )
9857            OR r.target_file = ?1
9858         ORDER BY r.ref_id",
9859    )?;
9860    let rows = stmt.query_map(params![rel_path], |row| {
9861        Ok(RefDependencyRow {
9862            ref_id: row.get(0)?,
9863            kind: row.get(1)?,
9864            caller_file: row.get(2)?,
9865            module_path: row.get(3)?,
9866            target_file: row.get(4)?,
9867        })
9868    })?;
9869    let mut ids = Vec::new();
9870    for row in rows {
9871        let row = row?;
9872        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
9873            ids.push(DependentRefSelection {
9874                ref_id: row.ref_id,
9875                caller_file: row.caller_file,
9876            });
9877        }
9878    }
9879    Ok(ids)
9880}
9881
9882fn record_dependent_refs(
9883    selected_ref_ids: &mut BTreeSet<String>,
9884    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
9885    dependent_refs: Vec<DependentRefSelection>,
9886) {
9887    for dependent_ref in dependent_refs {
9888        let DependentRefSelection {
9889            ref_id,
9890            caller_file,
9891        } = dependent_ref;
9892        selected_ref_ids.insert(ref_id.clone());
9893        selected_refs_by_caller
9894            .entry(caller_file)
9895            .or_default()
9896            .insert(ref_id);
9897    }
9898}
9899
9900#[cfg(test)]
9901fn refs_by_caller_for_ref_ids(
9902    tx: &Transaction<'_>,
9903    ref_ids: &BTreeSet<String>,
9904) -> Result<BTreeMap<String, BTreeSet<String>>> {
9905    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9906    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
9907    for ref_id in ref_ids {
9908        if let Some(caller) = stmt
9909            .query_row(params![ref_id], |row| row.get::<_, String>(0))
9910            .optional()?
9911        {
9912            by_caller.entry(caller).or_default().insert(ref_id.clone());
9913        }
9914    }
9915    Ok(by_caller)
9916}
9917
9918fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9919    tx.execute(
9920        "DELETE FROM file_dependencies WHERE file_path = ?1",
9921        params![rel_path],
9922    )?;
9923    delete_refs_for_caller(tx, rel_path)?;
9924    tx.execute(
9925        "DELETE FROM dispatch_hints WHERE file = ?1",
9926        params![rel_path],
9927    )?;
9928    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
9929    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
9930    Ok(())
9931}
9932
9933fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
9934    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
9935    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
9936    let mut ids = BTreeSet::new();
9937    for row in rows {
9938        ids.insert(row?);
9939    }
9940    delete_ref_ids(tx, &ids)
9941}
9942
9943fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
9944    for ref_id in ref_ids {
9945        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
9946        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
9947    }
9948    Ok(())
9949}
9950
9951fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
9952    let mut stmt = conn.prepare(
9953        "SELECT source.file_path, source.scoped_name, edges.target_file,
9954                edges.target_symbol, edges.kind, edges.line
9955         FROM edges
9956         JOIN nodes AS source ON source.id = edges.source_node
9957         ORDER BY source.file_path, source.scoped_name, edges.target_file,
9958                  edges.target_symbol, edges.kind, edges.line",
9959    )?;
9960    let rows = stmt.query_map([], |row| {
9961        Ok(StoredEdge {
9962            source_file: row.get(0)?,
9963            source_symbol: row.get(1)?,
9964            target_file: row.get(2)?,
9965            target_symbol: row.get(3)?,
9966            kind: row.get(4)?,
9967            line: row.get::<_, i64>(5)? as u32,
9968        })
9969    })?;
9970    let mut edges = BTreeSet::new();
9971    for row in rows {
9972        edges.insert(row?);
9973    }
9974    Ok(edges)
9975}
9976
9977fn module_target_from_dependencies(
9978    project_root: &Path,
9979    dependencies: &BTreeSet<String>,
9980) -> Option<String> {
9981    dependencies.iter().find_map(|dep| {
9982        let path = project_root.join(dep);
9983        if path.is_file() {
9984            Some(relative_path(project_root, &canonicalize_path(&path)))
9985        } else {
9986            None
9987        }
9988    })
9989}
9990
9991fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
9992    let mut named = HashMap::new();
9993    if let Some(full_ref) = &raw_ref.full_ref {
9994        named = parse_reexport_names(full_ref);
9995    }
9996    ReexportIndex {
9997        target_file,
9998        named,
9999        wildcard: raw_ref.wildcard,
10000    }
10001}
10002
10003fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
10004    let mut names = HashMap::new();
10005    let Some(open) = statement.find('{') else {
10006        return names;
10007    };
10008    let Some(close) = statement[open + 1..]
10009        .find('}')
10010        .map(|offset| open + 1 + offset)
10011    else {
10012        return names;
10013    };
10014    for spec in statement[open + 1..close].split(',') {
10015        let spec = spec.trim();
10016        if spec.is_empty() {
10017            continue;
10018        }
10019        if let Some((source, local)) = spec.split_once(" as ") {
10020            names.insert(local.trim().to_string(), source.trim().to_string());
10021        } else {
10022            names.insert(spec.to_string(), spec.to_string());
10023        }
10024    }
10025    names
10026}
10027
10028#[derive(Debug)]
10029struct RefDependencyRow {
10030    ref_id: String,
10031    kind: String,
10032    caller_file: String,
10033    module_path: Option<String>,
10034    target_file: Option<String>,
10035}
10036
10037fn ref_dependency_row_depends_on(
10038    project_root: &Path,
10039    row: &RefDependencyRow,
10040    rel_path: &str,
10041) -> bool {
10042    if row.target_file.as_deref() == Some(rel_path) {
10043        return true;
10044    }
10045
10046    match row.kind.as_str() {
10047        "call" => true,
10048        "import" | "reexport" => row
10049            .module_path
10050            .as_deref()
10051            .map(|module_path| {
10052                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
10053                    .contains(rel_path)
10054            })
10055            .unwrap_or(false),
10056        "export_alias" => false,
10057        _ => false,
10058    }
10059}
10060
10061fn module_dependencies_for_ref(
10062    project_root: &Path,
10063    caller_file: &str,
10064    module_path: &str,
10065) -> BTreeSet<String> {
10066    module_dependencies(project_root, &project_root.join(caller_file), module_path)
10067}
10068
10069fn import_dependencies(
10070    project_root: &Path,
10071    abs_path: &Path,
10072    imports: &[ImportStatement],
10073) -> BTreeSet<String> {
10074    let mut deps = BTreeSet::new();
10075    for import in imports {
10076        deps.extend(module_dependencies(
10077            project_root,
10078            abs_path,
10079            &import.module_path,
10080        ));
10081    }
10082    deps
10083}
10084
10085fn module_dependencies(
10086    project_root: &Path,
10087    abs_path: &Path,
10088    module_path: &str,
10089) -> BTreeSet<String> {
10090    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
10091    let caller_dir = abs_path.parent().unwrap_or(project_root);
10092    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
10093        deps.insert(relative_path(project_root, &resolved));
10094    }
10095    if module_path.starts_with('.') {
10096        let base = caller_dir.join(module_path);
10097        for candidate in relative_module_candidates(&base) {
10098            deps.insert(relative_path(project_root, &candidate));
10099        }
10100    }
10101    deps
10102}
10103
10104fn rust_module_dependencies(
10105    project_root: &Path,
10106    abs_path: &Path,
10107    module_path: &str,
10108) -> BTreeSet<String> {
10109    let mut deps = BTreeSet::new();
10110    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
10111    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
10112        return deps;
10113    };
10114    let src_prefix = rust_src_prefix(&rel_path);
10115    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
10116    if !path_segments.is_empty() {
10117        rust_push_module_dependency_candidate(
10118            project_root,
10119            &mut deps,
10120            &src_prefix,
10121            &path_segments[..path_segments.len() - 1],
10122        );
10123    }
10124    deps
10125}
10126
10127fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
10128    let path = rust_module_path_without_alias_or_use_list(module_path);
10129    let segments = path
10130        .split("::")
10131        .map(str::trim)
10132        .filter(|segment| !segment.is_empty())
10133        .collect::<Vec<_>>();
10134    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
10135        return None;
10136    }
10137    rust_resolve_segments(rel_path, &segments)
10138}
10139
10140fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
10141    let path = module_path
10142        .trim()
10143        .trim_end_matches(';')
10144        .split_once(" as ")
10145        .map(|(left, _)| left.trim())
10146        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
10147    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
10148}
10149
10150fn rust_push_module_dependency_candidate(
10151    project_root: &Path,
10152    deps: &mut BTreeSet<String>,
10153    src_prefix: &str,
10154    segments: &[String],
10155) {
10156    let candidates = if segments.is_empty() {
10157        vec![
10158            format!("{src_prefix}/lib.rs"),
10159            format!("{src_prefix}/main.rs"),
10160        ]
10161    } else {
10162        vec![
10163            format!("{}/{}.rs", src_prefix, segments.join("/")),
10164            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
10165        ]
10166    };
10167    for candidate in candidates {
10168        if project_root.join(&candidate).is_file() {
10169            deps.insert(candidate);
10170        }
10171    }
10172}
10173
10174fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
10175    let mut candidates = Vec::new();
10176    if base.extension().is_some() {
10177        candidates.push(base.to_path_buf());
10178        return candidates;
10179    }
10180    for ext in JS_TS_EXTENSIONS {
10181        candidates.push(base.with_extension(ext));
10182    }
10183    for ext in JS_TS_EXTENSIONS {
10184        candidates.push(base.join(format!("index.{ext}")));
10185    }
10186    candidates
10187}
10188
10189fn import_local_names(import: &ImportStatement) -> Vec<String> {
10190    let mut names = Vec::new();
10191    if let Some(default) = &import.default_import {
10192        names.push(default.clone());
10193    }
10194    if let Some(namespace) = &import.namespace_import {
10195        names.push(namespace.clone());
10196    }
10197    for name in &import.names {
10198        names.push(crate::imports::specifier_local_name(name).to_string());
10199    }
10200    names
10201}
10202
10203fn import_requested_names(import: &ImportStatement) -> Vec<String> {
10204    import
10205        .names
10206        .iter()
10207        .map(|name| crate::imports::specifier_imported_name(name).to_string())
10208        .collect()
10209}
10210
10211fn import_is_wildcard(import: &ImportStatement) -> bool {
10212    import.namespace_import.is_some() || import.raw_text.contains('*')
10213}
10214
10215fn namespace_alias(full_ref: &str) -> Option<String> {
10216    full_ref
10217        .split_once('.')
10218        .map(|(namespace, _)| namespace.to_string())
10219}
10220
10221fn import_kind_label(kind: ImportKind) -> &'static str {
10222    match kind {
10223        ImportKind::Value => "value",
10224        ImportKind::Type => "type",
10225        ImportKind::SideEffect => "side_effect",
10226    }
10227}
10228
10229fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
10230    match kind {
10231        SymbolKind::Function => "function",
10232        SymbolKind::Class => "class",
10233        SymbolKind::Method => "method",
10234        SymbolKind::Struct => "struct",
10235        SymbolKind::Interface => "interface",
10236        SymbolKind::Enum => "enum",
10237        SymbolKind::TypeAlias => "type_alias",
10238        SymbolKind::Variable => "variable",
10239        SymbolKind::Heading => "heading",
10240        SymbolKind::FileSummary => "file_summary",
10241    }
10242}
10243
10244fn is_type_like(kind: &SymbolKind) -> bool {
10245    matches!(
10246        kind,
10247        SymbolKind::Class
10248            | SymbolKind::Struct
10249            | SymbolKind::Interface
10250            | SymbolKind::Enum
10251            | SymbolKind::TypeAlias
10252    )
10253}
10254
10255fn lang_label(lang: LangId) -> &'static str {
10256    match lang {
10257        LangId::TypeScript => "typescript",
10258        LangId::Tsx => "tsx",
10259        LangId::JavaScript => "javascript",
10260        LangId::Python => "python",
10261        LangId::Rust => "rust",
10262        LangId::Go => "go",
10263        LangId::C => "c",
10264        LangId::Cpp => "cpp",
10265        LangId::Zig => "zig",
10266        LangId::CSharp => "csharp",
10267        LangId::Bash => "bash",
10268        LangId::Html => "html",
10269        LangId::Markdown => "markdown",
10270        LangId::Solidity => "solidity",
10271        LangId::Scss => "scss",
10272        LangId::Vue => "vue",
10273        LangId::Json => "json",
10274        LangId::Scala => "scala",
10275        LangId::Java => "java",
10276        LangId::Ruby => "ruby",
10277        LangId::Kotlin => "kotlin",
10278        LangId::Swift => "swift",
10279        LangId::Php => "php",
10280        LangId::Lua => "lua",
10281        LangId::Perl => "perl",
10282        LangId::Yaml => "yaml",
10283        LangId::Pascal => "pascal",
10284        LangId::R => "r",
10285        LangId::Groovy => "groovy",
10286        LangId::ObjC => "objc",
10287    }
10288}
10289
10290fn lang_from_label(label: &str) -> Option<LangId> {
10291    match label {
10292        "typescript" => Some(LangId::TypeScript),
10293        "tsx" => Some(LangId::Tsx),
10294        "javascript" => Some(LangId::JavaScript),
10295        "python" => Some(LangId::Python),
10296        "rust" => Some(LangId::Rust),
10297        "go" => Some(LangId::Go),
10298        "c" => Some(LangId::C),
10299        "cpp" => Some(LangId::Cpp),
10300        "zig" => Some(LangId::Zig),
10301        "csharp" => Some(LangId::CSharp),
10302        "bash" => Some(LangId::Bash),
10303        "html" => Some(LangId::Html),
10304        "markdown" => Some(LangId::Markdown),
10305        "solidity" => Some(LangId::Solidity),
10306        "scss" => Some(LangId::Scss),
10307        "vue" => Some(LangId::Vue),
10308        "json" => Some(LangId::Json),
10309        "scala" => Some(LangId::Scala),
10310        "java" => Some(LangId::Java),
10311        "ruby" => Some(LangId::Ruby),
10312        "kotlin" => Some(LangId::Kotlin),
10313        "swift" => Some(LangId::Swift),
10314        "php" => Some(LangId::Php),
10315        "lua" => Some(LangId::Lua),
10316        "perl" => Some(LangId::Perl),
10317        "yaml" => Some(LangId::Yaml),
10318        "pascal" => Some(LangId::Pascal),
10319        "r" => Some(LangId::R),
10320        "groovy" => Some(LangId::Groovy),
10321        "objc" => Some(LangId::ObjC),
10322        _ => None,
10323    }
10324}
10325
10326fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
10327    let mut normalized = if files.is_empty() {
10328        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
10329    } else {
10330        files
10331            .iter()
10332            .map(|path| normalize_file_path(project_root, path))
10333            .collect::<Result<Vec<_>>>()?
10334    };
10335    normalized.sort();
10336    normalized.dedup();
10337    Ok(normalized)
10338}
10339
10340fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
10341    let full_path = if path.is_relative() {
10342        project_root.join(path)
10343    } else {
10344        path.to_path_buf()
10345    };
10346    Ok(canonicalize_path(&full_path))
10347}
10348
10349fn canonicalize_path(path: &Path) -> PathBuf {
10350    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
10351}
10352
10353fn relative_path(project_root: &Path, path: &Path) -> String {
10354    if let Ok(stripped) = path.strip_prefix(project_root) {
10355        return stripped.to_string_lossy().replace('\\', "/");
10356    }
10357    let canon_root = canonicalize_path(project_root);
10358    let canon_path = canonicalize_path(path);
10359    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
10360        return stripped.to_string_lossy().replace('\\', "/");
10361    }
10362    canon_path.to_string_lossy().replace('\\', "/")
10363}
10364
10365fn unqualified_name(scoped: &str) -> &str {
10366    if scoped == TOP_LEVEL_SYMBOL {
10367        return scoped;
10368    }
10369    scoped
10370        .rsplit("::")
10371        .next()
10372        .unwrap_or(scoped)
10373        .rsplit('.')
10374        .next()
10375        .unwrap_or(scoped)
10376        .rsplit('#')
10377        .next()
10378        .unwrap_or(scoped)
10379}
10380
10381fn ref_id(parts: &[&str]) -> String {
10382    let joined = parts.join("\0");
10383    hash_to_hex(blake3::hash(joined.as_bytes()))
10384}
10385
10386fn hash_to_hex(hash: blake3::Hash) -> String {
10387    hash.to_hex().to_string()
10388}
10389
10390fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
10391    let bytes = hex_to_bytes(value)?;
10392    Some(blake3::Hash::from_bytes(bytes))
10393}
10394
10395fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
10396    if value.len() != 64 {
10397        return None;
10398    }
10399    let mut bytes = [0u8; 32];
10400    for (index, slot) in bytes.iter_mut().enumerate() {
10401        let start = index * 2;
10402        let end = start + 2;
10403        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
10404    }
10405    Some(bytes)
10406}
10407
10408#[derive(Debug, Clone)]
10409struct LineIndex {
10410    newline_offsets: Vec<usize>,
10411    source_len: usize,
10412}
10413
10414impl LineIndex {
10415    fn new(source: &str) -> Self {
10416        Self {
10417            newline_offsets: source
10418                .bytes()
10419                .enumerate()
10420                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
10421                .collect(),
10422            source_len: source.len(),
10423        }
10424    }
10425
10426    fn byte_to_line(&self, byte_offset: usize) -> u32 {
10427        let byte_offset = byte_offset.min(self.source_len);
10428        self.newline_offsets
10429            .partition_point(|offset| *offset < byte_offset) as u32
10430            + 1
10431    }
10432}
10433
10434fn empty_to_none(value: String) -> Option<String> {
10435    if value.is_empty() {
10436        None
10437    } else {
10438        Some(value)
10439    }
10440}
10441
10442fn bool_int(value: bool) -> i64 {
10443    if value {
10444        1
10445    } else {
10446        0
10447    }
10448}
10449
10450fn system_time_to_ns(time: SystemTime) -> i64 {
10451    time.duration_since(UNIX_EPOCH)
10452        .unwrap_or_default()
10453        .as_nanos()
10454        .min(i64::MAX as u128) as i64
10455}
10456
10457fn ns_to_system_time(value: i64) -> SystemTime {
10458    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
10459}
10460
10461fn unix_seconds_now() -> i64 {
10462    SystemTime::now()
10463        .duration_since(UNIX_EPOCH)
10464        .unwrap_or_default()
10465        .as_secs() as i64
10466}
10467
10468/// Serializes every test that drives the process-wide refresh worker
10469/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
10470/// worker down between another test's enqueue and its flush, deferring the
10471/// batch and zeroing that test's seam counts).
10472#[cfg(test)]
10473pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
10474
10475#[cfg(test)]
10476mod refresh_worker_tests {
10477    use super::*;
10478    use std::fs;
10479    use tempfile::tempdir;
10480
10481    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10482        let temp = tempdir().unwrap();
10483        let root = temp.path().join("root");
10484        let callgraph_dir = temp
10485            .path()
10486            .join("storage")
10487            .join("callgraph")
10488            .join(crate::search_index::artifact_cache_key(&root));
10489        fs::create_dir_all(&root).unwrap();
10490        let source = root.join("main.rs");
10491        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
10492        let (store, _) = CallGraphStore::cold_build_with_lease(
10493            callgraph_dir.clone(),
10494            root.clone(),
10495            std::slice::from_ref(&source),
10496        )
10497        .unwrap();
10498        drop(store);
10499        (temp, root, callgraph_dir, source)
10500    }
10501
10502    fn pending_paths() -> PendingCallGraphStorePaths {
10503        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
10504    }
10505
10506    fn wait_for_refresh_calls(root: &Path, expected: usize) {
10507        let deadline = Instant::now() + Duration::from_secs(12);
10508        while callgraph_refresh_worker_test_counts(root).0 < expected {
10509            assert!(
10510                Instant::now() < deadline,
10511                "timed out waiting for {expected} callgraph refresh worker call(s)"
10512            );
10513            std::thread::sleep(Duration::from_millis(5));
10514        }
10515    }
10516
10517    fn wait_for_refresh_worker_idle() {
10518        let deadline = Instant::now() + Duration::from_secs(12);
10519        loop {
10520            let worker = CALLGRAPH_REFRESH_WORKER
10521                .get_or_init(|| Mutex::new(None))
10522                .lock()
10523                .expect("callgraph refresh worker mutex poisoned")
10524                .clone();
10525            let idle = worker.is_none_or(|worker| {
10526                let queue = worker
10527                    .shared
10528                    .queue
10529                    .lock()
10530                    .expect("callgraph refresh queue mutex poisoned");
10531                queue.active.is_none() && queue.order.is_empty()
10532            });
10533            if idle {
10534                return;
10535            }
10536            assert!(
10537                Instant::now() < deadline,
10538                "timed out waiting for callgraph refresh worker to become idle"
10539            );
10540            std::thread::sleep(Duration::from_millis(5));
10541        }
10542    }
10543
10544    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
10545        let temp = tempdir().unwrap();
10546        let root = temp.path().join("workspace");
10547        let callgraph_dir = temp
10548            .path()
10549            .join("storage")
10550            .join("callgraph")
10551            .join(crate::search_index::artifact_cache_key(&root));
10552        fs::create_dir_all(root.join("app/src")).unwrap();
10553        fs::write(
10554            root.join("Cargo.toml"),
10555            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
10556        )
10557        .unwrap();
10558        fs::write(
10559            root.join("app/Cargo.toml"),
10560            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10561        )
10562        .unwrap();
10563        let caller = root.join("app/src/lib.rs");
10564        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
10565        let (store, _) = CallGraphStore::cold_build_with_lease(
10566            callgraph_dir.clone(),
10567            root.clone(),
10568            std::slice::from_ref(&caller),
10569        )
10570        .unwrap();
10571        drop(store);
10572        (temp, root, callgraph_dir, caller)
10573    }
10574
10575    #[test]
10576    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
10577        let _guard = REFRESH_WORKER_TEST_LOCK
10578            .lock()
10579            .unwrap_or_else(std::sync::PoisonError::into_inner);
10580        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10581        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10582        reset_workspace_crate_prefix_build_count(&root);
10583        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10584
10585        for revision in ["first", "second"] {
10586            fs::write(
10587                &caller,
10588                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
10589            )
10590            .unwrap();
10591            enqueue_callgraph_store_refresh(
10592                callgraph_dir.clone(),
10593                root.clone(),
10594                vec![caller.clone()],
10595                pending_paths(),
10596            );
10597            wait_for_refresh_worker_idle();
10598        }
10599
10600        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10601        assert!(flush_callgraph_store_refreshes_with_budget(
10602            Duration::from_secs(5)
10603        ));
10604        clear_callgraph_refresh_worker_test_seam(&root);
10605    }
10606
10607    #[test]
10608    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
10609        let _guard = REFRESH_WORKER_TEST_LOCK
10610            .lock()
10611            .unwrap_or_else(std::sync::PoisonError::into_inner);
10612        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10613        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
10614        reset_workspace_crate_prefix_build_count(&root);
10615        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10616
10617        fs::write(
10618            &caller,
10619            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
10620        )
10621        .unwrap();
10622        enqueue_callgraph_store_refresh(
10623            callgraph_dir.clone(),
10624            root.clone(),
10625            vec![caller.clone()],
10626            pending_paths(),
10627        );
10628        wait_for_refresh_worker_idle();
10629        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
10630
10631        let added_manifest = root.join("added/Cargo.toml");
10632        let added_source = root.join("added/src/lib.rs");
10633        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
10634        fs::write(
10635            root.join("Cargo.toml"),
10636            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
10637        )
10638        .unwrap();
10639        fs::write(
10640            &added_manifest,
10641            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
10642        )
10643        .unwrap();
10644        fs::write(&added_source, "pub fn target() {}\n").unwrap();
10645        fs::write(
10646            &caller,
10647            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
10648        )
10649        .unwrap();
10650
10651        enqueue_callgraph_store_refresh(
10652            callgraph_dir.clone(),
10653            root.clone(),
10654            vec![
10655                root.join("Cargo.toml"),
10656                added_manifest,
10657                added_source,
10658                caller,
10659            ],
10660            pending_paths(),
10661        );
10662        assert!(flush_callgraph_store_refreshes_with_budget(
10663            Duration::from_secs(12)
10664        ));
10665
10666        // This is the negative control for a permanently-static cache: without
10667        // manifest invalidation the build count stays at one and the call remains
10668        // unresolved because `added_crate` was absent when the map was primed.
10669        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
10670        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10671            .unwrap()
10672            .expect("refreshed workspace store");
10673        let tree = store
10674            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
10675            .unwrap();
10676        assert_eq!(tree.children.len(), 1);
10677        assert_eq!(tree.children[0].file, "added/src/lib.rs");
10678        assert_eq!(tree.children[0].name, "target");
10679        assert!(tree.children[0].resolved);
10680        clear_callgraph_refresh_worker_test_seam(&root);
10681    }
10682
10683    #[test]
10684    fn forced_rebuild_without_writer_capability_cannot_report_old_store_as_ready() {
10685        let _git_env = crate::test_env::hermetic_git_env_guard();
10686        let temp = tempdir().unwrap();
10687        let main = temp.path().join("main");
10688        let root = temp.path().join("worktree");
10689        fs::create_dir_all(&main).unwrap();
10690        let mut git = std::process::Command::new("git");
10691        assert!(
10692            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
10693                .status()
10694                .unwrap()
10695                .success()
10696        );
10697        let source = main.join("lib.rs");
10698        fs::write(&source, "pub fn marker() {}\n").unwrap();
10699        for args in [
10700            vec![
10701                "-C",
10702                main.to_str().unwrap(),
10703                "config",
10704                "user.email",
10705                "test@example.com",
10706            ],
10707            vec![
10708                "-C",
10709                main.to_str().unwrap(),
10710                "config",
10711                "user.name",
10712                "AFT Test",
10713            ],
10714            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
10715            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
10716        ] {
10717            let mut command = std::process::Command::new("git");
10718            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
10719                .status()
10720                .unwrap()
10721                .success());
10722        }
10723        let mut worktree = std::process::Command::new("git");
10724        assert!(crate::test_env::apply_hermetic_git_env(
10725            worktree
10726                .arg("-C")
10727                .arg(&main)
10728                .args(["worktree", "add", "--detach"])
10729                .arg(&root),
10730        )
10731        .status()
10732        .unwrap()
10733        .success());
10734
10735        let project_key = crate::search_index::artifact_cache_key(&root);
10736        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
10737        crate::root_cache::configure_artifact_access(&root, &project_key, true);
10738        let source = root.join("lib.rs");
10739        let error =
10740            CallGraphStore::force_cold_build_with_lease_chunked(callgraph_dir, root, &[source], 1)
10741                .expect_err("borrow-only forced rebuild must remain unsatisfied");
10742
10743        assert!(matches!(error, CallGraphStoreError::Unavailable(_)));
10744    }
10745
10746    #[test]
10747    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
10748        let _guard = REFRESH_WORKER_TEST_LOCK
10749            .lock()
10750            .unwrap_or_else(std::sync::PoisonError::into_inner);
10751        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10752        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10753        let pending = pending_paths();
10754        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10755
10756        let lifecycle = SubcLifecycleAdmission::default();
10757        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
10758        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10759        let ticket = CallgraphRefreshTicket::new(
10760            lifecycle,
10761            Arc::clone(&generation),
10762            7,
10763            publish_epoch.clone(),
10764            publish_epoch.current(),
10765        );
10766        // Supersede before the worker runs: the batch must defer, not commit.
10767        generation.store(8, std::sync::atomic::Ordering::SeqCst);
10768
10769        enqueue_callgraph_store_refresh_fenced(
10770            callgraph_dir,
10771            root.clone(),
10772            vec![source.clone()],
10773            Arc::clone(&pending),
10774            ticket,
10775        );
10776        assert!(flush_callgraph_store_refreshes_with_budget(
10777            Duration::from_secs(5)
10778        ));
10779        assert_eq!(
10780            callgraph_refresh_worker_test_counts(&root).0,
10781            0,
10782            "superseded batch must not reach refresh_files"
10783        );
10784        assert!(
10785            pending.lock().contains(&source),
10786            "superseded batch must defer its paths to the pending sink"
10787        );
10788        clear_callgraph_refresh_worker_test_seam(&root);
10789    }
10790
10791    #[test]
10792    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
10793        let _guard = REFRESH_WORKER_TEST_LOCK
10794            .lock()
10795            .unwrap_or_else(std::sync::PoisonError::into_inner);
10796        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10797        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10798        let pending = pending_paths();
10799        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10800
10801        let lifecycle = SubcLifecycleAdmission::default();
10802        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
10803        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10804        let expected_epoch = publish_epoch.current();
10805        let ticket = CallgraphRefreshTicket::new(
10806            lifecycle,
10807            generation,
10808            3,
10809            publish_epoch.clone(),
10810            expected_epoch,
10811        );
10812        // A cold build published a replacement generation after enqueue.
10813        publish_epoch.next();
10814
10815        enqueue_callgraph_store_refresh_fenced(
10816            callgraph_dir,
10817            root.clone(),
10818            vec![source.clone()],
10819            Arc::clone(&pending),
10820            ticket,
10821        );
10822        assert!(flush_callgraph_store_refreshes_with_budget(
10823            Duration::from_secs(5)
10824        ));
10825        assert_eq!(
10826            callgraph_refresh_worker_test_counts(&root).0,
10827            0,
10828            "epoch-superseded batch must not reach refresh_files"
10829        );
10830        assert!(
10831            pending.lock().contains(&source),
10832            "epoch-superseded batch must defer its paths to the pending sink"
10833        );
10834        clear_callgraph_refresh_worker_test_seam(&root);
10835    }
10836
10837    #[test]
10838    fn fenced_refresh_with_current_ticket_commits_normally() {
10839        let _guard = REFRESH_WORKER_TEST_LOCK
10840            .lock()
10841            .unwrap_or_else(std::sync::PoisonError::into_inner);
10842        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10843        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10844        let pending = pending_paths();
10845        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
10846
10847        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
10848
10849        let lifecycle = SubcLifecycleAdmission::default();
10850        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
10851        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
10852        let ticket = CallgraphRefreshTicket::new(
10853            lifecycle,
10854            generation,
10855            5,
10856            publish_epoch.clone(),
10857            publish_epoch.current(),
10858        );
10859
10860        enqueue_callgraph_store_refresh_fenced(
10861            callgraph_dir.clone(),
10862            root.clone(),
10863            vec![source.clone()],
10864            Arc::clone(&pending),
10865            ticket,
10866        );
10867        assert!(flush_callgraph_store_refreshes_with_budget(
10868            Duration::from_secs(5)
10869        ));
10870        assert_eq!(
10871            callgraph_refresh_worker_test_counts(&root).0,
10872            1,
10873            "current ticket must run the refresh"
10874        );
10875        assert!(
10876            pending.lock().is_empty(),
10877            "committed batch must not defer paths"
10878        );
10879
10880        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
10881            .unwrap()
10882            .expect("published generation must remain readable");
10883        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10884        assert_eq!(
10885            tree.children[0].name, "new_leaf",
10886            "fenced commit must actually persist the refreshed content"
10887        );
10888        clear_callgraph_refresh_worker_test_seam(&root);
10889    }
10890
10891    #[test]
10892    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
10893        let _guard = REFRESH_WORKER_TEST_LOCK
10894            .lock()
10895            .unwrap_or_else(std::sync::PoisonError::into_inner);
10896        // Generous pre-drain: the refresh worker is process-wide, so a prior
10897        // test's still-running batch (slow Windows CI) must fully settle
10898        // before this test enqueues, or its wait deadline absorbs the
10899        // leftover work. Idle workers return immediately.
10900        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10901        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
10902        let pending = pending_paths();
10903        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
10904
10905        enqueue_callgraph_store_refresh(
10906            callgraph_dir.clone(),
10907            root.clone(),
10908            vec![source.clone()],
10909            Arc::clone(&pending),
10910        );
10911        wait_for_refresh_calls(&root, 1);
10912        for _ in 0..3 {
10913            enqueue_callgraph_store_refresh(
10914                callgraph_dir.clone(),
10915                root.clone(),
10916                vec![source.clone()],
10917                Arc::clone(&pending),
10918            );
10919        }
10920
10921        assert!(flush_callgraph_store_refreshes_with_budget(
10922            Duration::from_secs(2)
10923        ));
10924        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
10925        assert!(pending.lock().is_empty());
10926        clear_callgraph_refresh_worker_test_seam(&root);
10927    }
10928
10929    #[test]
10930    fn queued_refresh_opens_generation_published_after_enqueue() {
10931        let _guard = REFRESH_WORKER_TEST_LOCK
10932            .lock()
10933            .unwrap_or_else(std::sync::PoisonError::into_inner);
10934        // Generous pre-drain: the refresh worker is process-wide, so a prior
10935        // test's still-running batch (slow Windows CI) must fully settle
10936        // before this test enqueues, or its wait deadline absorbs the
10937        // leftover work. Idle workers return immediately.
10938        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
10939        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
10940        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
10941        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
10942        let (active_held_rx, active_release_tx) =
10943            install_refresh_worker_test_gate(active_root.clone());
10944        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
10945        enqueue_callgraph_store_refresh(
10946            active_dir,
10947            active_root.clone(),
10948            vec![active_source],
10949            pending_paths(),
10950        );
10951        active_held_rx
10952            .recv_timeout(Duration::from_secs(12))
10953            .expect("active refresh worker holds the queue");
10954
10955        fs::write(
10956            &target_source,
10957            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10958        )
10959        .unwrap();
10960        enqueue_callgraph_store_refresh(
10961            target_dir.clone(),
10962            target_root.clone(),
10963            vec![target_source.clone()],
10964            pending_paths(),
10965        );
10966        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
10967            target_dir.clone(),
10968            target_root.clone(),
10969            std::slice::from_ref(&target_source),
10970        )
10971        .unwrap();
10972        fs::write(
10973            &target_source,
10974            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
10975        )
10976        .unwrap();
10977        drop(new_generation);
10978
10979        active_release_tx
10980            .send(())
10981            .expect("release active refresh worker");
10982        wait_for_refresh_calls(&target_root, 1);
10983        assert!(flush_callgraph_store_refreshes_with_budget(
10984            Duration::from_secs(12)
10985        ));
10986        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
10987            .unwrap()
10988            .expect("current callgraph generation");
10989        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
10990        assert_eq!(tree.children[0].name, "worker_leaf");
10991        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
10992        clear_callgraph_refresh_worker_test_seam(&active_root);
10993        clear_callgraph_refresh_worker_test_seam(&target_root);
10994    }
10995
10996    #[test]
10997    fn refresh_failure_marks_files_stale() {
10998        let _guard = REFRESH_WORKER_TEST_LOCK
10999            .lock()
11000            .unwrap_or_else(std::sync::PoisonError::into_inner);
11001        // Generous pre-drain: the refresh worker is process-wide, so a prior
11002        // test's still-running batch (slow Windows CI) must fully settle
11003        // before this test enqueues, or its wait deadline absorbs the
11004        // leftover work. Idle workers return immediately.
11005        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11006        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
11007        let pending = pending_paths();
11008        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
11009
11010        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
11011        assert!(flush_callgraph_store_refreshes_with_budget(
11012            Duration::from_secs(2)
11013        ));
11014
11015        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
11016        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
11017            .unwrap()
11018            .expect("ready callgraph store");
11019        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
11020        clear_callgraph_refresh_worker_test_seam(&root);
11021    }
11022
11023    #[test]
11024    fn bounded_shutdown_defers_unprocessed_batches() {
11025        let _guard = REFRESH_WORKER_TEST_LOCK
11026            .lock()
11027            .unwrap_or_else(std::sync::PoisonError::into_inner);
11028        // Generous pre-drain: the refresh worker is process-wide, so a prior
11029        // test's still-running batch (slow Windows CI) must fully settle
11030        // before this test enqueues, or its wait deadline absorbs the
11031        // leftover work. Idle workers return immediately.
11032        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
11033        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
11034        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
11035        let active_pending = pending_paths();
11036        let queued_pending = pending_paths();
11037        set_callgraph_refresh_worker_test_seam(
11038            active_root.clone(),
11039            Duration::from_millis(300),
11040            false,
11041        );
11042
11043        enqueue_callgraph_store_refresh(
11044            active_dir,
11045            active_root.clone(),
11046            vec![active_source.clone()],
11047            Arc::clone(&active_pending),
11048        );
11049        wait_for_refresh_calls(&active_root, 1);
11050        enqueue_callgraph_store_refresh(
11051            queued_dir,
11052            queued_root.clone(),
11053            vec![queued_source.clone()],
11054            Arc::clone(&queued_pending),
11055        );
11056
11057        assert!(!flush_callgraph_store_refreshes_with_budget(
11058            Duration::from_millis(20)
11059        ));
11060        assert!(active_pending.lock().contains(&active_source));
11061        assert!(queued_pending.lock().contains(&queued_source));
11062        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
11063        clear_callgraph_refresh_worker_test_seam(&active_root);
11064    }
11065}
11066
11067#[cfg(test)]
11068mod cold_build_insert_tests {
11069    use super::*;
11070    use crate::imports::ImportBlock;
11071    use std::cell::Cell;
11072    use std::fs;
11073    use std::path::{Path, PathBuf};
11074    use tempfile::tempdir;
11075
11076    thread_local! {
11077        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
11078        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
11079        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
11080    }
11081
11082    fn count_caller_traversal_selects(sql: &str) {
11083        let sql = sql.trim_start();
11084        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
11085            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
11086        }
11087        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
11088            && sql.contains("e.target_file =")
11089        {
11090            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
11091        }
11092        if sql.starts_with("WITH requested") {
11093            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
11094        }
11095    }
11096
11097    #[test]
11098    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
11099        let dir = tempdir().unwrap();
11100        let previous_root = dir.path().join("previous-root");
11101        let current_root = dir.path().join("current-root");
11102        fs::create_dir_all(&previous_root).unwrap();
11103        fs::create_dir_all(&current_root).unwrap();
11104        fs::remove_dir(&previous_root).unwrap();
11105        let mut conn = Connection::open_in_memory().unwrap();
11106        initialize_schema(&conn).unwrap();
11107        conn.execute(
11108            "INSERT INTO backend_file_state(
11109                backend, workspace_root, file_path, content_hash, status, updated_at
11110             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
11111            params![previous_root.display().to_string()],
11112        )
11113        .unwrap();
11114
11115        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
11116
11117        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
11118        assert_eq!(
11119            stored_workspace_roots(&conn).unwrap(),
11120            vec![previous_root.display().to_string()]
11121        );
11122    }
11123
11124    #[test]
11125    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
11126        assert_eq!(
11127            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
11128            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
11129        );
11130    }
11131
11132    #[test]
11133    fn legacy_migration_completion_log_has_operator_fields() {
11134        assert_eq!(
11135            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
11136            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
11137        );
11138    }
11139
11140    fn write_generation_with_age(
11141        dir: &Path,
11142        project_key: &str,
11143        ordinal: u64,
11144        age: Duration,
11145    ) -> String {
11146        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
11147        let path = dir.join(&generation);
11148        fs::write(&path, b"sqlite placeholder").unwrap();
11149        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11150        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11151        generation
11152    }
11153
11154    #[test]
11155    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
11156        let dir = tempfile::tempdir().unwrap();
11157        let project_key = "project";
11158        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11159        let previous =
11160            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11161        let pinned =
11162            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11163        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11164
11165        gc_old_generations(dir.path(), project_key, &current);
11166
11167        assert!(dir.path().join(&previous).is_file());
11168        assert!(dir.path().join(&pinned).is_file());
11169
11170        drop(marker);
11171        gc_old_generations(dir.path(), project_key, &current);
11172
11173        assert!(dir.path().join(&previous).is_file());
11174        assert!(!dir.path().join(&pinned).exists());
11175    }
11176
11177    #[test]
11178    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
11179        let dir = tempfile::tempdir().unwrap();
11180        let project_key = "project";
11181        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11182        let _previous =
11183            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
11184        let pinned =
11185            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
11186        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
11187        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
11188
11189        gc_old_generations(dir.path(), project_key, &current);
11190
11191        assert!(dir.path().join(&pinned).is_file());
11192    }
11193
11194    #[test]
11195    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
11196        let dir = tempfile::tempdir().unwrap();
11197        let project_key = "project";
11198        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
11199        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
11200        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
11201        let old = write_generation_with_age(
11202            dir.path(),
11203            project_key,
11204            200,
11205            expired + Duration::from_secs(60),
11206        );
11207        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
11208
11209        gc_old_generations(dir.path(), project_key, &current);
11210
11211        assert!(dir.path().join(&current).is_file());
11212        assert!(dir.path().join(&previous).is_file());
11213        assert!(!dir.path().join(&old).exists());
11214    }
11215
11216    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
11217        let path = dir.join(name);
11218        fs::write(&path, b"temp placeholder").unwrap();
11219        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
11220        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
11221        path
11222    }
11223
11224    #[test]
11225    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
11226        let dir = tempdir().unwrap();
11227        // One directory holds both an aged orphan (with its journal sidecar) and a
11228        // fresh temporary, so this proves the sweep SELECTS by age rather than
11229        // deleting everything in the directory.
11230        let aged = "project.g100.1.sqlite.tmp.1.200";
11231        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
11232        let fresh = "project.g300.1.sqlite.tmp.1.400";
11233        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
11234        write_build_temp_with_age(dir.path(), aged, aged_age);
11235        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
11236        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11237
11238        sweep_orphaned_build_temps(dir.path());
11239
11240        assert!(
11241            !dir.path().join(aged).exists(),
11242            "aged orphan must be removed"
11243        );
11244        assert!(
11245            !dir.path().join(aged_journal).exists(),
11246            "aged journal sidecar must be removed"
11247        );
11248        assert!(
11249            dir.path().join(fresh).is_file(),
11250            "fresh temporary must survive"
11251        );
11252    }
11253
11254    #[test]
11255    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
11256        let storage = tempdir().unwrap();
11257        let storage_root = storage.path();
11258        // The production shape: a legacy per-harness store whose root no longer
11259        // builds there — no `.current` pointer, no running build — so the per-root
11260        // cleanup never fires for it. A sibling root still building in the
11261        // root-keyed store triggers the store-wide sweep, which must reach into the
11262        // legacy directory and reclaim the orphan.
11263        let legacy_dir = storage_root.join("opencode").join("callgraph");
11264        fs::create_dir_all(&legacy_dir).unwrap();
11265        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
11266        write_build_temp_with_age(
11267            &legacy_dir,
11268            orphan,
11269            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11270        );
11271        assert!(
11272            !legacy_dir.join("deadbeef.current").exists(),
11273            "the dead root has no current pointer"
11274        );
11275
11276        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
11277        fs::create_dir_all(&root_keyed_dir).unwrap();
11278
11279        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
11280
11281        assert!(
11282            !legacy_dir.join(orphan).exists(),
11283            "legacy orphan must be reclaimed by the store-wide sweep"
11284        );
11285    }
11286
11287    #[test]
11288    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
11289        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
11290        // everything (min_age = 0) removes the fresh temporary that the real 24h
11291        // threshold spares in the test above. If a mutation to the age check leaves
11292        // the fresh file in place here, the predicate is no longer doing the
11293        // selection work the fresh-survives assertion relies on.
11294        let dir = tempdir().unwrap();
11295        let fresh = "project.g300.1.sqlite.tmp.1.400";
11296        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
11297
11298        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
11299
11300        assert!(
11301            !dir.path().join(fresh).exists(),
11302            "with the age predicate forced open, the fresh temporary is removed"
11303        );
11304    }
11305
11306    #[test]
11307    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
11308        let dir = tempdir().unwrap();
11309        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
11310        // to be swept, plus a live read marker, is generation GC's jurisdiction.
11311        // The orphan sweep must not intersect it.
11312        let generation = write_generation_with_age(
11313            dir.path(),
11314            "project",
11315            400,
11316            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
11317        );
11318        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11319
11320        sweep_orphaned_build_temps(dir.path());
11321
11322        assert!(
11323            dir.path().join(&generation).is_file(),
11324            "completed generation must survive the orphan sweep"
11325        );
11326        assert!(
11327            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
11328            "read marker must survive the orphan sweep"
11329        );
11330    }
11331
11332    #[test]
11333    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
11334        let dir = tempfile::tempdir().unwrap();
11335        let project_key = "project".to_string();
11336        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
11337        let sqlite_path = dir.path().join(&generation);
11338        fs::remove_file(&sqlite_path).unwrap();
11339        let conn = Connection::open(&sqlite_path).unwrap();
11340        let store = CallGraphStore::from_connection(
11341            dir.path().to_path_buf(),
11342            project_key,
11343            sqlite_path,
11344            dir.path().to_path_buf(),
11345            false,
11346            Some(generation.clone()),
11347            None,
11348            None,
11349            conn,
11350        );
11351
11352        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
11353        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
11354
11355        drop(marker);
11356        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
11357    }
11358
11359    #[test]
11360    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
11361        let dir = tempdir().expect("temp dir");
11362        let file = dir.path().join("main.ts");
11363        fs::write(&file, "export function main() {}\n").expect("write fixture");
11364        let store = CallGraphStore::open(
11365            dir.path().join(".store-readiness-cache"),
11366            dir.path().to_path_buf(),
11367        )
11368        .expect("open store");
11369        {
11370            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11371            conn.trace(Some(count_caller_traversal_selects));
11372        }
11373
11374        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11375        assert!(store.indexed_file_count().is_err());
11376        assert!(store.indexed_file_count().is_err());
11377        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
11378
11379        store
11380            .cold_build(std::slice::from_ref(&file))
11381            .expect("cold build");
11382        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11383        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
11384        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
11385        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
11386
11387        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11388        conn.trace(None);
11389    }
11390
11391    #[test]
11392    fn callers_depth_boundary_batches_sqlite_counts() {
11393        const CALLER_COUNT: usize = 1_000;
11394
11395        let dir = tempdir().expect("temp dir");
11396        let file = dir.path().join("main.ts");
11397        let mut source = String::from("export function sharedHotHelper() {}\n");
11398        for index in 0..CALLER_COUNT {
11399            source.push_str(&format!(
11400                "export function caller{index}() {{ sharedHotHelper(); }}\n"
11401            ));
11402        }
11403        fs::write(&file, source).expect("write fixture");
11404
11405        let store = CallGraphStore::open(
11406            dir.path().join(".store-callers-query-fanout"),
11407            dir.path().to_path_buf(),
11408        )
11409        .expect("open store");
11410        store
11411            .cold_build(std::slice::from_ref(&file))
11412            .expect("cold build");
11413
11414        CALLER_QUERY_SELECTS.with(|count| count.set(0));
11415        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
11416        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
11417        {
11418            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11419            conn.trace(Some(count_caller_traversal_selects));
11420        }
11421
11422        let started = Instant::now();
11423        let result = crate::commands::callgraph_store_adapter::callers_result(
11424            &store,
11425            Path::new("main.ts"),
11426            "sharedHotHelper",
11427            1,
11428            true,
11429        )
11430        .expect("callers result");
11431        let elapsed = started.elapsed();
11432
11433        {
11434            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11435            conn.trace(None);
11436        }
11437        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
11438        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
11439        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
11440        eprintln!(
11441            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
11442            result.total_callers,
11443            caller_queries,
11444            boundary_queries,
11445            total_selects,
11446            elapsed.as_secs_f64() * 1_000.0
11447        );
11448
11449        assert_eq!(result.total_callers, CALLER_COUNT);
11450        assert_eq!(caller_queries, 1);
11451        assert_eq!(boundary_queries, 3);
11452        assert_eq!(total_selects, 9);
11453    }
11454
11455    #[test]
11456    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
11457        let dir = tempdir().expect("temp dir");
11458        let file = dir.path().join("main.ts");
11459        fs::write(
11460            &file,
11461            r#"export function topA() {
11462  root();
11463}
11464
11465export function topB() {
11466  root();
11467}
11468
11469export function root() {
11470  leaf();
11471  missing();
11472}
11473
11474export function leaf() {}
11475"#,
11476        )
11477        .expect("write fixture");
11478
11479        let store = CallGraphStore::open(
11480            dir.path().join(".store-depth-boundary-counts"),
11481            dir.path().to_path_buf(),
11482        )
11483        .expect("open store");
11484        store
11485            .cold_build(std::slice::from_ref(&file))
11486            .expect("cold build");
11487
11488        let root = store
11489            .node_for(Path::new("main.ts"), "root")
11490            .expect("root node");
11491        let leaf = store
11492            .node_for(Path::new("main.ts"), "leaf")
11493            .expect("leaf node");
11494
11495        let (full_forward_len, full_direct_len) = {
11496            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
11497            conn.execute(
11498                "INSERT INTO edges (
11499                    edge_id, ref_id, source_node, target_node, target_file,
11500                    target_symbol, kind, line, provenance
11501                 ) VALUES (
11502                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
11503                    ?2, ?3, 'call', 98, ?4
11504                 )",
11505                rusqlite::params![
11506                    &root.node_id,
11507                    &leaf.file,
11508                    &leaf.symbol,
11509                    PROVENANCE_TREESITTER
11510                ],
11511            )
11512            .expect("insert dangling forward edge");
11513            conn.execute(
11514                "INSERT INTO edges (
11515                    edge_id, ref_id, source_node, target_node, target_file,
11516                    target_symbol, kind, line, provenance
11517                 ) VALUES (
11518                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
11519                    ?1, ?2, ?3, 'call', 99, ?4
11520                 )",
11521                rusqlite::params![
11522                    &root.node_id,
11523                    &root.file,
11524                    &root.symbol,
11525                    PROVENANCE_TREESITTER
11526                ],
11527            )
11528            .expect("insert dangling direct-caller edge");
11529
11530            let full_forward_len = forward_calls_for_node(&conn, &root)
11531                .expect("full forward calls")
11532                .len();
11533            let counted_forward_len =
11534                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
11535            assert_eq!(
11536                counted_forward_len, full_forward_len,
11537                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
11538            );
11539
11540            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
11541                .expect("full direct callers");
11542            let full_direct_len = full_direct.len();
11543            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
11544                .expect("counted direct callers");
11545            assert_eq!(
11546                counted_direct_len, full_direct_len,
11547                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
11548            );
11549
11550            let distinct_direct_len = full_direct
11551                .iter()
11552                .map(|site| {
11553                    (
11554                        site.caller.file.clone(),
11555                        site.line,
11556                        site.target_file.clone(),
11557                        site.target_symbol.clone(),
11558                    )
11559                })
11560                .collect::<BTreeSet<_>>()
11561                .len();
11562            let batch_counts = direct_caller_counts_for_tuples(
11563                &conn,
11564                &[
11565                    (root.file.clone(), root.symbol.clone()),
11566                    (root.file.clone(), root.symbol.clone()),
11567                    (leaf.file.clone(), leaf.symbol.clone()),
11568                ],
11569            )
11570            .expect("batched direct-caller counts");
11571            assert_eq!(batch_counts.len(), 2);
11572            assert_eq!(
11573                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
11574                Some(&distinct_direct_len)
11575            );
11576
11577            (full_forward_len, full_direct_len)
11578        };
11579
11580        assert_eq!(
11581            full_forward_len, 2,
11582            "fixture root should have one resolved and one unresolved outgoing call"
11583        );
11584        assert_eq!(
11585            full_direct_len, 2,
11586            "fixture root should have two real direct callers"
11587        );
11588
11589        let tree = store
11590            .call_tree(Path::new("main.ts"), "root", 0)
11591            .expect("call tree");
11592        assert!(tree.depth_limited);
11593        assert_eq!(tree.children.len(), 0);
11594        assert_eq!(
11595            tree.truncated, full_forward_len,
11596            "call_tree depth boundary must report the full forward-call list length"
11597        );
11598
11599        let callers = store
11600            .callers_of(Path::new("main.ts"), "leaf", 0)
11601            .expect("callers");
11602        assert!(callers.depth_limited);
11603        assert_eq!(callers.callers.len(), 1);
11604        assert_eq!(callers.callers[0].caller.symbol, "root");
11605        assert_eq!(
11606            callers.truncated, full_direct_len,
11607            "callers depth boundary must report the full direct-caller list length"
11608        );
11609    }
11610
11611    #[test]
11612    fn source_freshness_matches_cache_collect_for_same_bytes() {
11613        let dir = tempdir().expect("temp dir");
11614        let path = dir.path().join("fixture.ts");
11615        let source = "export function main() { return helper(); }\n";
11616        fs::write(&path, source).expect("write fixture");
11617
11618        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
11619        let actual =
11620            collect_source_freshness(&path, source).expect("collect freshness from source");
11621
11622        assert_eq!(actual, expected);
11623    }
11624
11625    #[test]
11626    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
11627        let root = tempfile::tempdir().unwrap();
11628        let callgraph_dir = tempfile::tempdir().unwrap();
11629        let source_dir = root.path().join("src");
11630        std::fs::create_dir_all(&source_dir).unwrap();
11631        let source = source_dir.join("lib.rs");
11632        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
11633        let files = vec![source.clone()];
11634        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
11635        let old_epoch = epoch.next();
11636        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
11637        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
11638        let old_epoch_flag = epoch.clone();
11639        let old_dir = callgraph_dir.path().to_path_buf();
11640        let old_root = root.path().to_path_buf();
11641        let old_files = files.clone();
11642        let old = std::thread::spawn(move || {
11643            set_cold_build_before_publish_observer(Some(Arc::new(move || {
11644                reached_tx.send(()).unwrap();
11645                release_rx.recv().unwrap();
11646            })));
11647            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
11648                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
11649            });
11650            set_cold_build_before_publish_observer(None);
11651            result
11652        });
11653        // Positive wait: the older build runs a real cold build (git probe +
11654        // SQLite schema init) before the barrier, which can exceed 5s on a
11655        // contended Windows CI runner. Only negative waits stay short.
11656        reached_rx
11657            .recv_timeout(Duration::from_secs(30))
11658            .expect("older build did not reach its publication barrier");
11659
11660        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
11661        let new_epoch = epoch.next();
11662        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
11663            CallGraphStore::cold_build_with_lease(
11664                callgraph_dir.path().to_path_buf(),
11665                root.path().to_path_buf(),
11666                &files,
11667            )
11668        })
11669        .expect("newer build should publish");
11670        drop(new_store);
11671
11672        release_tx.send(()).unwrap();
11673        assert!(matches!(
11674            old.join().unwrap(),
11675            Err(CallGraphStoreError::Superseded)
11676        ));
11677
11678        let current = CallGraphStore::open_readonly(
11679            callgraph_dir.path().to_path_buf(),
11680            root.path().to_path_buf(),
11681        )
11682        .unwrap()
11683        .expect("current callgraph generation");
11684        assert_eq!(
11685            current
11686                .nodes_matching("new_generation_marker")
11687                .unwrap()
11688                .len(),
11689            1
11690        );
11691        assert!(current
11692            .nodes_matching("old_generation_marker")
11693            .unwrap()
11694            .is_empty());
11695    }
11696
11697    #[test]
11698    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
11699        let dir = tempdir().expect("temp dir");
11700        let project_root = dir.path();
11701        let extract = fixture_extract(project_root);
11702        let resolved = fixture_resolved(&extract);
11703
11704        let reference = build_reference_connection(project_root, &extract, &resolved);
11705        let optimized = build_optimized_connection(project_root, &extract, &resolved);
11706
11707        for table in [
11708            "files",
11709            "nodes",
11710            "file_dependencies",
11711            "dispatch_hints",
11712            "refs",
11713            "edges",
11714        ] {
11715            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
11716            // the reference and optimized builds run sequentially and can straddle a
11717            // one-second tick under load, so it is legitimately allowed to differ.
11718            // This mirrors the existing exclusions of `backend_file_state.updated_at`
11719            // and the chunked-vs-unchunked sibling test. The check is for structural
11720            // row equivalence of the optimized bulk insert, not wall-clock equality.
11721            let excluded: &[&str] = if table == "files" {
11722                &["indexed_at"]
11723            } else {
11724                &[]
11725            };
11726            assert_eq!(
11727                table_rows_without(&reference, table, excluded),
11728                table_rows_without(&optimized, table, excluded),
11729                "table `{table}` rows must match apart from wall-clock columns"
11730            );
11731        }
11732        assert_eq!(
11733            backend_state_rows(&reference),
11734            backend_state_rows(&optimized),
11735            "backend freshness rows must match apart from updated_at"
11736        );
11737        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
11738    }
11739
11740    #[test]
11741    fn cold_build_chunked_matches_unchunked_logical_rows() {
11742        let dir = tempdir().expect("temp dir");
11743        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
11744        write_chunked_equivalence_fixture(&project_root);
11745        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11746        assert!(
11747            files.len() > 6,
11748            "fixture should be large enough to split into multiple chunks"
11749        );
11750
11751        let unchunked = CallGraphStore::open(
11752            project_root.join(".store-unchunked"),
11753            project_root.to_path_buf(),
11754        )
11755        .expect("open unchunked store");
11756        let unchunked_stats = unchunked
11757            .cold_build_chunked(&files, 0)
11758            .expect("unchunked cold build");
11759
11760        let chunked = CallGraphStore::open(
11761            project_root.join(".store-chunked"),
11762            project_root.to_path_buf(),
11763        )
11764        .expect("open chunked store");
11765        let chunked_stats = chunked
11766            .cold_build_chunked(&files, 3)
11767            .expect("chunked cold build");
11768
11769        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
11770        assert_eq!(
11771            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
11772            chunked.edge_snapshot().expect("chunked edge snapshot"),
11773            "public edge snapshots must match"
11774        );
11775
11776        let dispatch_edges = {
11777            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
11778            conn.query_row(
11779                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
11780                [],
11781                |row| row.get::<_, i64>(0),
11782            )
11783            .expect("count dispatch edges")
11784        };
11785        assert!(
11786            dispatch_edges > 0,
11787            "fixture must exercise method-dispatch edge insertion"
11788        );
11789
11790        for table in [
11791            "edges",
11792            "refs",
11793            "nodes",
11794            "file_dependencies",
11795            "dispatch_hints",
11796        ] {
11797            assert_eq!(
11798                graph_table_rows(&unchunked, table),
11799                graph_table_rows(&chunked, table),
11800                "chunked cold build must match unchunked rows for {table}"
11801            );
11802        }
11803        assert_eq!(
11804            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
11805            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
11806            "files rows must match apart from indexed_at"
11807        );
11808        assert_eq!(
11809            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
11810            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
11811            "backend freshness rows must match apart from updated_at"
11812        );
11813
11814        let published_dir = project_root.join(".store-published");
11815        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
11816            published_dir.clone(),
11817            project_root.to_path_buf(),
11818            &files,
11819            0,
11820        )
11821        .expect("published unchunked cold build");
11822        assert!(
11823            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
11824                .expect("needs_cold_build after publish"),
11825            "published store should be ready"
11826        );
11827        drop(_published);
11828        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
11829            published_dir,
11830            project_root.to_path_buf(),
11831            &files,
11832            3,
11833        )
11834        .expect("ensure with a different chunk size");
11835        assert!(
11836            rebuild_stats.is_none(),
11837            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
11838        );
11839    }
11840
11841    // Perf A/B bench (not a gate): measures cold_build wall time at a given
11842    // chunk size against a real repo. Driven by env so the same binary can A/B
11843    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
11844    // memory work. Run:
11845    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
11846    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
11847    #[test]
11848    #[ignore]
11849    fn bench_cold_build_chunk() {
11850        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
11851        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
11852            .expect("AFT_PERF_CHUNK")
11853            .parse()
11854            .expect("AFT_PERF_CHUNK must be a non-negative integer");
11855        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
11856        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
11857        let dir = tempdir().expect("temp dir");
11858        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
11859            .expect("open store");
11860        let started = Instant::now();
11861        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
11862        let ms = started.elapsed().as_millis();
11863        println!(
11864            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
11865            stats.files, stats.nodes, stats.refs, stats.edges
11866        );
11867    }
11868
11869    #[test]
11870    fn persisted_workspace_reexport_selects_its_package_dependency() {
11871        let root = tempdir().expect("temp dir");
11872        let dependencies = BTreeSet::from([
11873            "packages/aft-bridge/src/index.ts".to_string(),
11874            "packages/opencode-plugin/src/types.ts".to_string(),
11875        ]);
11876        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
11877
11878        assert_eq!(
11879            stored_dependencies_for_module(
11880                root.path(),
11881                "packages/opencode-plugin/src/shared/bash-hints.ts",
11882                "@cortexkit/aft-bridge",
11883                &dependencies,
11884                &indexed_files,
11885            ),
11886            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
11887        );
11888    }
11889
11890    #[test]
11891    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
11892        let dir = tempdir().expect("temp dir");
11893        let project_root = dir.path();
11894        let files =
11895            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
11896        let index_path = project_root.join("src/index.ts");
11897
11898        let store = CallGraphStore::open(
11899            project_root.join(".store-incremental-barrel"),
11900            project_root.to_path_buf(),
11901        )
11902        .expect("open incremental store");
11903        store.cold_build(&files).expect("initial cold build");
11904
11905        {
11906            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
11907            let tx = conn.transaction().expect("dependency transaction");
11908            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
11909                .expect("dependent refs for barrel");
11910            let selected_ref_ids = dependent_refs
11911                .iter()
11912                .map(|dependent_ref| dependent_ref.ref_id.clone())
11913                .collect::<BTreeSet<_>>();
11914            let mut threaded_ref_ids = BTreeSet::new();
11915            let mut threaded_by_caller = BTreeMap::new();
11916            record_dependent_refs(
11917                &mut threaded_ref_ids,
11918                &mut threaded_by_caller,
11919                dependent_refs,
11920            );
11921            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
11922                .expect("old per-ref caller lookup");
11923
11924            assert_eq!(threaded_ref_ids, selected_ref_ids);
11925            assert_eq!(threaded_by_caller, old_by_caller);
11926            for consumer in [
11927                "src/consumer_a.ts",
11928                "src/consumer_b.ts",
11929                "src/consumer_c.ts",
11930            ] {
11931                assert!(
11932                    threaded_by_caller.contains_key(consumer),
11933                    "barrel edit should select dependent refs from {consumer}"
11934                );
11935            }
11936        }
11937
11938        fs::write(
11939            &index_path,
11940            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
11941        )
11942        .expect("edit barrel");
11943        let stats = store
11944            .refresh_files(std::slice::from_ref(&index_path))
11945            .expect("incremental refresh");
11946        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
11947        assert!(
11948            stats.dependency_selected_refs > 0,
11949            "barrel surface edit should select dependent refs"
11950        );
11951
11952        let cold_store = CallGraphStore::open(
11953            project_root.join(".store-cold-barrel"),
11954            project_root.to_path_buf(),
11955        )
11956        .expect("open cold rebuild store");
11957        cold_store
11958            .cold_build(&files)
11959            .expect("comparison cold build");
11960
11961        for table in [
11962            "nodes",
11963            "refs",
11964            "file_dependencies",
11965            "edges",
11966            "dispatch_hints",
11967        ] {
11968            assert_eq!(
11969                graph_table_rows(&store, table),
11970                graph_table_rows(&cold_store, table),
11971                "incremental refresh {table} rows must match cold rebuild"
11972            );
11973        }
11974
11975        let consumer_path = project_root.join("src/consumer_a.ts");
11976        fs::write(
11977            &consumer_path,
11978            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
11979        )
11980        .expect("edit barrel consumer");
11981        store
11982            .refresh_files(std::slice::from_ref(&consumer_path))
11983            .expect("refresh consumer through unchanged barrel");
11984        cold_store
11985            .cold_build(&files)
11986            .expect("comparison cold rebuild after consumer refresh");
11987        for table in [
11988            "nodes",
11989            "refs",
11990            "file_dependencies",
11991            "edges",
11992            "dispatch_hints",
11993        ] {
11994            assert_eq!(
11995                graph_table_rows(&store, table),
11996                graph_table_rows(&cold_store, table),
11997                "refresh through a persisted barrel must preserve cold-build {table} rows"
11998            );
11999        }
12000    }
12001
12002    fn build_reference_connection(
12003        project_root: &Path,
12004        extract: &FileExtract,
12005        resolved: &ResolvedRef,
12006    ) -> Connection {
12007        let mut conn = Connection::open_in_memory().expect("open reference db");
12008        configure_build_connection(&conn).expect("configure reference db");
12009        initialize_schema(&conn).expect("initialize reference schema");
12010        {
12011            let tx = conn.transaction().expect("reference transaction");
12012            clear_tables(&tx).expect("reference clear");
12013            insert_meta(&tx).expect("reference meta");
12014            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
12015            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
12016            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12017                .expect("reference dispatch edges");
12018            assert_eq!(supplemental, 0);
12019            tx.commit().expect("reference commit");
12020        }
12021        conn
12022    }
12023
12024    fn build_optimized_connection(
12025        project_root: &Path,
12026        extract: &FileExtract,
12027        resolved: &ResolvedRef,
12028    ) -> Connection {
12029        let mut conn = Connection::open_in_memory().expect("open optimized db");
12030        configure_build_connection(&conn).expect("configure optimized db");
12031        initialize_schema(&conn).expect("initialize optimized schema");
12032        {
12033            let tx = conn.transaction().expect("optimized transaction");
12034            clear_tables(&tx).expect("optimized clear");
12035            insert_meta(&tx).expect("optimized meta");
12036            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
12037            {
12038                let workspace_root = project_root.display().to_string();
12039                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
12040                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
12041                    .expect("optimized file extract");
12042                insert_resolved_ref_prepared(&mut inserts, resolved)
12043                    .expect("optimized resolved ref");
12044            }
12045            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
12046            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
12047                .expect("optimized dispatch edges");
12048            assert_eq!(supplemental, 0);
12049            tx.commit().expect("optimized commit");
12050        }
12051        conn
12052    }
12053
12054    fn fixture_extract(_project_root: &Path) -> FileExtract {
12055        let rel_path = "src/main.ts".to_string();
12056        let target_path = "src/helper.ts".to_string();
12057        let node = NodeRecord {
12058            id: "node-main".to_string(),
12059            file_path: rel_path.clone(),
12060            name: "main".to_string(),
12061            scoped_name: "main".to_string(),
12062            kind: "function".to_string(),
12063            range: Range {
12064                start_line: 0,
12065                start_col: 0,
12066                end_line: 0,
12067                end_col: 32,
12068            },
12069            range_ordinal: 0,
12070            signature: Some("export function main()".to_string()),
12071            exported: true,
12072            is_default_export: false,
12073            is_type_like: false,
12074            is_callgraph_entry_point: true,
12075        };
12076        let mut dependencies = BTreeSet::new();
12077        dependencies.insert(target_path.clone());
12078        let raw_ref = RawRef {
12079            ref_id: "ref-main-helper".to_string(),
12080            caller_node: Some(node.id.clone()),
12081            caller_symbol: Some(node.scoped_name.clone()),
12082            caller_file: rel_path.clone(),
12083            kind: "call".to_string(),
12084            short_name: Some("helper".to_string()),
12085            full_ref: Some("helper".to_string()),
12086            module_path: None,
12087            import_kind: None,
12088            local_name: Some("helper".to_string()),
12089            requested_name: Some("helper".to_string()),
12090            namespace_alias: None,
12091            wildcard: false,
12092            line: 1,
12093            byte_start: 24,
12094            byte_end: 32,
12095            dependencies,
12096        };
12097        FileExtract {
12098            rel_path,
12099            freshness: FileFreshness {
12100                mtime: UNIX_EPOCH + Duration::from_secs(123),
12101                size: 40,
12102                content_hash: cache_freshness::hash_bytes(b"fixture source"),
12103            },
12104            lang: LangId::TypeScript,
12105            data: FileCallData {
12106                calls_by_symbol: HashMap::new(),
12107                exported_symbols: Vec::new(),
12108                symbol_metadata: HashMap::new(),
12109                default_export_symbol: None,
12110                import_block: ImportBlock::empty(),
12111                lang: LangId::TypeScript,
12112            },
12113            nodes: vec![node.clone()],
12114            raw_refs: vec![raw_ref],
12115            dispatch_hints: vec![DispatchHint {
12116                id: "dispatch-main-helper".to_string(),
12117                method_name: "helper".to_string(),
12118                caller_node: node.id,
12119                file: "src/main.ts".to_string(),
12120                line: 1,
12121                byte_start: 24,
12122                byte_end: 32,
12123            }],
12124            surface_fingerprint: "surface".to_string(),
12125        }
12126    }
12127
12128    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
12129        let raw = extract.raw_refs[0].clone();
12130        let mut dependencies = raw.dependencies.clone();
12131        dependencies.insert("src/helper.ts".to_string());
12132        ResolvedRef {
12133            edge: Some(EdgeRecord {
12134                edge_id: "edge-main-helper".to_string(),
12135                source_node: raw.caller_node.clone().expect("caller node"),
12136                target_node: Some("node-helper".to_string()),
12137                target_file: "src/helper.ts".to_string(),
12138                target_symbol: "helper".to_string(),
12139                kind: "call".to_string(),
12140                line: raw.line,
12141            }),
12142            raw,
12143            status: "resolved".to_string(),
12144            target_node: Some("node-helper".to_string()),
12145            target_file: Some("src/helper.ts".to_string()),
12146            target_symbol: Some("helper".to_string()),
12147            dependencies,
12148        }
12149    }
12150
12151    fn write_chunked_equivalence_fixture(project_root: &Path) {
12152        let ts_dir = project_root.join("ts");
12153        fs::create_dir_all(&ts_dir).expect("create ts dir");
12154        fs::write(
12155            ts_dir.join("leaf.ts"),
12156            "export function leaf(value: number) {\n  return value + 1;\n}\n",
12157        )
12158        .expect("write ts leaf");
12159        fs::write(
12160            ts_dir.join("mid.ts"),
12161            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
12162        )
12163        .expect("write ts mid");
12164        fs::write(
12165            ts_dir.join("entry.ts"),
12166            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
12167        )
12168        .expect("write ts entry");
12169        fs::write(
12170            ts_dir.join("worker.ts"),
12171            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
12172        )
12173        .expect("write ts worker");
12174        for idx in 0..4 {
12175            fs::write(
12176                ts_dir.join(format!("extra_{idx}.ts")),
12177                format!(
12178                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
12179                ),
12180            )
12181            .expect("write ts extra");
12182        }
12183
12184        let rust_dir = project_root.join("src");
12185        let commands_dir = rust_dir.join("commands");
12186        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
12187        fs::write(
12188            rust_dir.join("context.rs"),
12189            r#"pub struct AppContext;
12190
12191impl AppContext {
12192    pub fn callgraph_store_for_ops(&self) -> usize {
12193        1
12194    }
12195}
12196"#,
12197        )
12198        .expect("write rust context");
12199        fs::write(
12200            rust_dir.join("lib.rs"),
12201            "pub mod context;\npub mod commands;\n",
12202        )
12203        .expect("write rust lib");
12204        fs::write(
12205            commands_dir.join("mod.rs"),
12206            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
12207        )
12208        .expect("write rust commands mod");
12209        for name in ["callers", "impact", "trace_to"] {
12210            fs::write(
12211                commands_dir.join(format!("{name}.rs")),
12212                format!(
12213                    r#"use crate::context::AppContext;
12214
12215pub fn handle_{name}(ctx: &AppContext) -> usize {{
12216    ctx.callgraph_store_for_ops()
12217}}
12218"#
12219                ),
12220            )
12221            .expect("write rust command");
12222        }
12223    }
12224
12225    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
12226        let src_dir = project_root.join("src");
12227        fs::create_dir_all(&src_dir).expect("create src dir");
12228
12229        let target_path = src_dir.join("target.ts");
12230        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
12231            .expect("write target");
12232
12233        let index_path = src_dir.join("index.ts");
12234        fs::write(&index_path, barrel_source).expect("write barrel");
12235
12236        let mut files = vec![target_path, index_path];
12237        for (file_name, function_name) in [
12238            ("consumer_a.ts", "consumerA"),
12239            ("consumer_b.ts", "consumerB"),
12240            ("consumer_c.ts", "consumerC"),
12241        ] {
12242            let path = src_dir.join(file_name);
12243            fs::write(
12244                &path,
12245                format!(
12246                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
12247                ),
12248            )
12249            .expect("write consumer");
12250            files.push(path);
12251        }
12252        files
12253    }
12254
12255    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
12256        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12257        table_rows(&conn, table)
12258    }
12259
12260    fn graph_table_rows_without(
12261        store: &CallGraphStore,
12262        table: &str,
12263        excluded_columns: &[&str],
12264    ) -> Vec<String> {
12265        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
12266        table_rows_without(&conn, table, excluded_columns)
12267    }
12268
12269    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
12270        table_rows_without(conn, table, &[])
12271    }
12272
12273    fn table_rows_without(
12274        conn: &Connection,
12275        table: &str,
12276        excluded_columns: &[&str],
12277    ) -> Vec<String> {
12278        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
12279        let columns: Vec<String> = conn
12280            .prepare(&format!("PRAGMA table_info({table})"))
12281            .expect("prepare table_info")
12282            .query_map([], |row| row.get::<_, String>(1))
12283            .expect("query table_info")
12284            .collect::<std::result::Result<Vec<String>, _>>()
12285            .expect("collect columns")
12286            .into_iter()
12287            .filter(|column| !excluded_columns.contains(column.as_str()))
12288            .collect();
12289        let sql = format!(
12290            "SELECT {} FROM {table} ORDER BY {}",
12291            columns.join(", "),
12292            columns.join(", ")
12293        );
12294        conn.prepare(&sql)
12295            .expect("prepare table rows")
12296            .query_map([], |row| row_to_strings(row, columns.len()))
12297            .expect("query table rows")
12298            .collect::<std::result::Result<_, _>>()
12299            .expect("collect table rows")
12300    }
12301
12302    fn assert_cold_build_stats_match_except_elapsed(
12303        expected: &ColdBuildStats,
12304        actual: &ColdBuildStats,
12305    ) {
12306        assert_eq!(actual.files, expected.files, "file counts must match");
12307        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
12308        assert_eq!(actual.refs, expected.refs, "ref counts must match");
12309        assert_eq!(actual.edges, expected.edges, "edge counts must match");
12310        assert_eq!(
12311            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
12312            expected
12313                .failed_files
12314                .iter()
12315                .cloned()
12316                .collect::<BTreeSet<_>>(),
12317            "failed file sets must match"
12318        );
12319    }
12320
12321    fn backend_state_rows(conn: &Connection) -> Vec<String> {
12322        conn.prepare(
12323            "SELECT backend, workspace_root, file_path, content_hash, status
12324             FROM backend_file_state
12325             ORDER BY backend, workspace_root, file_path, content_hash, status",
12326        )
12327        .expect("prepare backend rows")
12328        .query_map([], |row| row_to_strings(row, 5))
12329        .expect("query backend rows")
12330        .collect::<std::result::Result<_, _>>()
12331        .expect("collect backend rows")
12332    }
12333
12334    fn secondary_indexes(conn: &Connection) -> Vec<String> {
12335        let mut indexes = Vec::new();
12336        for table in [
12337            "files",
12338            "nodes",
12339            "refs",
12340            "file_dependencies",
12341            "edges",
12342            "dispatch_hints",
12343            "type_ref_names",
12344            "backend_file_state",
12345            "meta",
12346        ] {
12347            let sql = format!("PRAGMA index_list({table})");
12348            let mut stmt = conn.prepare(&sql).expect("prepare index list");
12349            let rows = stmt
12350                .query_map([], |row| row.get::<_, String>(1))
12351                .expect("query index list");
12352            for name in rows {
12353                let name = name.expect("index name");
12354                if name.starts_with("idx_") {
12355                    indexes.push(format!("{table}:{name}"));
12356                }
12357            }
12358        }
12359        indexes.sort();
12360        indexes
12361    }
12362
12363    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
12364        let mut values = Vec::with_capacity(len);
12365        for index in 0..len {
12366            let value = row.get_ref(index)?;
12367            values.push(match value {
12368                rusqlite::types::ValueRef::Null => "NULL".to_string(),
12369                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
12370                rusqlite::types::ValueRef::Real(value) => value.to_string(),
12371                rusqlite::types::ValueRef::Text(value) => {
12372                    String::from_utf8_lossy(value).into_owned()
12373                }
12374                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
12375            });
12376        }
12377        Ok(values.join("\u{1f}"))
12378    }
12379}
12380
12381#[cfg(test)]
12382mod rust_resolution_tests {
12383    use super::*;
12384    use crate::inspect::job::CallgraphSnapshot;
12385    use std::fs;
12386    use tempfile::tempdir;
12387
12388    #[test]
12389    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
12390        let dir = tempdir().expect("tempdir");
12391        let root = dir.path();
12392        write_rust_manifest(root, "scoped-alias-fixture");
12393        write_file(
12394            root,
12395            "src/lib.rs",
12396            r#"pub mod finalization_contract;
12397
12398pub fn run_alias() {
12399    use crate::finalization_contract as fc;
12400    fc::check_mason_contract();
12401}
12402"#,
12403        );
12404        write_file(
12405            root,
12406            "src/finalization_contract.rs",
12407            r#"pub fn check_mason_contract() {}
12408fn planted_dead() {}
12409"#,
12410        );
12411
12412        let (store, snapshot) = cold_build_twice(root);
12413        assert_direct_caller(
12414            &store,
12415            "src/finalization_contract.rs",
12416            "check_mason_contract",
12417            "src/lib.rs",
12418            "run_alias",
12419        );
12420        assert_projected_call(
12421            root,
12422            &snapshot,
12423            "src/finalization_contract.rs",
12424            "check_mason_contract",
12425        );
12426        assert_no_projected_call(
12427            root,
12428            &snapshot,
12429            "src/finalization_contract.rs",
12430            "planted_dead",
12431        );
12432        assert!(
12433            store
12434                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
12435                .expect("planted dead callers")
12436                .is_empty(),
12437            "planted-dead guard should stay without callers"
12438        );
12439    }
12440
12441    #[test]
12442    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
12443        let dir = tempdir().expect("tempdir");
12444        let root = dir.path();
12445        write_rust_manifest(root, "inline-module-fixture");
12446        write_file(
12447            root,
12448            "src/lib.rs",
12449            r#"mod work_graph { fn operations() {} }
12450mod manifest { fn operations() {} }
12451mod audit { fn operations() {} }
12452mod dispatch { fn operations() {} }
12453mod finalization { fn operations() {} }
12454
12455pub fn run_inline_operations() {
12456    work_graph::operations();
12457    manifest::operations();
12458    audit::operations();
12459    dispatch::operations();
12460    finalization::operations();
12461}
12462
12463fn planted_dead() {}
12464"#,
12465        );
12466
12467        let (store, snapshot) = cold_build_twice(root);
12468        for module in [
12469            "work_graph",
12470            "manifest",
12471            "audit",
12472            "dispatch",
12473            "finalization",
12474        ] {
12475            assert_direct_caller(
12476                &store,
12477                "src/lib.rs",
12478                &format!("{module}::operations"),
12479                "src/lib.rs",
12480                "run_inline_operations",
12481            );
12482        }
12483        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
12484        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12485    }
12486
12487    #[test]
12488    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
12489        let dir = tempdir().expect("tempdir");
12490        let root = dir.path();
12491        fs::write(
12492            root.join("Cargo.toml"),
12493            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
12494        )
12495        .expect("write workspace manifest");
12496        write_file(
12497            root,
12498            "crates/but-action/Cargo.toml",
12499            r#"[package]
12500name = "but-action"
12501version = "0.1.0"
12502edition = "2021"
12503"#,
12504        );
12505        write_file(
12506            root,
12507            "crates/but-action/src/lib.rs",
12508            "mod action;\npub use action::{list_actions};\n",
12509        );
12510        write_file(
12511            root,
12512            "crates/but-action/src/action.rs",
12513            "pub fn list_actions() {}\nfn planted_dead() {}\n",
12514        );
12515        write_file(
12516            root,
12517            "crates/app/Cargo.toml",
12518            r#"[package]
12519name = "app"
12520version = "0.1.0"
12521edition = "2021"
12522"#,
12523        );
12524        write_file(
12525            root,
12526            "crates/app/src/lib.rs",
12527            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
12528        );
12529
12530        let (store, snapshot) = cold_build_twice(root);
12531        assert_direct_caller(
12532            &store,
12533            "crates/but-action/src/action.rs",
12534            "list_actions",
12535            "crates/app/src/lib.rs",
12536            "run_actions",
12537        );
12538        assert!(
12539            store
12540                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
12541                .expect("lib reexport callers")
12542                .is_empty(),
12543            "call should target the reexported source function, not lib.rs"
12544        );
12545        assert_projected_call(
12546            root,
12547            &snapshot,
12548            "crates/but-action/src/action.rs",
12549            "list_actions",
12550        );
12551        assert_no_projected_call(
12552            root,
12553            &snapshot,
12554            "crates/but-action/src/action.rs",
12555            "planted_dead",
12556        );
12557    }
12558
12559    #[test]
12560    fn rust_generic_self_turbofish_method_dispatch_resolves() {
12561        let dir = tempdir().expect("tempdir");
12562        let root = dir.path();
12563        write_rust_manifest(root, "generic-self-fixture");
12564        write_file(
12565            root,
12566            "src/lib.rs",
12567            r#"pub struct Matcher;
12568
12569impl Matcher {
12570    pub fn run(&self) -> bool {
12571        self.fuzzy_match_optimal::<usize>("needle")
12572    }
12573
12574    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
12575        let _ = std::marker::PhantomData::<T>;
12576        true
12577    }
12578
12579    fn planted_dead(&self) {}
12580}
12581
12582pub fn entry() -> bool {
12583    let matcher = Matcher;
12584    matcher.run()
12585}
12586"#,
12587        );
12588
12589        let (store, snapshot) = cold_build_twice(root);
12590        assert_direct_caller(
12591            &store,
12592            "src/lib.rs",
12593            "Matcher::fuzzy_match_optimal",
12594            "src/lib.rs",
12595            "Matcher::run",
12596        );
12597        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
12598        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
12599    }
12600
12601    #[test]
12602    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
12603        let dir = tempdir().expect("tempdir");
12604        let root = dir.path();
12605        write_rust_manifest(root, "manifest-operations-fixture");
12606        write_file(
12607            root,
12608            "src/main.rs",
12609            r#"mod dispatch;
12610use dispatch::{manifest_operations};
12611
12612fn main() {
12613    manifest_operations();
12614}
12615"#,
12616        );
12617        write_file(
12618            root,
12619            "src/dispatch.rs",
12620            r#"mod work_graph { fn operations() {} }
12621mod manifest { fn operations() {} }
12622mod audit { fn operations() {} }
12623mod descriptor { fn operations() {} }
12624mod writer { fn operations() {} }
12625
12626pub fn manifest_operations() {
12627    manifest::operations();
12628}
12629
12630pub fn work_graph_operations() {
12631    work_graph::operations();
12632}
12633
12634pub fn audit_operations() {
12635    audit::operations();
12636}
12637
12638pub fn descriptor_operations() {
12639    descriptor::operations();
12640}
12641
12642pub fn writer_operations() {
12643    writer::operations();
12644}
12645
12646fn planted_dead() {}
12647"#,
12648        );
12649
12650        let (store, snapshot) = cold_build_twice(root);
12651        assert_direct_caller(
12652            &store,
12653            "src/dispatch.rs",
12654            "manifest_operations",
12655            "src/main.rs",
12656            "main",
12657        );
12658        assert_direct_caller(
12659            &store,
12660            "src/dispatch.rs",
12661            "manifest::operations",
12662            "src/dispatch.rs",
12663            "manifest_operations",
12664        );
12665        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
12666        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
12667        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
12668    }
12669
12670    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
12671        let files = rust_files(root);
12672        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
12673            .expect("open first store");
12674        first.cold_build(&files).expect("first cold build");
12675        let first_snapshot =
12676            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
12677
12678        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
12679            .expect("open second store");
12680        second.cold_build(&files).expect("second cold build");
12681        let second_snapshot =
12682            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
12683
12684        assert_eq!(
12685            projection_rows(&first_snapshot),
12686            projection_rows(&second_snapshot),
12687            "cold-build projection should be deterministic"
12688        );
12689        (first, first_snapshot)
12690    }
12691
12692    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
12693        let mut rows = Vec::new();
12694        for export in &snapshot.exported_symbols {
12695            rows.push(format!(
12696                "export\t{}\t{}\t{}\t{}",
12697                export.file.display(),
12698                export.symbol,
12699                export.kind,
12700                export.line
12701            ));
12702        }
12703        for call in &snapshot.outbound_calls {
12704            rows.push(format!(
12705                "call\t{}\t{}\t{}\t{}\t{}",
12706                call.caller_file.display(),
12707                call.caller_symbol,
12708                call.target,
12709                call.line,
12710                call.provenance
12711            ));
12712        }
12713        for file in &snapshot.entry_points {
12714            rows.push(format!("entry_file\t{}", file.display()));
12715        }
12716        for (file, symbols) in &snapshot.entry_point_symbols {
12717            for symbol in symbols {
12718                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
12719            }
12720        }
12721        rows.sort();
12722        rows
12723    }
12724
12725    fn assert_direct_caller(
12726        store: &CallGraphStore,
12727        target_rel: &str,
12728        target_symbol: &str,
12729        caller_rel: &str,
12730        caller_symbol: &str,
12731    ) {
12732        let callers = store
12733            .direct_callers_of(Path::new(target_rel), target_symbol)
12734            .unwrap_or_else(|error| {
12735                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
12736            });
12737        assert!(
12738            callers.iter().any(|site| {
12739                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
12740            }),
12741            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
12742        );
12743    }
12744
12745    fn assert_projected_call(
12746        root: &Path,
12747        snapshot: &CallgraphSnapshot,
12748        target_rel: &str,
12749        symbol: &str,
12750    ) {
12751        let target = projected_target(root, target_rel, symbol);
12752        assert!(
12753            snapshot.outbound_calls.iter().any(|call| {
12754                call.target == target
12755                    || call.target.starts_with(&format!(
12756                        "{target}{}",
12757                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12758                    ))
12759            }),
12760            "expected projected call to {target}; calls: {:#?}",
12761            snapshot.outbound_calls
12762        );
12763    }
12764
12765    fn assert_no_projected_call(
12766        root: &Path,
12767        snapshot: &CallgraphSnapshot,
12768        target_rel: &str,
12769        symbol: &str,
12770    ) {
12771        let target = projected_target(root, target_rel, symbol);
12772        assert!(
12773            snapshot.outbound_calls.iter().all(|call| {
12774                call.target != target
12775                    && !call.target.starts_with(&format!(
12776                        "{target}{}",
12777                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
12778                    ))
12779            }),
12780            "did not expect projected call to {target}; calls: {:#?}",
12781            snapshot.outbound_calls
12782        );
12783    }
12784
12785    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
12786        // Projection targets carry the normalized (verbatim-stripped)
12787        // canonical form; bare fs::canonicalize diverges on Windows.
12788        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
12789        format!("{}::{symbol}", path.display())
12790    }
12791
12792    fn write_rust_manifest(root: &Path, name: &str) {
12793        write_file(
12794            root,
12795            "Cargo.toml",
12796            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
12797        );
12798    }
12799
12800    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
12801        let path = root.join(rel_path);
12802        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
12803        fs::write(&path, source).expect("write fixture file");
12804        path
12805    }
12806
12807    fn rust_files(root: &Path) -> Vec<PathBuf> {
12808        let mut files = Vec::new();
12809        collect_rust_files(root, &mut files);
12810        files.sort();
12811        files
12812    }
12813
12814    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
12815        for entry in fs::read_dir(dir).expect("read fixture dir") {
12816            let entry = entry.expect("read fixture entry");
12817            let path = entry.path();
12818            if path.is_dir() {
12819                let name = path
12820                    .file_name()
12821                    .and_then(|name| name.to_str())
12822                    .unwrap_or("");
12823                if !name.starts_with(".store") {
12824                    collect_rust_files(&path, files);
12825                }
12826            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
12827                files.push(path);
12828            }
12829        }
12830    }
12831}
12832
12833#[cfg(test)]
12834mod build_pool_tests {
12835    use super::build_pool_size;
12836
12837    #[test]
12838    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
12839        let size = build_pool_size();
12840        // Never zero, never the full core count, never above the 8 cap — this is
12841        // the starvation guard for the cold-build's all-cores tree-sitter pass.
12842        assert!(size >= 1, "pool size must be at least 1");
12843        assert!(size <= 8, "pool size must be capped at 8, got {size}");
12844
12845        let cores = std::thread::available_parallelism()
12846            .map(|p| p.get())
12847            .unwrap_or(1);
12848        let expected = cores.div_ceil(2).clamp(1, 8);
12849        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
12850    }
12851}
12852
12853#[cfg(test)]
12854mod reexport_resolution_tests {
12855    use super::*;
12856
12857    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
12858        ProjectIndex {
12859            project_root: PathBuf::from("/fixture"),
12860            files: files.into_iter().collect(),
12861            caller_data: HashMap::new(),
12862            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
12863        }
12864    }
12865
12866    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
12867        DbFileIndex {
12868            lang: None,
12869            exports: HashSet::new(),
12870            default_export: None,
12871            export_aliases: HashMap::new(),
12872            node_by_scoped: HashMap::new(),
12873            node_by_bare: HashMap::new(),
12874            module_targets: HashMap::new(),
12875            reexports: reexport_targets
12876                .iter()
12877                .map(|target| ReexportIndex {
12878                    target_file: Some((*target).to_string()),
12879                    named: HashMap::new(),
12880                    wildcard: true,
12881                })
12882                .collect(),
12883        }
12884    }
12885
12886    /// A dense wildcard re-export cycle (barrel files re-exporting each
12887    /// other) must resolve in O(files), not O(branching^depth). Without the
12888    /// resolver's memoization, resolving a MISSING symbol through this
12889    /// 12-file complete digraph explores ~11^16 paths and this test never
12890    /// finishes: the depth cap bounds path length, not path count, and one
12891    /// such resolution can pin a worker thread at 100% CPU indefinitely.
12892    #[test]
12893    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
12894        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
12895        let files = names
12896            .iter()
12897            .map(|name| {
12898                let targets: Vec<&str> = names
12899                    .iter()
12900                    .filter(|other| *other != name)
12901                    .map(String::as_str)
12902                    .collect();
12903                (name.clone(), barrel_file(&targets))
12904            })
12905            .collect();
12906        let index = barrel_index(files);
12907
12908        assert_eq!(
12909            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
12910            None
12911        );
12912    }
12913
12914    /// Depth-dominance counterexample: the walk first reaches `shared` down a
12915    /// 16-hop chain (no budget left for its leaf), then reaches it again
12916    /// directly at depth 1. Plain visited-set pruning would skip the second
12917    /// visit and lose a resolution the capped resolver finds; the
12918    /// depth-dominance memo revisits because the second arrival is shallower.
12919    #[test]
12920    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
12921        let mut leaf = barrel_file(&[]);
12922        leaf.exports.insert("deep_symbol".to_string());
12923        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
12924        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
12925        // entry's SECOND reexport goes straight to shared.
12926        files.push((
12927            "src/entry.ts".to_string(),
12928            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
12929        ));
12930        for i in 0..15 {
12931            let next = if i == 14 {
12932                "src/shared.ts".to_string()
12933            } else {
12934                format!("src/chain{}.ts", i + 1)
12935            };
12936            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
12937        }
12938        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
12939        files.push(("src/leaf.ts".to_string(), leaf));
12940        let index = barrel_index(files);
12941
12942        assert_eq!(
12943            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
12944            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
12945            "a shallower re-visit must not be pruned by a deeper capped visit"
12946        );
12947    }
12948
12949    #[test]
12950    fn symbol_reachable_through_reexport_cycle_still_resolves() {
12951        let mut leaf = barrel_file(&[]);
12952        leaf.exports.insert("real_symbol".to_string());
12953        let index = barrel_index(vec![
12954            (
12955                "src/a.ts".to_string(),
12956                barrel_file(&["src/b.ts", "src/a.ts"]),
12957            ),
12958            (
12959                "src/b.ts".to_string(),
12960                barrel_file(&["src/a.ts", "src/leaf.ts"]),
12961            ),
12962            ("src/leaf.ts".to_string(), leaf),
12963        ]);
12964
12965        assert_eq!(
12966            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
12967            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
12968        );
12969    }
12970}
12971
12972#[cfg(test)]
12973mod method_dispatch_inference_tests {
12974    use super::*;
12975    use std::fs;
12976    use tempfile::tempdir;
12977
12978    #[test]
12979    fn java_field_receiver_type_selects_declared_class_method() {
12980        let source = r#"class EntryPoint {
12981    private UserService userService;
12982
12983    void handle() {
12984        userService.find();
12985    }
12986}
12987
12988class UserService {
12989    void find() {}
12990}
12991
12992class AuditService {
12993    void find() {}
12994}
12995"#;
12996        let dir = tempdir().expect("temp dir");
12997        let root = dir.path();
12998        write_fixture(root, "src/EntryPoint.java", source);
12999        let reference = reference(
13000            "java",
13001            "src/EntryPoint.java",
13002            "EntryPoint::handle",
13003            "userService",
13004            "find",
13005            line_of(source, "userService.find()"),
13006        );
13007        let mut cache = DispatchSourceCache::new();
13008
13009        let receiver_type =
13010            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
13011        assert_eq!(receiver_type, "UserService");
13012
13013        let candidates = vec![
13014            method_candidate("audit", "AuditService::find"),
13015            method_candidate("user", "UserService::find"),
13016        ];
13017        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
13018            .expect("type candidate");
13019        assert_eq!(selected.scoped_name, "UserService::find");
13020
13021        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
13022        assert!(
13023            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
13024        );
13025    }
13026
13027    #[test]
13028    fn kotlin_property_and_local_value_types_are_inferred() {
13029        let source = r#"class Handler {
13030    private val auditService: AuditService = AuditService()
13031
13032    fun handle() {
13033        auditService.find()
13034        val userService: UserService = UserService()
13035        userService.find()
13036        val billingService = BillingService()
13037        billingService.find()
13038    }
13039}
13040
13041class UserService { fun find() {} }
13042class AuditService { fun find() {} }
13043class BillingService { fun find() {} }
13044"#;
13045        let dir = tempdir().expect("temp dir");
13046        let root = dir.path();
13047        write_fixture(root, "src/Handler.kt", source);
13048        let mut cache = DispatchSourceCache::new();
13049
13050        let audit_ref = reference(
13051            "kotlin",
13052            "src/Handler.kt",
13053            "Handler::handle",
13054            "auditService",
13055            "find",
13056            line_of(source, "auditService.find()"),
13057        );
13058        assert_eq!(
13059            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
13060            Some("AuditService")
13061        );
13062
13063        let user_ref = reference(
13064            "kotlin",
13065            "src/Handler.kt",
13066            "Handler::handle",
13067            "userService",
13068            "find",
13069            line_of(source, "userService.find()"),
13070        );
13071        assert_eq!(
13072            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
13073            Some("UserService")
13074        );
13075
13076        let billing_ref = reference(
13077            "kotlin",
13078            "src/Handler.kt",
13079            "Handler::handle",
13080            "billingService",
13081            "find",
13082            line_of(source, "billingService.find()"),
13083        );
13084        assert_eq!(
13085            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
13086            Some("BillingService")
13087        );
13088    }
13089
13090    #[test]
13091    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
13092        let source = r#"struct Foo { void run(); };
13093struct PointerFoo { void run(); };
13094struct FactoryFoo { void run(); };
13095FactoryFoo makeFactoryFoo();
13096
13097void handle() {
13098    Foo foo;
13099    foo.run();
13100    PointerFoo* pointerFoo = nullptr;
13101    pointerFoo->run();
13102    auto factoryFoo = makeFactoryFoo();
13103    factoryFoo.run();
13104}
13105"#;
13106        let dir = tempdir().expect("temp dir");
13107        let root = dir.path();
13108        write_fixture(root, "src/fixture.cpp", source);
13109        let mut cache = DispatchSourceCache::new();
13110
13111        let foo_ref = reference(
13112            "cpp",
13113            "src/fixture.cpp",
13114            "handle",
13115            "foo",
13116            "run",
13117            line_of(source, "foo.run()"),
13118        );
13119        assert_eq!(
13120            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
13121            Some("Foo")
13122        );
13123
13124        let pointer_ref = reference(
13125            "cpp",
13126            "src/fixture.cpp",
13127            "handle",
13128            "pointerFoo",
13129            "run",
13130            line_of(source, "pointerFoo->run()"),
13131        );
13132        assert_eq!(
13133            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
13134            Some("PointerFoo")
13135        );
13136
13137        let factory_ref = reference(
13138            "cpp",
13139            "src/fixture.cpp",
13140            "handle",
13141            "factoryFoo",
13142            "run",
13143            line_of(source, "factoryFoo.run()"),
13144        );
13145        assert_eq!(
13146            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
13147            Some("FactoryFoo")
13148        );
13149    }
13150
13151    #[test]
13152    fn unknown_java_receiver_still_uses_name_match_fallback() {
13153        let source = r#"class EntryPoint {
13154    void handle() {
13155        service.runSpecial();
13156    }
13157}
13158
13159class OnlyService {
13160    void runSpecial() {}
13161}
13162"#;
13163        let dir = tempdir().expect("temp dir");
13164        let root = dir.path();
13165        write_fixture(root, "src/EntryPoint.java", source);
13166        let reference = reference(
13167            "java",
13168            "src/EntryPoint.java",
13169            "EntryPoint::handle",
13170            "service",
13171            "runSpecial",
13172            line_of(source, "service.runSpecial()"),
13173        );
13174        let mut cache = DispatchSourceCache::new();
13175
13176        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
13177        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
13178        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
13179        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
13180    }
13181
13182    fn reference(
13183        lang: &str,
13184        caller_file: &str,
13185        caller_symbol: &str,
13186        receiver: &str,
13187        method_name: &str,
13188        line: u32,
13189    ) -> NameMatchRef {
13190        NameMatchRef {
13191            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
13192            caller_node: format!("{caller_symbol}:node"),
13193            caller_file: caller_file.to_string(),
13194            caller_symbol: caller_symbol.to_string(),
13195            caller_signature: None,
13196            receiver: receiver.to_string(),
13197            method_name: method_name.to_string(),
13198            colon_dispatch: false,
13199            line,
13200            lang: lang.to_string(),
13201        }
13202    }
13203
13204    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
13205        NameMatchCandidate {
13206            node_id: node_id.to_string(),
13207            file_path: "src/targets.fixture".to_string(),
13208            scoped_name: scoped_name.to_string(),
13209            kind: "method".to_string(),
13210        }
13211    }
13212
13213    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
13214        let path = root.join(rel_path);
13215        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
13216        fs::write(path, source).expect("write fixture");
13217    }
13218
13219    fn line_of(source: &str, needle: &str) -> u32 {
13220        source
13221            .lines()
13222            .position(|line| line.contains(needle))
13223            .map(|index| index as u32 + 1)
13224            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
13225    }
13226}