Skip to main content

aft/inspect/
cache.rs

1use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock, RwLock};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8const INSPECT_SQLITE_SIDECAR_SUFFIXES: &[&str] = &["-wal", "-shm", "-journal"];
9const INSPECT_WRITER_LEASE_TIMEOUT: Duration = Duration::from_secs(2);
10// Field data (2026-08-06): ~/.local/share/cortexkit/aft/inspect held 1,945
11// scope-key directories for about 30 repositories; 1,849 directories (21.5 GB)
12// had no file newer than seven days. Scope keys hash canonical checkout paths,
13// so reclaimed worktrees cannot ever reach their old directories again. This is
14// a structural fix for that permanent accumulation, not a user-facing knob.
15const INSPECT_SCOPE_MIN_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
16/// Bound one process-wide pass so a slow filesystem cannot stall publication;
17/// the first-level cursor resumes the remaining scope directories next time.
18const INSPECT_SCOPE_SWEEP_BUDGET: Duration = Duration::from_secs(5);
19
20#[derive(Default)]
21struct InspectScopeSweepCursor {
22    last_name: Option<String>,
23}
24
25static INSPECT_SCOPE_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, InspectScopeSweepCursor>>> =
26    OnceLock::new();
27
28use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
29
30use crate::cache_freshness::{FileFreshness, FreshnessVerdict};
31use crate::config::Config;
32use crate::jsonc::strip_jsonc;
33
34use super::job::{
35    contribution_with_type_ref_names, type_ref_names_from_contribution, FileContribution,
36    InspectCategory, JobKey,
37};
38
39#[derive(Debug, Default)]
40pub(crate) struct Tier2ContributionUpdates {
41    pub upserts: Vec<FileContribution>,
42    pub deletes: Vec<PathBuf>,
43    pub metadata_updates: Vec<(PathBuf, FileFreshness)>,
44}
45
46#[derive(Debug, Default, Clone, Copy)]
47pub(crate) struct InspectDbTimings {
48    pub lock_wait: Duration,
49    pub transaction: Duration,
50}
51
52#[derive(Debug)]
53pub enum InspectCacheError {
54    Io(std::io::Error),
55    WriterLeaseTimeout,
56    Sql(rusqlite::Error),
57    Json(serde_json::Error),
58    LockPoisoned(&'static str),
59    InvalidHash(String),
60}
61
62impl fmt::Display for InspectCacheError {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            InspectCacheError::Io(error) => write!(formatter, "inspect cache io error: {error}"),
66            InspectCacheError::WriterLeaseTimeout => {
67                write!(
68                    formatter,
69                    "writer_lease_timeout: inspect writer lease deadline elapsed"
70                )
71            }
72            InspectCacheError::Sql(error) => {
73                write!(formatter, "inspect cache sqlite error: {error}")
74            }
75            InspectCacheError::Json(error) => {
76                write!(formatter, "inspect cache json error: {error}")
77            }
78            InspectCacheError::LockPoisoned(name) => {
79                write!(formatter, "inspect cache lock poisoned: {name}")
80            }
81            InspectCacheError::InvalidHash(hash) => {
82                write!(formatter, "inspect cache invalid blake3 hash: {hash}")
83            }
84        }
85    }
86}
87
88impl std::error::Error for InspectCacheError {}
89
90impl From<std::io::Error> for InspectCacheError {
91    fn from(error: std::io::Error) -> Self {
92        Self::Io(error)
93    }
94}
95
96impl From<rusqlite::Error> for InspectCacheError {
97    fn from(error: rusqlite::Error) -> Self {
98        Self::Sql(error)
99    }
100}
101
102impl From<serde_json::Error> for InspectCacheError {
103    fn from(error: serde_json::Error) -> Self {
104        Self::Json(error)
105    }
106}
107
108/// Persisted Tier-2 contribution/aggregate format version.
109///
110/// Bump this when `FileContribution.contribution` JSON changes in a way that
111/// requires existing per-file contributions to be rebuilt before roll-up, OR
112/// when the roll-up/aggregation LOGIC changes (e.g. dead_code reachability):
113/// cached aggregates are keyed by a `contribution_set_hash` that folds in this
114/// version, so a logic-only change is invisible to existing caches unless the
115/// version moves. v6: dead_code now propagates liveness through dispatch-only
116/// method bodies (free fns reached only via `obj.method()` were false-dead).
117/// v7: duplicates now collapses nested/overlapping fragments (a duplicated
118/// block no longer reports every nested subtree as its own group).
119/// v8: entry-point recognition seeds npm `scripts` source files as liveness
120/// roots (baked into per-file liveness_roots), and dead_code/unused_exports
121/// exclude test-support files (fixtures/corpora/mocks) from reporting.
122/// v9: unused_exports resolves NodeNext `./x.js` import specifiers to their
123/// `.ts` source (alters resolved import edges), fixing false-unused on symbols
124/// re-exported/imported with a `.js` extension in a `.ts` source tree.
125/// v10: public-API entry resolution remaps build-output entries (dist/index.js)
126/// to their src/ source equivalent, so the source barrel is recognized as a
127/// public-API file and its re-exports are suppressed (changes public-API set).
128/// v11: dead_code/unused_exports drill-down is ranked by signal tier (product
129/// findings before benchmark/tooling noise) before the cap, and a ranked `top`
130/// preview is folded into all three Tier-2 aggregates — changes cached payload.
131/// v12: dead_code internal call rows include call-edge provenance, changing
132/// cached per-file contribution payloads and aggregate roll-up inputs.
133/// v13: dead_code callgraph snapshots are projected from the persisted
134/// CallgraphStore; per-row provenance now reflects store resolution tiers.
135/// v14: TS/JS dead_code and unused_exports contributions carry oxc verdicts,
136/// provenance, and oxc honesty metadata.
137/// v15: dead_code reachability counts exact type_match call edges as resolved
138/// liveness (qualified-constructor calls like AppContext::new -> BackupStore::new
139/// no longer collapse to bare `new` and drop), changing the dead verdict for the
140/// same contribution set — existing caches must invalidate.
141/// v16: unused_exports stores raw oxc FileFacts and recomputes verdicts during
142/// roll-up, enabling incremental one-file reparses without stale verdicts.
143/// v17: dead_code stores raw per-file facts and recomputes callgraph/re-export,
144/// entry-root, imported-export, and oxc verdict liveness during roll-up.
145/// v18: dead_code/unused_exports aggregate hashes include the full TS/JS
146/// resolver-config dependency set (tsconfig/jsconfig variants and extends
147/// chains), so alias-only config edits invalidate verdict roll-ups.
148/// v19: dead_code entry reachability executes side-effect-only imported modules,
149/// preserving same-file and transitive static-import liveness without marking all
150/// target exports used.
151/// v20: duplicates aggregate hashes include inspect.duplicates.expected_mirrors,
152/// so changing intentional mirror architecture rules invalidates cached roll-ups.
153/// v21: Rust dead_code contributions carry attribute-root entry facts for
154/// externally-invoked functions such as Tauri commands and ABI exports.
155/// v22: cycles persists TS/JS resolved import-edge facts and rolls them up into
156/// strongly connected module components.
157/// v23: TS/JS re-export verdicts collapse barrel aliases to canonical exports
158/// and carry non-counted re-export context on canonical findings.
159/// v24: TS/JS verdicts distinguish product references from test-only
160/// references so test-only usage moves out of headline dead/unused counts.
161/// v25: framework file-based route files execute as roots while only their
162/// framework-called exports are seeded live; manifest-driven route detection
163/// changes dead/unused verdict roll-ups without changing per-file facts.
164/// v26: TS/JS per-file facts record exported-symbol decorators, and NestJS
165/// decorator roots change dead/unused verdict roll-ups.
166/// v28: Rust dead-code facts include function/type names found inside macro
167/// token trees. The aggregate scan uses those names for reachability only;
168/// call-graph navigation remains based on resolved source-level calls.
169/// v29: duplicates verdicts require every reported occurrence to span at least
170/// 10 source lines, so unchanged per-file fragment facts can now aggregate to a
171/// different surfaced group set.
172/// v30: cycles verdicts retain singleton strongly connected components when a
173/// resolved non-type import explicitly points back to the same file.
174/// v31: TODO extraction now reads parser comment nodes and ignores marker-like
175/// text in strings, changing aggregate verdicts for unchanged source files.
176/// v32: Rust dead-code reachability follows function items referenced as values,
177/// and callers inside cfg(test) regions move otherwise-dead targets into the
178/// test-only bucket.
179/// v33: cyclomatic-complexity contributions and thresholded hotspot aggregates
180/// add a Tier-2 category, so unchanged contribution sets need a new roll-up.
181pub(crate) const TIER2_CONTRIBUTION_CACHE_VERSION: u32 = 33;
182
183#[derive(Debug, Clone)]
184pub struct ContributionRecord {
185    pub category: InspectCategory,
186    pub file_path: PathBuf,
187    pub freshness: FileFreshness,
188    pub contribution: serde_json::Value,
189    pub type_ref_names: BTreeSet<String>,
190}
191
192#[derive(Debug, Clone)]
193struct MemoryAggregate {
194    payload: serde_json::Value,
195    generated_at: i64,
196    contribution_set_hash: Option<String>,
197}
198
199const TIER1_FILE_MEMO_MAX_ENTRIES: usize = 4_096;
200
201#[derive(Debug, Clone)]
202struct Tier1MemoEntry<T> {
203    freshness: FileFreshness,
204    value: T,
205    generation: u64,
206}
207
208#[derive(Debug, Clone)]
209struct LruNode {
210    path: PathBuf,
211    generation: u64,
212}
213
214#[derive(Debug)]
215struct Tier1MemoState<T> {
216    entries: HashMap<PathBuf, Tier1MemoEntry<T>>,
217    lru: VecDeque<LruNode>,
218    next_generation: u64,
219    capacity: usize,
220}
221
222impl<T> Default for Tier1MemoState<T> {
223    fn default() -> Self {
224        Self {
225            entries: HashMap::new(),
226            lru: VecDeque::new(),
227            next_generation: 0,
228            capacity: TIER1_FILE_MEMO_MAX_ENTRIES,
229        }
230    }
231}
232
233impl<T> Tier1MemoState<T> {
234    fn insert(&mut self, path: PathBuf, mut entry: Tier1MemoEntry<T>) {
235        let generation = self.allocate_generation();
236        entry.generation = generation;
237        self.entries.insert(path.clone(), entry);
238        self.lru.push_back(LruNode { path, generation });
239        self.compact_lru_if_needed();
240        self.evict_lru();
241    }
242
243    fn remove(&mut self, path: &Path) {
244        self.entries.remove(path);
245        self.compact_lru_if_needed();
246    }
247
248    fn touch(&mut self, path: &Path) {
249        if !self.entries.contains_key(path) {
250            return;
251        }
252
253        let generation = self.allocate_generation();
254        if let Some(entry) = self.entries.get_mut(path) {
255            entry.generation = generation;
256            self.lru.push_back(LruNode {
257                path: path.to_path_buf(),
258                generation,
259            });
260        }
261        self.compact_lru_if_needed();
262    }
263
264    fn allocate_generation(&mut self) -> u64 {
265        if self.next_generation == u64::MAX {
266            self.rebuild_lru();
267        }
268        let generation = self.next_generation;
269        self.next_generation += 1;
270        generation
271    }
272
273    fn compact_lru_if_needed(&mut self) {
274        let max_lru_nodes = self.capacity.saturating_mul(2).max(self.entries.len());
275        if self.lru.len() > max_lru_nodes {
276            self.rebuild_lru();
277        }
278    }
279
280    fn rebuild_lru(&mut self) {
281        let mut live_nodes = self
282            .entries
283            .iter()
284            .map(|(path, entry)| (entry.generation, path.clone()))
285            .collect::<Vec<_>>();
286        live_nodes.sort_by_key(|(generation, _)| *generation);
287
288        self.lru.clear();
289        for (generation, (_, path)) in live_nodes.into_iter().enumerate() {
290            let generation = generation as u64;
291            if let Some(entry) = self.entries.get_mut(&path) {
292                entry.generation = generation;
293            }
294            self.lru.push_back(LruNode { path, generation });
295        }
296        self.next_generation = self.lru.len() as u64;
297    }
298
299    fn retain_live_lru_nodes(&mut self) {
300        let entries = &self.entries;
301        self.lru.retain(|node| {
302            entries
303                .get(&node.path)
304                .is_some_and(|entry| entry.generation == node.generation)
305        });
306    }
307
308    fn evict_lru(&mut self) {
309        while self.entries.len() > self.capacity {
310            let Some(node) = self.lru.pop_front() else {
311                break;
312            };
313            if self
314                .entries
315                .get(&node.path)
316                .is_some_and(|entry| entry.generation == node.generation)
317            {
318                self.entries.remove(&node.path);
319            }
320        }
321        self.compact_lru_if_needed();
322    }
323}
324
325#[derive(Debug)]
326pub(crate) struct Tier1FileMemo<T> {
327    state: Mutex<Tier1MemoState<T>>,
328}
329
330impl<T> Default for Tier1FileMemo<T> {
331    fn default() -> Self {
332        Self {
333            state: Mutex::new(Tier1MemoState::default()),
334        }
335    }
336}
337
338impl<T> Tier1FileMemo<T> {
339    /// Prevent a full-scope scan from evicting entries that the same scan will
340    /// need again on its next run. Capacity only grows here; a completed full
341    /// scan shrinks it back to that scan's live file set in [`Self::prune_to_scope`].
342    pub(crate) fn reserve_for_scan(&self, file_count: usize) {
343        let required_capacity = file_count.max(TIER1_FILE_MEMO_MAX_ENTRIES);
344        if let Ok(mut state) = self.state.lock() {
345            state.capacity = state.capacity.max(required_capacity);
346        }
347    }
348
349    /// Drop entries outside a completed full-project scan and right-size the
350    /// memo to the live project. Scoped scans must not call this because their
351    /// path list intentionally omits valid entries elsewhere in the project.
352    pub(crate) fn prune_to_scope(&self, project_root: &Path, live_paths: &[PathBuf]) {
353        let live_paths = live_paths
354            .iter()
355            .map(PathBuf::as_path)
356            .collect::<HashSet<_>>();
357        if let Ok(mut state) = self.state.lock() {
358            state.entries.retain(|path, _| {
359                !path.starts_with(project_root) || live_paths.contains(path.as_path())
360            });
361            state.capacity = live_paths.len().max(TIER1_FILE_MEMO_MAX_ENTRIES);
362            state.retain_live_lru_nodes();
363            state.evict_lru();
364        }
365    }
366}
367
368impl<T: Clone> Tier1FileMemo<T> {
369    pub(crate) fn get_or_insert_with<F>(&self, path: &Path, scan: F) -> T
370    where
371        F: FnOnce(&Path) -> (Option<FileFreshness>, T),
372    {
373        if let Some(cached) = self.cached_value(path) {
374            return cached;
375        }
376
377        let (freshness, value) = scan(path);
378        if let Ok(mut state) = self.state.lock() {
379            if let Some(freshness) = freshness {
380                state.insert(
381                    path.to_path_buf(),
382                    Tier1MemoEntry {
383                        freshness,
384                        value: value.clone(),
385                        generation: 0,
386                    },
387                );
388            } else {
389                state.remove(path);
390            }
391        }
392        value
393    }
394
395    fn cached_value(&self, path: &Path) -> Option<T> {
396        let mut cached = self
397            .state
398            .lock()
399            .ok()
400            .and_then(|state| state.entries.get(path).cloned())?;
401
402        match crate::cache_freshness::verify_file(path, &cached.freshness) {
403            FreshnessVerdict::HotFresh => {
404                if let Ok(mut state) = self.state.lock() {
405                    state.touch(path);
406                }
407                Some(cached.value)
408            }
409            FreshnessVerdict::ContentFresh {
410                new_mtime,
411                new_size,
412            } => {
413                cached.freshness.mtime = new_mtime;
414                cached.freshness.size = new_size;
415                let value = cached.value.clone();
416                if let Ok(mut state) = self.state.lock() {
417                    state.insert(path.to_path_buf(), cached);
418                }
419                Some(value)
420            }
421            FreshnessVerdict::Stale => None,
422            FreshnessVerdict::Deleted => {
423                if let Ok(mut state) = self.state.lock() {
424                    state.remove(path);
425                }
426                None
427            }
428        }
429    }
430}
431
432#[derive(Debug)]
433pub struct InspectCache {
434    project_root: PathBuf,
435    project_key: String,
436    sqlite_path: PathBuf,
437    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
438    read_marker: Option<crate::root_cache::ReadMarker>,
439    conn: Mutex<Connection>,
440    memory: RwLock<HashMap<JobKey, MemoryAggregate>>,
441}
442
443#[derive(Debug)]
444pub struct ReadonlyInspectCache {
445    inner: InspectCache,
446}
447
448impl InspectCache {
449    /// Estimate only the in-process aggregate map. SQLite-owned allocations are
450    /// measured once by the process-wide SQLite allocator counters.
451    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
452        let memory = match self.memory.try_read() {
453            Ok(memory) => memory,
454            Err(_) => return crate::memory::MemoryEstimate::busy(),
455        };
456        if memory.is_empty() {
457            return crate::memory::MemoryEstimate::partial(0)
458                .count("memory_aggregates", 0)
459                .count("open_generation_handles", 1);
460        }
461        let aggregate_bytes = memory.iter().fold(0u64, |bytes, (key, aggregate)| {
462            bytes
463                .saturating_add(std::mem::size_of::<JobKey>() as u64)
464                .saturating_add(
465                    key.scope_hash
466                        .as_ref()
467                        .map(|hash| crate::memory::usize_to_u64(hash.len()))
468                        .unwrap_or(0),
469                )
470                .saturating_add(std::mem::size_of::<MemoryAggregate>() as u64)
471                .saturating_add(crate::memory::estimated_json_bytes(&aggregate.payload))
472                .saturating_add(
473                    aggregate
474                        .contribution_set_hash
475                        .as_ref()
476                        .map(|hash| crate::memory::usize_to_u64(hash.len()))
477                        .unwrap_or(0),
478                )
479        });
480        let metadata_bytes = crate::memory::path_bytes(&self.project_root)
481            .saturating_add(crate::memory::usize_to_u64(self.project_key.len()))
482            .saturating_add(crate::memory::path_bytes(&self.sqlite_path));
483        crate::memory::MemoryEstimate::partial(aggregate_bytes.saturating_add(metadata_bytes))
484            .count("memory_aggregates", memory.len())
485            .count("open_generation_handles", 1)
486    }
487}
488
489pub trait InspectCacheRead {
490    fn get_aggregated_for_config(
491        &self,
492        key: &JobKey,
493        config: &Config,
494    ) -> Result<Option<serde_json::Value>, InspectCacheError>;
495    fn latest_aggregate_any_hash(
496        &self,
497        category: InspectCategory,
498    ) -> Result<Option<serde_json::Value>, InspectCacheError>;
499    fn contribution_freshness(
500        &self,
501        category: InspectCategory,
502    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError>;
503    fn load_tier2_contributions(
504        &self,
505        category: InspectCategory,
506    ) -> Result<Vec<ContributionRecord>, InspectCacheError>;
507    fn contribution_set_hash_for_config(
508        &self,
509        category: InspectCategory,
510        config: &Config,
511    ) -> Result<String, InspectCacheError>;
512    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError>;
513}
514
515impl InspectCache {
516    pub fn open(inspect_dir: PathBuf, project_root: PathBuf) -> Result<Self, InspectCacheError> {
517        let project_key = crate::path_identity::project_scope_key(&project_root);
518        let project_inspect_dir = project_inspect_dir(inspect_dir.clone(), &project_key);
519        let Some(writer_lease) =
520            acquire_writer_lease(&project_inspect_dir, &project_key, &project_root)?
521        else {
522            return match Self::open_readonly(inspect_dir, project_root.clone())? {
523                Some(cache) => Ok(cache.into_inner()),
524                None => Self::borrow_only_empty(project_inspect_dir, project_root, project_key),
525            };
526        };
527        let inspect_dir = project_inspect_dir;
528        if !writer_lease.verify().map_err(InspectCacheError::from)? {
529            return Err(InspectCacheError::Io(std::io::Error::other(
530                "inspect writer lease epoch changed before opening cache",
531            )));
532        }
533        std::fs::create_dir_all(&inspect_dir)?;
534        let (sqlite_path, generation, needs_publish) =
535            resolve_or_create_inspect_target(&inspect_dir, &project_key);
536        let conn = Connection::open(&sqlite_path)?;
537        configure_connection(&conn)?;
538        if !writer_lease.verify().map_err(InspectCacheError::from)? {
539            return Err(InspectCacheError::Io(std::io::Error::other(
540                "inspect writer lease epoch changed before schema initialization",
541            )));
542        }
543        initialize_schema(&conn)?;
544        if needs_publish {
545            if !writer_lease.verify().map_err(InspectCacheError::from)? {
546                return Err(InspectCacheError::Io(std::io::Error::other(
547                    "inspect writer lease epoch changed before pointer publish",
548                )));
549            }
550            publish_inspect_pointer(
551                &inspect_dir,
552                &project_key,
553                generation.as_deref().unwrap_or_default(),
554            )?;
555        }
556        Ok(Self::from_connection(
557            project_root,
558            project_key,
559            sqlite_path,
560            Some(writer_lease),
561            None,
562            conn,
563        ))
564    }
565
566    pub fn open_readonly(
567        inspect_dir: PathBuf,
568        project_root: PathBuf,
569    ) -> Result<Option<ReadonlyInspectCache>, InspectCacheError> {
570        let project_key = crate::path_identity::project_scope_key(&project_root);
571        let inspect_dir = project_inspect_dir(inspect_dir, &project_key);
572        let Some((sqlite_path, generation)) = resolve_inspect_target(&inspect_dir, &project_key)
573        else {
574            return Ok(None);
575        };
576        let conn = open_readonly_connection(&sqlite_path)?;
577        let marker_label = generation.as_deref().unwrap_or("legacy");
578        let read_marker = crate::root_cache::ReadMarker::create(&inspect_dir, marker_label)?;
579        Ok(Some(ReadonlyInspectCache::from_inner(
580            Self::from_connection(
581                project_root,
582                project_key,
583                sqlite_path,
584                None,
585                Some(read_marker),
586                conn,
587            ),
588        )))
589    }
590
591    fn borrow_only_empty(
592        inspect_dir: PathBuf,
593        project_root: PathBuf,
594        project_key: String,
595    ) -> Result<Self, InspectCacheError> {
596        let conn = Connection::open_in_memory()?;
597        initialize_schema(&conn)?;
598        conn.pragma_update(None, "query_only", true)?;
599        Ok(Self::from_connection(
600            project_root,
601            project_key.clone(),
602            inspect_dir.join(format!("{project_key}.borrow-only")),
603            None,
604            None,
605            conn,
606        ))
607    }
608
609    fn from_connection(
610        project_root: PathBuf,
611        project_key: String,
612        sqlite_path: PathBuf,
613        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
614        read_marker: Option<crate::root_cache::ReadMarker>,
615        conn: Connection,
616    ) -> Self {
617        Self {
618            project_root,
619            project_key,
620            sqlite_path,
621            writer_lease,
622            read_marker,
623            conn: Mutex::new(conn),
624            memory: RwLock::new(HashMap::new()),
625        }
626    }
627
628    pub fn project_root(&self) -> &Path {
629        &self.project_root
630    }
631
632    pub fn project_key(&self) -> &str {
633        &self.project_key
634    }
635
636    pub fn sqlite_path(&self) -> &Path {
637        &self.sqlite_path
638    }
639
640    pub fn writer_epoch_for_test(&self) -> Option<&str> {
641        self.writer_lease.as_ref().map(|lease| lease.epoch())
642    }
643
644    fn verify_writer_lease(&self) -> Result<(), InspectCacheError> {
645        let Some(lease) = self.writer_lease.as_ref() else {
646            return Err(InspectCacheError::Io(std::io::Error::other(
647                "inspect cache opened read-only; write API is unavailable",
648            )));
649        };
650        if lease.verify()? {
651            Ok(())
652        } else {
653            Err(InspectCacheError::Io(std::io::Error::other(format!(
654                "inspect writer lease for key {} lost epoch {}; aborting write",
655                lease.key(),
656                lease.epoch()
657            ))))
658        }
659    }
660
661    fn refresh_read_marker(&self) -> Result<(), InspectCacheError> {
662        if let Some(marker) = self.read_marker.as_ref() {
663            marker.touch_if_due()?;
664        }
665        Ok(())
666    }
667
668    pub fn store_aggregated(
669        &self,
670        key: JobKey,
671        payload: serde_json::Value,
672    ) -> Result<(), InspectCacheError> {
673        self.verify_writer_lease()?;
674        self.store_memory_aggregate(key, payload, None)
675    }
676
677    fn store_memory_aggregate(
678        &self,
679        key: JobKey,
680        payload: serde_json::Value,
681        contribution_set_hash: Option<String>,
682    ) -> Result<(), InspectCacheError> {
683        self.memory
684            .write()
685            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
686            .insert(
687                key,
688                MemoryAggregate {
689                    payload,
690                    generated_at: unix_seconds_now(),
691                    contribution_set_hash,
692                },
693            );
694        Ok(())
695    }
696
697    pub fn get_aggregated(
698        &self,
699        key: &JobKey,
700    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
701        self.get_aggregated_with_config(key, None)
702    }
703
704    pub fn get_aggregated_for_config(
705        &self,
706        key: &JobKey,
707        config: &Config,
708    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
709        self.get_aggregated_with_config(key, Some(config))
710    }
711
712    fn get_aggregated_with_config(
713        &self,
714        key: &JobKey,
715        config: Option<&Config>,
716    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
717        self.refresh_read_marker()?;
718        if !key.category.is_tier2() {
719            return Ok(self
720                .memory
721                .read()
722                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
723                .get(key)
724                .map(|entry| entry.payload.clone()));
725        }
726
727        let current_hash = {
728            let conn = self
729                .conn
730                .lock()
731                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
732            contribution_set_hash_with_conn(
733                &conn,
734                key.category,
735                &self.project_key,
736                &self.project_root,
737                config,
738            )?
739        };
740
741        let memory_entry = {
742            self.memory
743                .read()
744                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
745                .get(key)
746                .cloned()
747        };
748        if let Some(entry) = memory_entry {
749            if entry.contribution_set_hash.as_deref() == Some(current_hash.as_str()) {
750                return Ok(Some(entry.payload));
751            }
752            self.memory
753                .write()
754                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
755                .remove(key);
756        }
757
758        let payload = {
759            let conn = self
760                .conn
761                .lock()
762                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
763            conn.query_row(
764                "SELECT aggregate FROM tier2_aggregates \
765                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
766                params![key.category.as_str(), self.project_key, current_hash],
767                |row| row.get::<_, Vec<u8>>(0),
768            )
769            .optional()?
770        };
771
772        match payload {
773            Some(bytes) => {
774                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
775                self.store_memory_aggregate(key.clone(), value.clone(), Some(current_hash))?;
776                Ok(Some(value))
777            }
778            None => Ok(None),
779        }
780    }
781
782    pub fn store_tier2_result(
783        &self,
784        key: JobKey,
785        scanned_files: &[PathBuf],
786        contributions: &[FileContribution],
787        aggregate: serde_json::Value,
788    ) -> Result<(), InspectCacheError> {
789        self.store_tier2_result_with_config(key, scanned_files, contributions, aggregate, None)
790    }
791
792    pub fn store_tier2_result_for_config(
793        &self,
794        key: JobKey,
795        scanned_files: &[PathBuf],
796        contributions: &[FileContribution],
797        aggregate: serde_json::Value,
798        config: &Config,
799    ) -> Result<(), InspectCacheError> {
800        self.store_tier2_result_with_config(
801            key,
802            scanned_files,
803            contributions,
804            aggregate,
805            Some(config),
806        )
807    }
808
809    fn store_tier2_result_with_config(
810        &self,
811        key: JobKey,
812        scanned_files: &[PathBuf],
813        contributions: &[FileContribution],
814        aggregate: serde_json::Value,
815        config: Option<&Config>,
816    ) -> Result<(), InspectCacheError> {
817        if !key.category.is_tier2() {
818            self.store_aggregated(key, aggregate)?;
819            return Ok(());
820        }
821
822        self.verify_writer_lease()?;
823        let now = unix_seconds_now();
824        let mut conn = self
825            .conn
826            .lock()
827            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
828        let tx = conn.transaction()?;
829
830        let scanned_relative = scanned_files
831            .iter()
832            .map(|path| relative_string(&self.project_root, path))
833            .collect::<BTreeSet<_>>();
834        let existing = existing_contribution_paths(&tx, key.category, &self.project_key)?;
835        for file_path in existing {
836            if !scanned_relative.contains(&file_path) {
837                tx.execute(
838                    "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
839                    params![key.category.as_str(), self.project_key, file_path],
840                )?;
841            }
842        }
843
844        for contribution in contributions {
845            let file_path = relative_string(&self.project_root, &contribution.file_path);
846            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
847                contribution.contribution.clone(),
848                &contribution.type_ref_names,
849            ))?;
850            tx.execute(
851                "INSERT INTO tier2_contributions \
852                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
853                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
854                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
855                 file_mtime_ns = excluded.file_mtime_ns, \
856                 file_size = excluded.file_size, \
857                 file_hash = excluded.file_hash, \
858                 contribution = excluded.contribution, \
859                 generated_at = excluded.generated_at",
860                params![
861                    contribution.category.as_str(),
862                    self.project_key,
863                    file_path,
864                    system_time_to_ns(contribution.freshness.mtime),
865                    contribution.freshness.size as i64,
866                    hash_to_hex(contribution.freshness.content_hash),
867                    blob,
868                    now,
869                ],
870            )?;
871        }
872
873        let contribution_set_hash = contribution_set_hash_with_conn(
874            &tx,
875            key.category,
876            &self.project_key,
877            &self.project_root,
878            config,
879        )?;
880        let aggregate_blob = serde_json::to_vec(&aggregate)?;
881        tx.execute(
882            "INSERT INTO tier2_aggregates \
883             (category, project_key, contribution_set_hash, aggregate, generated_at) \
884             VALUES (?1, ?2, ?3, ?4, ?5) \
885             ON CONFLICT(category, project_key) DO UPDATE SET \
886             contribution_set_hash = excluded.contribution_set_hash, \
887             aggregate = excluded.aggregate, \
888             generated_at = excluded.generated_at",
889            params![
890                key.category.as_str(),
891                self.project_key,
892                contribution_set_hash,
893                aggregate_blob,
894                now,
895            ],
896        )?;
897        tx.execute(
898            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
899             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
900            params![key.category.as_str(), self.project_key, now],
901        )?;
902        tx.commit()?;
903
904        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash))
905    }
906
907    pub(crate) fn apply_contribution_updates_for_config(
908        &self,
909        category: InspectCategory,
910        updates: Tier2ContributionUpdates,
911        config: &Config,
912    ) -> Result<(String, InspectDbTimings), InspectCacheError> {
913        self.apply_contribution_updates_with_config(category, updates, Some(config))
914    }
915
916    fn apply_contribution_updates_with_config(
917        &self,
918        category: InspectCategory,
919        updates: Tier2ContributionUpdates,
920        config: Option<&Config>,
921    ) -> Result<(String, InspectDbTimings), InspectCacheError> {
922        self.verify_writer_lease()?;
923        let now = unix_seconds_now();
924        let lock_started = Instant::now();
925        let mut conn = self
926            .conn
927            .lock()
928            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
929        let mut timings = InspectDbTimings {
930            lock_wait: lock_started.elapsed(),
931            ..InspectDbTimings::default()
932        };
933        let transaction_started = Instant::now();
934        let tx = conn.transaction()?;
935
936        for relative_file in updates.deletes {
937            tx.execute(
938                "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
939                params![
940                    category.as_str(),
941                    self.project_key,
942                    relative_file.to_string_lossy().to_string()
943                ],
944            )?;
945        }
946
947        for (relative_file, freshness) in updates.metadata_updates {
948            tx.execute(
949                "UPDATE tier2_contributions \
950                 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
951                 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
952                params![
953                    category.as_str(),
954                    self.project_key,
955                    relative_file.to_string_lossy().to_string(),
956                    system_time_to_ns(freshness.mtime),
957                    freshness.size as i64,
958                    hash_to_hex(freshness.content_hash),
959                ],
960            )?;
961        }
962
963        for contribution in updates.upserts {
964            let file_path = relative_string(&self.project_root, &contribution.file_path);
965            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
966                contribution.contribution.clone(),
967                &contribution.type_ref_names,
968            ))?;
969            tx.execute(
970                "INSERT INTO tier2_contributions \
971                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
972                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
973                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
974                 file_mtime_ns = excluded.file_mtime_ns, \
975                 file_size = excluded.file_size, \
976                 file_hash = excluded.file_hash, \
977                 contribution = excluded.contribution, \
978                 generated_at = excluded.generated_at",
979                params![
980                    contribution.category.as_str(),
981                    self.project_key,
982                    file_path,
983                    system_time_to_ns(contribution.freshness.mtime),
984                    contribution.freshness.size as i64,
985                    hash_to_hex(contribution.freshness.content_hash),
986                    blob,
987                    now,
988                ],
989            )?;
990        }
991
992        let contribution_set_hash = contribution_set_hash_with_conn(
993            &tx,
994            category,
995            &self.project_key,
996            &self.project_root,
997            config,
998        )?;
999        tx.commit()?;
1000        timings.transaction = transaction_started.elapsed();
1001
1002        self.memory
1003            .write()
1004            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1005            .remove(&JobKey::for_project_category(category));
1006
1007        Ok((contribution_set_hash, timings))
1008    }
1009
1010    pub(crate) fn load_aggregate_if_hash_matches(
1011        &self,
1012        category: InspectCategory,
1013        contribution_set_hash: &str,
1014    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1015        self.refresh_read_marker()?;
1016        let payload = {
1017            let conn = self
1018                .conn
1019                .lock()
1020                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1021            conn.query_row(
1022                "SELECT aggregate FROM tier2_aggregates \
1023                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
1024                params![category.as_str(), self.project_key, contribution_set_hash],
1025                |row| row.get::<_, Vec<u8>>(0),
1026            )
1027            .optional()?
1028        };
1029
1030        match payload {
1031            Some(bytes) => {
1032                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
1033                self.store_memory_aggregate(
1034                    JobKey::for_project_category(category),
1035                    value.clone(),
1036                    Some(contribution_set_hash.to_string()),
1037                )?;
1038                Ok(Some(value))
1039            }
1040            None => Ok(None),
1041        }
1042    }
1043
1044    pub(crate) fn latest_aggregate_any_hash(
1045        &self,
1046        category: InspectCategory,
1047    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1048        self.refresh_read_marker()?;
1049        let payload = {
1050            let conn = self
1051                .conn
1052                .lock()
1053                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1054            conn.query_row(
1055                "SELECT aggregate FROM tier2_aggregates \
1056                 WHERE category = ?1 AND project_key = ?2 \
1057                 ORDER BY generated_at DESC LIMIT 1",
1058                params![category.as_str(), self.project_key],
1059                |row| row.get::<_, Vec<u8>>(0),
1060            )
1061            .optional()?
1062        };
1063
1064        match payload {
1065            Some(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
1066                .map(Some)
1067                .map_err(InspectCacheError::from),
1068            None => Ok(None),
1069        }
1070    }
1071
1072    pub(crate) fn touch_tier2_last_full_run(
1073        &self,
1074        category: InspectCategory,
1075    ) -> Result<i64, InspectCacheError> {
1076        self.verify_writer_lease()?;
1077        let mut conn = self
1078            .conn
1079            .lock()
1080            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1081        let tx = conn.transaction()?;
1082        let previous = tx
1083            .query_row(
1084                "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1085                params![category.as_str(), self.project_key],
1086                |row| row.get::<_, i64>(0),
1087            )
1088            .optional()?;
1089        let now = unix_seconds_now();
1090        let last_full_run = previous.map_or(now, |previous| now.max(previous.saturating_add(1)));
1091        tx.execute(
1092            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)              ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1093            params![category.as_str(), self.project_key, last_full_run],
1094        )?;
1095        tx.commit()?;
1096        Ok(last_full_run)
1097    }
1098
1099    pub(crate) fn store_tier2_aggregate(
1100        &self,
1101        key: JobKey,
1102        contribution_set_hash: &str,
1103        aggregate: serde_json::Value,
1104    ) -> Result<(), InspectCacheError> {
1105        if !key.category.is_tier2() {
1106            self.store_aggregated(key, aggregate)?;
1107            return Ok(());
1108        }
1109
1110        self.verify_writer_lease()?;
1111        let now = unix_seconds_now();
1112        let aggregate_blob = serde_json::to_vec(&aggregate)?;
1113        let mut conn = self
1114            .conn
1115            .lock()
1116            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1117        let tx = conn.transaction()?;
1118        tx.execute(
1119            "INSERT INTO tier2_aggregates \
1120             (category, project_key, contribution_set_hash, aggregate, generated_at) \
1121             VALUES (?1, ?2, ?3, ?4, ?5) \
1122             ON CONFLICT(category, project_key) DO UPDATE SET \
1123             contribution_set_hash = excluded.contribution_set_hash, \
1124             aggregate = excluded.aggregate, \
1125             generated_at = excluded.generated_at",
1126            params![
1127                key.category.as_str(),
1128                self.project_key,
1129                contribution_set_hash,
1130                aggregate_blob,
1131                now,
1132            ],
1133        )?;
1134        tx.execute(
1135            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
1136             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1137            params![key.category.as_str(), self.project_key, now],
1138        )?;
1139        tx.commit()?;
1140
1141        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash.to_string()))
1142    }
1143
1144    pub fn load_tier2_contributions(
1145        &self,
1146        category: InspectCategory,
1147    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1148        self.refresh_read_marker()?;
1149        let conn = self
1150            .conn
1151            .lock()
1152            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1153        let mut stmt = conn.prepare(
1154            "SELECT file_path, file_mtime_ns, file_size, file_hash, contribution \
1155             FROM tier2_contributions \
1156             WHERE category = ?1 AND project_key = ?2 \
1157             ORDER BY file_path ASC",
1158        )?;
1159        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1160            let file_path: String = row.get(0)?;
1161            let mtime_ns: i64 = row.get(1)?;
1162            let file_size: i64 = row.get(2)?;
1163            let file_hash: String = row.get(3)?;
1164            let contribution: Vec<u8> = row.get(4)?;
1165            Ok((file_path, mtime_ns, file_size, file_hash, contribution))
1166        })?;
1167
1168        let mut records = Vec::new();
1169        for row in rows {
1170            let (file_path, mtime_ns, file_size, file_hash, contribution) = row?;
1171            let contribution: serde_json::Value = serde_json::from_slice(&contribution)?;
1172            let type_ref_names = type_ref_names_from_contribution(&contribution);
1173            records.push(ContributionRecord {
1174                category,
1175                file_path: PathBuf::from(file_path),
1176                freshness: FileFreshness {
1177                    mtime: ns_to_system_time(mtime_ns),
1178                    size: file_size.max(0) as u64,
1179                    content_hash: hash_from_hex(&file_hash)?,
1180                },
1181                contribution,
1182                type_ref_names,
1183            });
1184        }
1185        Ok(records)
1186    }
1187
1188    pub fn delete_tier2_contribution(
1189        &self,
1190        category: InspectCategory,
1191        relative_file: &Path,
1192    ) -> Result<(), InspectCacheError> {
1193        self.verify_writer_lease()?;
1194        let conn = self
1195            .conn
1196            .lock()
1197            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1198        conn.execute(
1199            "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1200            params![
1201                category.as_str(),
1202                self.project_key,
1203                relative_file.to_string_lossy().to_string()
1204            ],
1205        )?;
1206        Ok(())
1207    }
1208
1209    pub fn update_content_fresh_metadata(
1210        &self,
1211        category: InspectCategory,
1212        relative_file: &Path,
1213        freshness: &FileFreshness,
1214    ) -> Result<(), InspectCacheError> {
1215        self.verify_writer_lease()?;
1216        let conn = self
1217            .conn
1218            .lock()
1219            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1220        conn.execute(
1221            "UPDATE tier2_contributions \
1222             SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
1223             WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1224            params![
1225                category.as_str(),
1226                self.project_key,
1227                relative_file.to_string_lossy().to_string(),
1228                system_time_to_ns(freshness.mtime),
1229                freshness.size as i64,
1230                hash_to_hex(freshness.content_hash),
1231            ],
1232        )?;
1233        Ok(())
1234    }
1235
1236    pub(crate) fn contribution_freshness(
1237        &self,
1238        category: InspectCategory,
1239    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1240        self.refresh_read_marker()?;
1241        let conn = self
1242            .conn
1243            .lock()
1244            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1245        let mut stmt = conn.prepare(
1246            "SELECT file_path, file_mtime_ns, file_size, file_hash \
1247             FROM tier2_contributions \
1248             WHERE category = ?1 AND project_key = ?2 \
1249             ORDER BY file_path ASC",
1250        )?;
1251        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1252            Ok((
1253                row.get::<_, String>(0)?,
1254                row.get::<_, i64>(1)?,
1255                row.get::<_, i64>(2)?,
1256                row.get::<_, String>(3)?,
1257            ))
1258        })?;
1259
1260        let mut records = Vec::new();
1261        for row in rows {
1262            let (file_path, mtime_ns, file_size, file_hash) = row?;
1263            records.push((
1264                PathBuf::from(file_path),
1265                FileFreshness {
1266                    mtime: ns_to_system_time(mtime_ns),
1267                    size: file_size.max(0) as u64,
1268                    content_hash: hash_from_hex(&file_hash)?,
1269                },
1270            ));
1271        }
1272        Ok(records)
1273    }
1274
1275    pub fn contribution_set_hash(
1276        &self,
1277        category: InspectCategory,
1278    ) -> Result<String, InspectCacheError> {
1279        self.contribution_set_hash_with_config(category, None)
1280    }
1281
1282    pub fn contribution_set_hash_for_config(
1283        &self,
1284        category: InspectCategory,
1285        config: &Config,
1286    ) -> Result<String, InspectCacheError> {
1287        self.contribution_set_hash_with_config(category, Some(config))
1288    }
1289
1290    fn contribution_set_hash_with_config(
1291        &self,
1292        category: InspectCategory,
1293        config: Option<&Config>,
1294    ) -> Result<String, InspectCacheError> {
1295        self.refresh_read_marker()?;
1296        let conn = self
1297            .conn
1298            .lock()
1299            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1300        contribution_set_hash_with_conn(
1301            &conn,
1302            category,
1303            &self.project_key,
1304            &self.project_root,
1305            config,
1306        )
1307    }
1308
1309    pub fn last_full_run(
1310        &self,
1311        category: InspectCategory,
1312    ) -> Result<Option<i64>, InspectCacheError> {
1313        self.refresh_read_marker()?;
1314        let conn = self
1315            .conn
1316            .lock()
1317            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1318        conn.query_row(
1319            "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1320            params![category.as_str(), self.project_key],
1321            |row| row.get::<_, i64>(0),
1322        )
1323        .optional()
1324        .map_err(InspectCacheError::from)
1325    }
1326
1327    pub fn memory_generated_at(&self, key: &JobKey) -> Result<Option<i64>, InspectCacheError> {
1328        self.refresh_read_marker()?;
1329        Ok(self
1330            .memory
1331            .read()
1332            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1333            .get(key)
1334            .map(|entry| entry.generated_at))
1335    }
1336}
1337
1338impl ReadonlyInspectCache {
1339    fn from_inner(inner: InspectCache) -> Self {
1340        Self { inner }
1341    }
1342
1343    fn into_inner(self) -> InspectCache {
1344        self.inner
1345    }
1346
1347    pub fn project_root(&self) -> &Path {
1348        self.inner.project_root()
1349    }
1350
1351    pub fn project_key(&self) -> &str {
1352        self.inner.project_key()
1353    }
1354
1355    pub fn sqlite_path(&self) -> &Path {
1356        self.inner.sqlite_path()
1357    }
1358
1359    pub fn get_aggregated_for_config(
1360        &self,
1361        key: &JobKey,
1362        config: &Config,
1363    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1364        self.inner.get_aggregated_for_config(key, config)
1365    }
1366
1367    pub fn latest_aggregate_any_hash(
1368        &self,
1369        category: InspectCategory,
1370    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1371        self.inner.latest_aggregate_any_hash(category)
1372    }
1373
1374    pub fn contribution_freshness(
1375        &self,
1376        category: InspectCategory,
1377    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1378        self.inner.contribution_freshness(category)
1379    }
1380
1381    pub fn load_tier2_contributions(
1382        &self,
1383        category: InspectCategory,
1384    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1385        self.inner.load_tier2_contributions(category)
1386    }
1387
1388    pub fn contribution_set_hash_for_config(
1389        &self,
1390        category: InspectCategory,
1391        config: &Config,
1392    ) -> Result<String, InspectCacheError> {
1393        self.inner
1394            .contribution_set_hash_for_config(category, config)
1395    }
1396
1397    pub fn last_full_run(
1398        &self,
1399        category: InspectCategory,
1400    ) -> Result<Option<i64>, InspectCacheError> {
1401        self.inner.last_full_run(category)
1402    }
1403}
1404
1405impl InspectCacheRead for InspectCache {
1406    fn get_aggregated_for_config(
1407        &self,
1408        key: &JobKey,
1409        config: &Config,
1410    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1411        InspectCache::get_aggregated_for_config(self, key, config)
1412    }
1413    fn latest_aggregate_any_hash(
1414        &self,
1415        category: InspectCategory,
1416    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1417        InspectCache::latest_aggregate_any_hash(self, category)
1418    }
1419    fn contribution_freshness(
1420        &self,
1421        category: InspectCategory,
1422    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1423        InspectCache::contribution_freshness(self, category)
1424    }
1425    fn load_tier2_contributions(
1426        &self,
1427        category: InspectCategory,
1428    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1429        InspectCache::load_tier2_contributions(self, category)
1430    }
1431    fn contribution_set_hash_for_config(
1432        &self,
1433        category: InspectCategory,
1434        config: &Config,
1435    ) -> Result<String, InspectCacheError> {
1436        InspectCache::contribution_set_hash_for_config(self, category, config)
1437    }
1438    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1439        InspectCache::last_full_run(self, category)
1440    }
1441}
1442
1443impl<T: InspectCacheRead + ?Sized> InspectCacheRead for Arc<T> {
1444    fn get_aggregated_for_config(
1445        &self,
1446        key: &JobKey,
1447        config: &Config,
1448    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1449        (**self).get_aggregated_for_config(key, config)
1450    }
1451    fn latest_aggregate_any_hash(
1452        &self,
1453        category: InspectCategory,
1454    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1455        (**self).latest_aggregate_any_hash(category)
1456    }
1457    fn contribution_freshness(
1458        &self,
1459        category: InspectCategory,
1460    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1461        (**self).contribution_freshness(category)
1462    }
1463    fn load_tier2_contributions(
1464        &self,
1465        category: InspectCategory,
1466    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1467        (**self).load_tier2_contributions(category)
1468    }
1469    fn contribution_set_hash_for_config(
1470        &self,
1471        category: InspectCategory,
1472        config: &Config,
1473    ) -> Result<String, InspectCacheError> {
1474        (**self).contribution_set_hash_for_config(category, config)
1475    }
1476    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1477        (**self).last_full_run(category)
1478    }
1479}
1480
1481impl InspectCacheRead for ReadonlyInspectCache {
1482    fn get_aggregated_for_config(
1483        &self,
1484        key: &JobKey,
1485        config: &Config,
1486    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1487        self.get_aggregated_for_config(key, config)
1488    }
1489    fn latest_aggregate_any_hash(
1490        &self,
1491        category: InspectCategory,
1492    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1493        self.latest_aggregate_any_hash(category)
1494    }
1495    fn contribution_freshness(
1496        &self,
1497        category: InspectCategory,
1498    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1499        self.contribution_freshness(category)
1500    }
1501    fn load_tier2_contributions(
1502        &self,
1503        category: InspectCategory,
1504    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1505        self.load_tier2_contributions(category)
1506    }
1507    fn contribution_set_hash_for_config(
1508        &self,
1509        category: InspectCategory,
1510        config: &Config,
1511    ) -> Result<String, InspectCacheError> {
1512        self.contribution_set_hash_for_config(category, config)
1513    }
1514    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1515        self.last_full_run(category)
1516    }
1517}
1518
1519fn project_inspect_dir(inspect_dir: PathBuf, project_key: &str) -> PathBuf {
1520    if inspect_dir
1521        .file_name()
1522        .and_then(|name| name.to_str())
1523        .is_some_and(|name| name == project_key)
1524    {
1525        inspect_dir
1526    } else {
1527        inspect_dir.join(project_key)
1528    }
1529}
1530
1531fn resolve_or_create_inspect_target(
1532    inspect_dir: &Path,
1533    project_key: &str,
1534) -> (PathBuf, Option<String>, bool) {
1535    if let Some((path, generation)) = resolve_inspect_target(inspect_dir, project_key) {
1536        return (path, generation, false);
1537    }
1538    let generation = inspect_generation_file_name(project_key);
1539    (inspect_dir.join(&generation), Some(generation), true)
1540}
1541
1542fn resolve_inspect_target(
1543    inspect_dir: &Path,
1544    project_key: &str,
1545) -> Option<(PathBuf, Option<String>)> {
1546    for _ in 0..5 {
1547        if let Some(generation) = read_inspect_pointer(inspect_dir, project_key) {
1548            let path = inspect_dir.join(&generation);
1549            if path.is_file() {
1550                return Some((path, Some(generation)));
1551            }
1552            std::thread::sleep(Duration::from_millis(5));
1553            continue;
1554        }
1555        let legacy = inspect_legacy_sqlite_path(inspect_dir, project_key);
1556        return legacy.is_file().then_some((legacy, None));
1557    }
1558    None
1559}
1560
1561fn inspect_generation_file_name(project_key: &str) -> String {
1562    format!(
1563        "{project_key}.g{}.{}.sqlite",
1564        now_nanos(),
1565        std::process::id()
1566    )
1567}
1568
1569fn inspect_pointer_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1570    inspect_dir.join(format!("{project_key}.current"))
1571}
1572
1573fn inspect_legacy_sqlite_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1574    inspect_dir.join(format!("{project_key}.sqlite"))
1575}
1576
1577fn read_inspect_pointer(inspect_dir: &Path, project_key: &str) -> Option<String> {
1578    let text = std::fs::read_to_string(inspect_pointer_path(inspect_dir, project_key)).ok()?;
1579    let name = text.trim();
1580    (!name.is_empty()).then(|| name.to_string())
1581}
1582
1583fn publish_inspect_pointer(
1584    inspect_dir: &Path,
1585    project_key: &str,
1586    generation: &str,
1587) -> Result<(), InspectCacheError> {
1588    let pointer = inspect_pointer_path(inspect_dir, project_key);
1589    let tmp = inspect_dir.join(format!(
1590        "{project_key}.current.tmp.{}.{}",
1591        std::process::id(),
1592        now_nanos()
1593    ));
1594    {
1595        use std::io::Write as _;
1596        let mut file = std::fs::File::create(&tmp)?;
1597        file.write_all(generation.as_bytes())?;
1598        file.write_all(b"\n")?;
1599        file.sync_all()?;
1600    }
1601    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
1602        let _ = std::fs::remove_file(&tmp);
1603        return Err(error.into());
1604    }
1605    crate::fs_lock::sync_parent(&pointer);
1606    gc_old_inspect_generations(inspect_dir, project_key, generation);
1607    Ok(())
1608}
1609
1610fn gc_old_inspect_generations(inspect_dir: &Path, project_key: &str, current: &str) {
1611    let Ok(entries) = std::fs::read_dir(inspect_dir) else {
1612        return;
1613    };
1614    let prefix = format!("{project_key}.g");
1615    for entry in entries.flatten() {
1616        let name = entry.file_name().to_string_lossy().to_string();
1617        if name == current || !name.starts_with(&prefix) || !name.ends_with(".sqlite") {
1618            continue;
1619        }
1620        let path = entry.path();
1621        let _ = std::fs::remove_file(&path);
1622        for suffix in INSPECT_SQLITE_SIDECAR_SUFFIXES {
1623            let _ = std::fs::remove_file(PathBuf::from(format!("{}{suffix}", path.display())));
1624        }
1625    }
1626}
1627
1628#[derive(Clone, Copy, Debug, Default)]
1629pub(crate) struct InspectScopeSweepSummary {
1630    removed: usize,
1631    bytes: u64,
1632    skipped_live: usize,
1633    skipped_marker: usize,
1634    scanned: usize,
1635    budget_exhausted: bool,
1636}
1637
1638#[derive(Clone, Copy, Debug, Default)]
1639struct InspectScopeFileStats {
1640    newest_file: Option<SystemTime>,
1641    bytes: u64,
1642}
1643
1644enum InspectScopeWalk {
1645    Complete(InspectScopeFileStats),
1646    BudgetExceeded,
1647    Failed,
1648}
1649
1650#[derive(Clone, Copy, Debug, Default)]
1651enum InspectScopeCandidateResult {
1652    #[default]
1653    Processed,
1654    Removed {
1655        bytes: u64,
1656    },
1657    SkippedLive,
1658    SkippedMarker,
1659    BudgetExceeded,
1660}
1661
1662/// Process-wide inspect-scope GC. The caller supplies the scope keys currently
1663/// bound in this process; the cursor lets the same publication cadence resume
1664/// after a large or slow first-level directory exceeds its wall-clock budget.
1665pub(crate) fn sweep_inspect_scope_dirs(
1666    inspect_root: &Path,
1667    live_scope_keys: &HashSet<String>,
1668) -> InspectScopeSweepSummary {
1669    sweep_inspect_scope_dirs_with_limits(
1670        inspect_root,
1671        live_scope_keys,
1672        INSPECT_SCOPE_SWEEP_BUDGET,
1673        usize::MAX,
1674    )
1675}
1676
1677fn sweep_inspect_scope_dirs_with_limits(
1678    inspect_root: &Path,
1679    live_scope_keys: &HashSet<String>,
1680    wall_clock_budget: Duration,
1681    entry_limit: usize,
1682) -> InspectScopeSweepSummary {
1683    let started = Instant::now();
1684    let deadline = started + wall_clock_budget;
1685    let mut summary = InspectScopeSweepSummary::default();
1686    let mut entries = match fs::read_dir(inspect_root) {
1687        Ok(entries) => entries
1688            .filter_map(Result::ok)
1689            .filter_map(|entry| {
1690                let file_type = entry.file_type().ok()?;
1691                file_type.is_dir().then(|| {
1692                    (
1693                        entry.file_name().to_string_lossy().to_string(),
1694                        entry.path(),
1695                    )
1696                })
1697            })
1698            .collect::<Vec<_>>(),
1699        Err(_) => Vec::new(),
1700    };
1701    entries.sort_by(|left, right| left.0.cmp(&right.0));
1702
1703    let cursor_store = INSPECT_SCOPE_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
1704    let last_name = cursor_store.lock().ok().and_then(|cursors| {
1705        cursors
1706            .get(inspect_root)
1707            .and_then(|cursor| cursor.last_name.clone())
1708    });
1709    let start_index = last_name
1710        .as_deref()
1711        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
1712        .unwrap_or(0);
1713    if start_index > 0 {
1714        entries.rotate_left(start_index);
1715    }
1716
1717    let mut cursor_name = last_name;
1718    for (processed, (name, path)) in entries.into_iter().enumerate() {
1719        if processed >= entry_limit || started.elapsed() >= wall_clock_budget {
1720            summary.budget_exhausted = true;
1721            break;
1722        }
1723        match inspect_scope_candidate(&path, &name, live_scope_keys, deadline) {
1724            InspectScopeCandidateResult::BudgetExceeded => {
1725                summary.budget_exhausted = true;
1726                break;
1727            }
1728            InspectScopeCandidateResult::Processed => {}
1729            InspectScopeCandidateResult::Removed { bytes } => {
1730                summary.removed += 1;
1731                summary.bytes = summary.bytes.saturating_add(bytes);
1732            }
1733            InspectScopeCandidateResult::SkippedLive => summary.skipped_live += 1,
1734            InspectScopeCandidateResult::SkippedMarker => summary.skipped_marker += 1,
1735        }
1736        summary.scanned += 1;
1737        cursor_name = Some(name);
1738    }
1739
1740    if let Ok(mut cursors) = cursor_store.lock() {
1741        let cursor = cursors.entry(inspect_root.to_path_buf()).or_default();
1742        cursor.last_name = summary.budget_exhausted.then_some(cursor_name).flatten();
1743        if !summary.budget_exhausted {
1744            cursor.last_name = None;
1745        }
1746    }
1747
1748    if summary.removed > 0 {
1749        crate::fs_lock::sync_parent(inspect_root);
1750    }
1751    crate::slog_info!(
1752        "inspect scope cache sweep root={} removed={} bytes={} skipped_live={} skipped_marker={} scanned={} budget_exhausted={}",
1753        inspect_root.display(),
1754        summary.removed,
1755        summary.bytes,
1756        summary.skipped_live,
1757        summary.skipped_marker,
1758        summary.scanned,
1759        summary.budget_exhausted
1760    );
1761    summary
1762}
1763
1764fn inspect_scope_candidate(
1765    scope_dir: &Path,
1766    scope_name: &str,
1767    live_scope_keys: &HashSet<String>,
1768    deadline: Instant,
1769) -> InspectScopeCandidateResult {
1770    if live_scope_keys.contains(scope_name) {
1771        return InspectScopeCandidateResult::SkippedLive;
1772    }
1773
1774    let stats = match inspect_scope_file_stats(scope_dir, deadline) {
1775        InspectScopeWalk::Complete(stats) => stats,
1776        InspectScopeWalk::BudgetExceeded => return InspectScopeCandidateResult::BudgetExceeded,
1777        InspectScopeWalk::Failed => return InspectScopeCandidateResult::Processed,
1778    };
1779    let Some(newest_file) = stats.newest_file else {
1780        return InspectScopeCandidateResult::Processed;
1781    };
1782    let now = SystemTime::now();
1783    if now.duration_since(newest_file).unwrap_or(Duration::ZERO) < INSPECT_SCOPE_MIN_AGE {
1784        return InspectScopeCandidateResult::Processed;
1785    }
1786
1787    if crate::root_cache::sweep_all_read_markers(scope_dir).protected {
1788        return InspectScopeCandidateResult::SkippedMarker;
1789    }
1790
1791    match fs::remove_dir_all(scope_dir) {
1792        Ok(()) => InspectScopeCandidateResult::Removed { bytes: stats.bytes },
1793        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !scope_dir.exists() => {
1794            InspectScopeCandidateResult::Removed { bytes: stats.bytes }
1795        }
1796        // A pinned directory (notably ERROR_SHARING_VIOLATION on Windows) is
1797        // left for the next cursor pass; all deletion errors are best effort.
1798        Err(_) => InspectScopeCandidateResult::Processed,
1799    }
1800}
1801
1802fn inspect_scope_file_stats(scope_dir: &Path, deadline: Instant) -> InspectScopeWalk {
1803    if Instant::now() >= deadline {
1804        return InspectScopeWalk::BudgetExceeded;
1805    }
1806    let entries = match fs::read_dir(scope_dir) {
1807        Ok(entries) => entries,
1808        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1809            return InspectScopeWalk::Complete(InspectScopeFileStats::default())
1810        }
1811        Err(_) => return InspectScopeWalk::Failed,
1812    };
1813    let mut stats = InspectScopeFileStats::default();
1814    for entry in entries {
1815        if Instant::now() >= deadline {
1816            return InspectScopeWalk::BudgetExceeded;
1817        }
1818        let entry = match entry {
1819            Ok(entry) => entry,
1820            Err(_) => return InspectScopeWalk::Failed,
1821        };
1822        let name = entry.file_name();
1823        if name == "readers" {
1824            // Marker heartbeats describe readers, not cache freshness. Marker
1825            // liveness is checked separately after the age predicate.
1826            continue;
1827        }
1828        let file_type = match entry.file_type() {
1829            Ok(file_type) => file_type,
1830            Err(_) => return InspectScopeWalk::Failed,
1831        };
1832        if file_type.is_dir() {
1833            match inspect_scope_file_stats(&entry.path(), deadline) {
1834                InspectScopeWalk::Complete(child) => merge_scope_file_stats(&mut stats, child),
1835                other => return other,
1836            }
1837            continue;
1838        }
1839        if !file_type.is_file() {
1840            continue;
1841        }
1842        let metadata = match entry.metadata() {
1843            Ok(metadata) => metadata,
1844            Err(_) => return InspectScopeWalk::Failed,
1845        };
1846        stats.bytes = stats.bytes.saturating_add(metadata.len());
1847        let Some(modified) = metadata.modified().ok() else {
1848            return InspectScopeWalk::Failed;
1849        };
1850        if stats.newest_file.is_none_or(|newest| modified > newest) {
1851            stats.newest_file = Some(modified);
1852        }
1853    }
1854    InspectScopeWalk::Complete(stats)
1855}
1856
1857fn merge_scope_file_stats(stats: &mut InspectScopeFileStats, child: InspectScopeFileStats) {
1858    stats.bytes = stats.bytes.saturating_add(child.bytes);
1859    if child.newest_file > stats.newest_file {
1860        stats.newest_file = child.newest_file;
1861    }
1862}
1863
1864#[cfg(test)]
1865fn reset_inspect_scope_sweep_cursor_for_test() {
1866    if let Some(cursors) = INSPECT_SCOPE_SWEEP_CURSORS.get() {
1867        cursors.lock().unwrap().clear();
1868    }
1869}
1870
1871fn acquire_writer_lease(
1872    inspect_dir: &Path,
1873    project_key: &str,
1874    project_root: &Path,
1875) -> Result<Option<Arc<crate::root_cache::WriterLease>>, InspectCacheError> {
1876    crate::root_cache::WriterLease::acquire_shared_with_timeout(
1877        crate::root_cache::RootCacheDomain::Inspect,
1878        inspect_dir,
1879        project_key,
1880        project_root,
1881        INSPECT_WRITER_LEASE_TIMEOUT,
1882    )
1883    .map_err(|error| match error {
1884        crate::fs_lock::AcquireError::Timeout => InspectCacheError::WriterLeaseTimeout,
1885        crate::fs_lock::AcquireError::Io(error) => InspectCacheError::Io(error),
1886    })
1887}
1888
1889fn open_readonly_connection(path: &Path) -> Result<Connection, InspectCacheError> {
1890    let uri = sqlite_readonly_uri(path);
1891    let conn = Connection::open_with_flags(
1892        &uri,
1893        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
1894    )?;
1895    conn.pragma_update(None, "synchronous", "NORMAL")?;
1896    conn.busy_timeout(reader_busy_timeout())?;
1897    conn.execute_batch("PRAGMA query_only=ON;")?;
1898    Ok(conn)
1899}
1900
1901fn reader_busy_timeout() -> Duration {
1902    let jitter = (now_nanos() % 500) as u64;
1903    Duration::from_millis(250 + jitter)
1904}
1905
1906fn sqlite_readonly_uri(path: &Path) -> String {
1907    let raw = path.to_string_lossy().replace('\\', "/");
1908    let encoded = percent_encode_sqlite_uri_path(&raw);
1909    if raw.starts_with('/') {
1910        format!("file://{encoded}?mode=ro")
1911    } else if raw.as_bytes().get(1) == Some(&b':') {
1912        format!("file:///{encoded}?mode=ro")
1913    } else {
1914        format!("file:{encoded}?mode=ro")
1915    }
1916}
1917
1918fn percent_encode_sqlite_uri_path(path: &str) -> String {
1919    let mut encoded = String::with_capacity(path.len());
1920    for byte in path.bytes() {
1921        match byte {
1922            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
1923                encoded.push(byte as char)
1924            }
1925            _ => encoded.push_str(&format!("%{byte:02X}")),
1926        }
1927    }
1928    encoded
1929}
1930
1931fn configure_connection(conn: &Connection) -> Result<(), InspectCacheError> {
1932    // WAL selection can require a database lock. Install the busy handler first
1933    // so a concurrent inspect reader or writer is waited out instead of making
1934    // the cache opener fail immediately on Windows.
1935    conn.busy_timeout(Duration::from_secs(5))?;
1936    conn.pragma_update(None, "journal_mode", "WAL")?;
1937    // Inspect facts are fully recomputable, so NORMAL avoids a full fsync per
1938    // commit while WAL transactions and writer-lease publication stay atomic.
1939    conn.pragma_update(None, "synchronous", "NORMAL")?;
1940    Ok(())
1941}
1942
1943fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1944    conn.execute_batch(
1945        "CREATE TABLE IF NOT EXISTS tier2_contributions (
1946            category        TEXT NOT NULL,
1947            project_key     TEXT NOT NULL,
1948            file_path       TEXT NOT NULL,
1949            file_mtime_ns   INTEGER NOT NULL,
1950            file_size       INTEGER NOT NULL,
1951            file_hash       TEXT NOT NULL,
1952            contribution    BLOB NOT NULL,
1953            generated_at    INTEGER NOT NULL,
1954            PRIMARY KEY (category, project_key, file_path)
1955        );
1956
1957        CREATE TABLE IF NOT EXISTS tier2_aggregates (
1958            category        TEXT NOT NULL,
1959            project_key     TEXT NOT NULL,
1960            contribution_set_hash TEXT NOT NULL,
1961            aggregate       BLOB NOT NULL,
1962            generated_at    INTEGER NOT NULL,
1963            PRIMARY KEY (category, project_key)
1964        );
1965
1966        CREATE TABLE IF NOT EXISTS tier2_meta (
1967            category        TEXT NOT NULL,
1968            project_key     TEXT NOT NULL,
1969            last_full_run   INTEGER NOT NULL,
1970            PRIMARY KEY (category, project_key)
1971        );",
1972    )?;
1973    Ok(())
1974}
1975
1976fn existing_contribution_paths(
1977    conn: &Connection,
1978    category: InspectCategory,
1979    project_key: &str,
1980) -> Result<Vec<String>, InspectCacheError> {
1981    let mut stmt = conn.prepare(
1982        "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1983    )?;
1984    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1985        row.get::<_, String>(0)
1986    })?;
1987    rows.collect::<Result<Vec<_>, _>>()
1988        .map_err(InspectCacheError::from)
1989}
1990
1991fn contribution_set_hash_with_conn(
1992    conn: &Connection,
1993    category: InspectCategory,
1994    project_key: &str,
1995    project_root: &Path,
1996    config: Option<&Config>,
1997) -> Result<String, InspectCacheError> {
1998    contribution_set_hash_with_version(
1999        conn,
2000        category,
2001        project_key,
2002        project_root,
2003        config,
2004        TIER2_CONTRIBUTION_CACHE_VERSION,
2005    )
2006}
2007
2008fn contribution_set_hash_with_version(
2009    conn: &Connection,
2010    category: InspectCategory,
2011    project_key: &str,
2012    project_root: &Path,
2013    config: Option<&Config>,
2014    cache_version: u32,
2015) -> Result<String, InspectCacheError> {
2016    let mut stmt = conn.prepare(
2017        "SELECT file_path, file_hash FROM tier2_contributions \
2018         WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
2019    )?;
2020    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
2021        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2022    })?;
2023
2024    let mut hasher = blake3::Hasher::new();
2025    hasher.update(b"tier2-contributions\0");
2026    hasher.update(&cache_version.to_le_bytes());
2027    hasher.update(b"\0");
2028    for row in rows {
2029        let (file_path, file_hash) = row?;
2030        hasher.update(file_path.as_bytes());
2031        hasher.update(b"\0");
2032        hasher.update(file_hash.as_bytes());
2033        hasher.update(b"\0");
2034    }
2035    update_manifest_fingerprint_hash(&mut hasher, project_root)?;
2036    if matches!(
2037        category,
2038        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2039    ) {
2040        update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
2041    }
2042    update_inspect_config_fingerprint_hash(&mut hasher, category, config);
2043    Ok(hasher.finalize().to_hex().to_string())
2044}
2045
2046fn update_inspect_config_fingerprint_hash(
2047    hasher: &mut blake3::Hasher,
2048    category: InspectCategory,
2049    config: Option<&Config>,
2050) {
2051    if category != InspectCategory::Duplicates {
2052        return;
2053    }
2054
2055    hasher.update(b"inspect.duplicates.expected_mirrors\0");
2056    let Some(config) = config else {
2057        return;
2058    };
2059    for pair in &config.inspect.duplicates.expected_mirrors {
2060        hasher.update(pair[0].as_bytes());
2061        hasher.update(b"\0");
2062        hasher.update(pair[1].as_bytes());
2063        hasher.update(b"\0");
2064    }
2065}
2066
2067fn update_resolver_config_fingerprint_hash(
2068    hasher: &mut blake3::Hasher,
2069    project_root: &Path,
2070) -> Result<(), InspectCacheError> {
2071    let manifest_root =
2072        fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2073    hasher.update(b"ts-js-resolver-configs\0");
2074    for config in collect_resolver_config_dependency_files(project_root) {
2075        let relative_path = config
2076            .strip_prefix(&manifest_root)
2077            .unwrap_or(config.as_path())
2078            .to_string_lossy()
2079            .replace('\\', "/");
2080        let content_hash = blake3::hash(&fs::read(&config)?);
2081        hasher.update(relative_path.as_bytes());
2082        hasher.update(b"\0");
2083        hasher.update(content_hash.as_bytes());
2084        hasher.update(b"\0");
2085    }
2086    Ok(())
2087}
2088
2089struct ResolverConfigDependency {
2090    path: PathBuf,
2091    follow_extends: bool,
2092}
2093
2094impl ResolverConfigDependency {
2095    fn resolver_config(path: PathBuf) -> Self {
2096        Self {
2097            path,
2098            follow_extends: true,
2099        }
2100    }
2101
2102    fn hashed_file(path: PathBuf) -> Self {
2103        Self {
2104            path,
2105            follow_extends: false,
2106        }
2107    }
2108}
2109
2110fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
2111    let mut configs = walk_resolver_config_files(project_root);
2112    let mut pending = configs.iter().cloned().collect::<Vec<_>>();
2113    let mut queued = configs.clone();
2114    while let Some(config) = pending.pop() {
2115        for dependency in resolver_config_extends_targets(&config, project_root) {
2116            let ResolverConfigDependency {
2117                path,
2118                follow_extends,
2119            } = dependency;
2120            configs.insert(path.clone());
2121            if follow_extends && queued.insert(path.clone()) {
2122                pending.push(path);
2123            }
2124        }
2125    }
2126    configs
2127}
2128
2129fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
2130    // Prevent a disappearing child mount from making ReadDir::drop abort on ENXIO.
2131    let walker = ignore::WalkBuilder::new(project_root)
2132        .same_file_system(true)
2133        .hidden(true)
2134        .git_ignore(true)
2135        .git_global(true)
2136        .git_exclude(true)
2137        .add_custom_ignore_filename(".aftignore")
2138        .filter_entry(|entry| {
2139            let name = entry.file_name().to_string_lossy();
2140            if entry
2141                .file_type()
2142                .is_some_and(|file_type| file_type.is_dir())
2143            {
2144                return !matches!(
2145                    name.as_ref(),
2146                    "node_modules"
2147                        | "target"
2148                        | "venv"
2149                        | ".venv"
2150                        | ".git"
2151                        | "__pycache__"
2152                        | ".tox"
2153                        | "dist"
2154                        | "build"
2155                );
2156            }
2157            true
2158        })
2159        .build();
2160
2161    walker
2162        .filter_map(Result::ok)
2163        .filter(|entry| {
2164            entry
2165                .file_type()
2166                .is_some_and(|file_type| file_type.is_file())
2167        })
2168        .map(|entry| entry.into_path())
2169        .filter(|path| {
2170            path.file_name()
2171                .and_then(|name| name.to_str())
2172                .is_some_and(is_resolver_config_file_name)
2173        })
2174        .filter_map(canonical_file_path)
2175        .collect()
2176}
2177
2178fn is_resolver_config_file_name(name: &str) -> bool {
2179    name == "tsconfig.json"
2180        || name == "jsconfig.json"
2181        || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
2182            && name.ends_with(".json"))
2183}
2184
2185fn resolver_config_extends_targets(
2186    config: &Path,
2187    project_root: &Path,
2188) -> Vec<ResolverConfigDependency> {
2189    let Ok(source) = fs::read_to_string(config) else {
2190        return Vec::new();
2191    };
2192    let Ok(value) = parse_resolver_config_json(&source) else {
2193        return Vec::new();
2194    };
2195
2196    let mut specs = Vec::new();
2197    collect_extends_specs(value.get("extends"), &mut specs);
2198    specs
2199        .into_iter()
2200        .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
2201        .collect()
2202}
2203
2204fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
2205    serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
2206}
2207
2208fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
2209    match value {
2210        Some(serde_json::Value::String(spec)) => specs.push(spec),
2211        Some(serde_json::Value::Array(values)) => {
2212            for value in values {
2213                collect_extends_specs(Some(value), specs);
2214            }
2215        }
2216        _ => {}
2217    }
2218}
2219
2220fn resolve_resolver_config_extends(
2221    config: &Path,
2222    project_root: &Path,
2223    spec: &str,
2224) -> Vec<ResolverConfigDependency> {
2225    let config_dir = config.parent().unwrap_or(project_root);
2226    let spec_path = Path::new(spec);
2227    if spec_path.is_absolute() || spec.starts_with('.') {
2228        return resolver_config_extends_target(&config_dir.join(spec_path))
2229            .map(ResolverConfigDependency::resolver_config)
2230            .into_iter()
2231            .collect();
2232    }
2233
2234    node_modules_resolver_config_dependencies(config_dir, project_root, spec)
2235}
2236
2237fn node_modules_resolver_config_dependencies(
2238    config_dir: &Path,
2239    project_root: &Path,
2240    spec: &str,
2241) -> Vec<ResolverConfigDependency> {
2242    let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2243    let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
2244    let enforce_project_boundary = config_dir.starts_with(&boundary);
2245    let is_bare_package = is_bare_package_extends_spec(spec);
2246    let mut dependencies = Vec::new();
2247    for ancestor in config_dir.ancestors() {
2248        let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
2249        if enforce_project_boundary && !ancestor.starts_with(&boundary) {
2250            break;
2251        }
2252        let package_dir = ancestor.join("node_modules").join(spec);
2253        let mut ancestor_dependencies = Vec::new();
2254        if is_bare_package {
2255            if let Some(mut package_dependencies) =
2256                package_json_resolver_config_dependencies(&package_dir)
2257            {
2258                let has_resolver_config = package_dependencies
2259                    .iter()
2260                    .any(|dependency| dependency.follow_extends);
2261                ancestor_dependencies.append(&mut package_dependencies);
2262                if has_resolver_config {
2263                    dependencies.extend(ancestor_dependencies);
2264                    return dependencies;
2265                }
2266            }
2267        }
2268        if let Some(target) = resolver_config_extends_target(&package_dir) {
2269            ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
2270            dependencies.extend(ancestor_dependencies);
2271            return dependencies;
2272        }
2273        dependencies.extend(ancestor_dependencies);
2274    }
2275    dependencies
2276}
2277
2278fn package_json_resolver_config_dependencies(
2279    package_dir: &Path,
2280) -> Option<Vec<ResolverConfigDependency>> {
2281    let package_json = canonical_file_path(package_dir.join("package.json"))?;
2282    let package_root = package_json
2283        .parent()
2284        .map(Path::to_path_buf)
2285        .unwrap_or_else(|| package_dir.to_path_buf());
2286    let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
2287
2288    let Ok(source) = fs::read_to_string(&package_json) else {
2289        return Some(dependencies);
2290    };
2291    let Ok(value) = parse_resolver_config_json(&source) else {
2292        return Some(dependencies);
2293    };
2294    let selected_config = value
2295        .get("tsconfig")
2296        .and_then(serde_json::Value::as_str)
2297        .map(str::trim)
2298        .filter(|value| !value.is_empty())
2299        .unwrap_or("tsconfig.json");
2300    if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
2301        dependencies.push(ResolverConfigDependency::resolver_config(target));
2302    }
2303
2304    Some(dependencies)
2305}
2306
2307fn is_bare_package_extends_spec(spec: &str) -> bool {
2308    let mut parts = spec.split('/').filter(|part| !part.is_empty());
2309    let Some(first) = parts.next() else {
2310        return false;
2311    };
2312    if first.starts_with('@') {
2313        parts.next().is_some() && parts.next().is_none()
2314    } else {
2315        parts.next().is_none()
2316    }
2317}
2318
2319fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
2320    resolver_config_extends_candidates(base)
2321        .into_iter()
2322        .find_map(canonical_file_path)
2323}
2324
2325fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
2326    let mut candidates = vec![base.to_path_buf()];
2327    if base.extension().is_none() {
2328        candidates.push(base.with_extension("json"));
2329        candidates.push(base.join("tsconfig.json"));
2330    }
2331    candidates
2332}
2333
2334fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
2335    if !path.is_file() {
2336        return None;
2337    }
2338    Some(fs::canonicalize(&path).unwrap_or(path))
2339}
2340
2341fn update_manifest_fingerprint_hash(
2342    hasher: &mut blake3::Hasher,
2343    project_root: &Path,
2344) -> Result<(), InspectCacheError> {
2345    // Entry-point manifests are normalized snapshots. A bare canonical root is
2346    // verbatim (`\\?\`) on Windows, so mixing it with those non-verbatim
2347    // manifests makes `strip_prefix` hash an absolute path instead of its
2348    // project-relative name.
2349    let manifest_root = crate::inspect::job::canonicalize_normalized(project_root);
2350    hasher.update(b"entry-point-manifests\0");
2351    for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
2352        let relative_path = manifest
2353            .strip_prefix(&manifest_root)
2354            .unwrap_or(manifest.as_path())
2355            .to_string_lossy()
2356            .replace('\\', "/");
2357        let content_hash = blake3::hash(&fs::read(&manifest)?);
2358        hasher.update(relative_path.as_bytes());
2359        hasher.update(b"\0");
2360        hasher.update(content_hash.as_bytes());
2361        hasher.update(b"\0");
2362    }
2363    Ok(())
2364}
2365
2366fn relative_string(project_root: &Path, path: &Path) -> String {
2367    if let Ok(relative) = path.strip_prefix(project_root) {
2368        return relative.to_string_lossy().to_string();
2369    }
2370
2371    if let (Ok(canonical_root), Ok(canonical_path)) =
2372        (fs::canonicalize(project_root), fs::canonicalize(path))
2373    {
2374        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
2375            return relative.to_string_lossy().to_string();
2376        }
2377    }
2378
2379    path.to_string_lossy().to_string()
2380}
2381
2382fn system_time_to_ns(time: SystemTime) -> i64 {
2383    let nanos = time
2384        .duration_since(UNIX_EPOCH)
2385        .unwrap_or_else(|_| Duration::from_secs(0))
2386        .as_nanos();
2387    nanos.min(i64::MAX as u128) as i64
2388}
2389
2390fn ns_to_system_time(value: i64) -> SystemTime {
2391    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
2392}
2393
2394fn hash_to_hex(hash: blake3::Hash) -> String {
2395    hash.to_hex().to_string()
2396}
2397
2398fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
2399    if value.len() != 64 {
2400        return Err(InspectCacheError::InvalidHash(value.to_string()));
2401    }
2402    let mut bytes = [0u8; 32];
2403    for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
2404        let hex = std::str::from_utf8(chunk)
2405            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2406        bytes[index] = u8::from_str_radix(hex, 16)
2407            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2408    }
2409    Ok(blake3::Hash::from_bytes(bytes))
2410}
2411
2412fn unix_seconds_now() -> i64 {
2413    SystemTime::now()
2414        .duration_since(UNIX_EPOCH)
2415        .unwrap_or_else(|_| Duration::from_secs(0))
2416        .as_secs()
2417        .min(i64::MAX as u64) as i64
2418}
2419
2420fn now_nanos() -> u128 {
2421    SystemTime::now()
2422        .duration_since(UNIX_EPOCH)
2423        .unwrap_or(Duration::ZERO)
2424        .as_nanos()
2425}
2426
2427#[cfg(test)]
2428mod tests {
2429    use super::*;
2430    use std::cell::Cell;
2431    use std::collections::HashSet;
2432    use std::fs;
2433    use std::path::{Path, PathBuf};
2434
2435    fn collect_freshness(path: &Path) -> FileFreshness {
2436        crate::cache_freshness::collect(path).unwrap()
2437    }
2438
2439    #[test]
2440    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
2441        assert_eq!(
2442            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
2443            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
2444        );
2445    }
2446
2447    #[test]
2448    fn held_writer_lease_fails_within_the_inspect_deadline() {
2449        let temp = tempfile::tempdir().expect("create temporary cache root");
2450        let project_root = temp.path().join("project");
2451        let inspect_dir = temp.path().join("inspect");
2452        fs::create_dir_all(&project_root).expect("create project root");
2453        let project_key = crate::path_identity::project_scope_key(&project_root);
2454        crate::root_cache::configure_artifact_access(&project_root, "shared", false);
2455        let project_dir = project_inspect_dir(inspect_dir.clone(), &project_key);
2456        fs::create_dir_all(&project_dir).expect("create project inspect directory");
2457
2458        // Hold the actual lease file through an independent handle so the test
2459        // exercises the contended wait rather than an uncontended fast path.
2460        let held = crate::fs_lock::try_acquire(
2461            &crate::root_cache::writer_lease_path(&project_dir),
2462            Duration::ZERO,
2463        )
2464        .expect("hold inspect writer lease");
2465        let started = Instant::now();
2466        let error = match InspectCache::open(inspect_dir.clone(), project_root.clone()) {
2467            Err(error) => error,
2468            Ok(_) => panic!("contended inspect cache must fail honestly"),
2469        };
2470        assert!(matches!(error, InspectCacheError::WriterLeaseTimeout));
2471        assert!(
2472            started.elapsed() <= INSPECT_WRITER_LEASE_TIMEOUT + Duration::from_secs(1),
2473            "writer lease acquisition exceeded its deadline"
2474        );
2475
2476        drop(held);
2477        InspectCache::open(inspect_dir, project_root)
2478            .expect("cache opens after the competing writer releases");
2479    }
2480
2481    #[cfg(windows)]
2482    fn verbatim_path(path: &Path) -> PathBuf {
2483        PathBuf::from(format!(r"\\?\{}", path.display()))
2484    }
2485
2486    #[cfg(windows)]
2487    #[test]
2488    fn manifest_fingerprint_uses_relative_paths_for_verbatim_roots() {
2489        let temp = tempfile::tempdir().expect("create temporary project");
2490        let project = temp.path().join("project");
2491        fs::create_dir_all(&project).expect("create project directory");
2492        let manifest = project.join("package.json");
2493        let content = br#"{"name":"fixture"}"#;
2494        fs::write(&manifest, content).expect("write package manifest");
2495
2496        let normalized_project = crate::inspect::job::canonicalize_normalized(&project);
2497        let mut actual = blake3::Hasher::new();
2498        update_manifest_fingerprint_hash(&mut actual, &verbatim_path(&normalized_project))
2499            .expect("hash entry-point manifest");
2500
2501        let mut expected = blake3::Hasher::new();
2502        expected.update(b"entry-point-manifests\0");
2503        expected.update(b"package.json\0");
2504        expected.update(blake3::hash(content).as_bytes());
2505        expected.update(b"\0");
2506
2507        assert_eq!(actual.finalize(), expected.finalize());
2508    }
2509
2510    #[test]
2511    fn inspect_cache_writer_uses_normal_synchronous_mode() {
2512        let temp = tempfile::tempdir().unwrap();
2513        let project_root = temp.path().join("checkout");
2514        fs::create_dir_all(&project_root).unwrap();
2515        let cache = InspectCache::open(temp.path().join("inspect"), project_root).unwrap();
2516        let conn = cache.conn.lock().unwrap();
2517        let synchronous: i64 = conn
2518            .query_row("PRAGMA synchronous", [], |row| row.get(0))
2519            .unwrap();
2520        assert_eq!(synchronous, 1, "SQLite NORMAL mode is numeric value 1");
2521    }
2522
2523    #[test]
2524    fn inspect_cache_publishes_pointer_generation_and_reopens_after_crash_redo() {
2525        let temp = tempfile::tempdir().unwrap();
2526        let project_root = temp.path().join("checkout");
2527        fs::create_dir_all(&project_root).unwrap();
2528        let inspect_dir = temp.path().join("inspect");
2529        let project_key = crate::path_identity::project_scope_key(&project_root);
2530
2531        fs::create_dir_all(inspect_dir.join("leftover-nonempty-dir")).unwrap();
2532        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2533        assert!(cache.sqlite_path().is_file());
2534        assert_ne!(
2535            cache.sqlite_path(),
2536            inspect_dir
2537                .join(&project_key)
2538                .join(format!("{project_key}.sqlite"))
2539        );
2540        let pointer = inspect_dir
2541            .join(&project_key)
2542            .join(format!("{project_key}.current"));
2543        let generation = fs::read_to_string(&pointer).unwrap();
2544        assert_eq!(
2545            inspect_dir.join(&project_key).join(generation.trim()),
2546            cache.sqlite_path()
2547        );
2548        drop(cache);
2549
2550        let reopened = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2551        assert_eq!(
2552            inspect_dir.join(&project_key).join(generation.trim()),
2553            reopened.sqlite_path()
2554        );
2555        let readonly = InspectCache::open_readonly(inspect_dir, project_root)
2556            .unwrap()
2557            .expect("pointer-published inspect cache should reopen read-only");
2558        assert_eq!(readonly.inner.sqlite_path(), reopened.sqlite_path());
2559    }
2560
2561    fn write_aged_scope_file(scope_dir: &Path, name: &str) {
2562        fs::create_dir_all(scope_dir.join("nested")).unwrap();
2563        let path = scope_dir.join("nested").join(name);
2564        fs::write(&path, b"old inspect payload").unwrap();
2565        let old = SystemTime::now()
2566            .checked_sub(INSPECT_SCOPE_MIN_AGE + Duration::from_secs(60))
2567            .unwrap();
2568        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(old)).unwrap();
2569    }
2570
2571    #[test]
2572    fn inspect_scope_sweep_reaps_aged_and_keeps_fresh_scope() {
2573        reset_inspect_scope_sweep_cursor_for_test();
2574        let temp = tempfile::tempdir().unwrap();
2575        let inspect_root = temp.path().join("inspect");
2576        let aged = inspect_root.join("aged-scope");
2577        let fresh = inspect_root.join("fresh-scope");
2578        write_aged_scope_file(&aged, "facts.sqlite");
2579        fs::create_dir_all(&fresh).unwrap();
2580        fs::write(fresh.join("facts.sqlite"), b"fresh inspect payload").unwrap();
2581
2582        let summary = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2583
2584        assert_eq!(summary.removed, 1);
2585        assert!(summary.bytes > 0, "reaped cache bytes must be reported");
2586        assert!(!aged.exists(), "an aged scope directory must be reaped");
2587        assert!(fresh.is_dir(), "a fresh scope directory must survive");
2588        // This assertion is intentionally a negative check: if the age
2589        // predicate is accidentally inverted, the fresh directory must still
2590        // be kept and this assertion will fail.
2591    }
2592
2593    #[test]
2594    fn inspect_scope_sweep_keeps_aged_live_scope_key() {
2595        reset_inspect_scope_sweep_cursor_for_test();
2596        let temp = tempfile::tempdir().unwrap();
2597        let inspect_root = temp.path().join("inspect");
2598        let project_root = temp.path().join("checkout");
2599        fs::create_dir_all(&project_root).unwrap();
2600        let scope_key = crate::path_identity::project_scope_key(&project_root);
2601        let live = inspect_root.join(&scope_key);
2602        write_aged_scope_file(&live, "facts.sqlite");
2603        crate::root_cache::register_live_scope(temp.path(), &project_root);
2604        let live_keys = crate::root_cache::live_scope_keys_for_storage(temp.path());
2605
2606        let summary = sweep_inspect_scope_dirs(&inspect_root, &live_keys);
2607
2608        crate::root_cache::unregister_live_scope(temp.path(), &project_root);
2609        assert_eq!(summary.skipped_live, 1);
2610        assert!(live.is_dir(), "a bound root's scope directory must survive");
2611        // This assertion is intentionally a negative check: if the live-key
2612        // exemption is removed, the bound root could be deleted and this
2613        // assertion will fail.
2614    }
2615
2616    #[test]
2617    fn inspect_scope_sweep_keeps_live_marker_then_reaps_stale_marker_scope() {
2618        reset_inspect_scope_sweep_cursor_for_test();
2619        let temp = tempfile::tempdir().unwrap();
2620        let inspect_root = temp.path().join("inspect");
2621        let scope = inspect_root.join("marked-scope");
2622        write_aged_scope_file(&scope, "facts.sqlite");
2623        let marker = crate::root_cache::ReadMarker::create(&scope, "generation").unwrap();
2624        let mut stale_metadata = marker.metadata().clone();
2625        stale_metadata.pid = 0;
2626        fs::write(
2627            marker.path(),
2628            serde_json::to_vec(&stale_metadata).expect("marker metadata serializes"),
2629        )
2630        .unwrap();
2631
2632        // Recreate a live marker after planting the stale fixture so the first
2633        // pass exercises the existing marker liveness rules, not scope age.
2634        let live_marker = crate::root_cache::ReadMarker::create(&scope, "live-generation").unwrap();
2635        let first = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2636        assert_eq!(first.skipped_marker, 1);
2637        assert!(scope.is_dir(), "a live read marker protects an aged scope");
2638        drop(live_marker);
2639
2640        let second = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2641        assert_eq!(second.removed, 1);
2642        assert!(
2643            !scope.exists(),
2644            "the scope is reapable after markers become stale"
2645        );
2646    }
2647
2648    #[test]
2649    fn inspect_scope_sweep_cursor_resumes_after_tiny_budget() {
2650        reset_inspect_scope_sweep_cursor_for_test();
2651        let temp = tempfile::tempdir().unwrap();
2652        let inspect_root = temp.path().join("inspect");
2653        for name in ["scope-a", "scope-b", "scope-c"] {
2654            write_aged_scope_file(&inspect_root.join(name), "facts.sqlite");
2655        }
2656
2657        let first = sweep_inspect_scope_dirs_with_limits(
2658            &inspect_root,
2659            &HashSet::new(),
2660            INSPECT_SCOPE_SWEEP_BUDGET,
2661            1,
2662        );
2663        assert!(first.budget_exhausted);
2664        assert!(!inspect_root.join("scope-a").exists());
2665        assert!(inspect_root.join("scope-b").exists());
2666        assert!(inspect_root.join("scope-c").exists());
2667
2668        let second = sweep_inspect_scope_dirs_with_limits(
2669            &inspect_root,
2670            &HashSet::new(),
2671            INSPECT_SCOPE_SWEEP_BUDGET,
2672            1,
2673        );
2674        assert!(second.budget_exhausted);
2675        assert!(!inspect_root.join("scope-b").exists());
2676        assert!(inspect_root.join("scope-c").exists());
2677
2678        let third = sweep_inspect_scope_dirs_with_limits(
2679            &inspect_root,
2680            &HashSet::new(),
2681            INSPECT_SCOPE_SWEEP_BUDGET,
2682            1,
2683        );
2684        assert!(!third.budget_exhausted);
2685        assert!(!inspect_root.join("scope-c").exists());
2686        reset_inspect_scope_sweep_cursor_for_test();
2687    }
2688
2689    #[test]
2690    fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
2691        let temp = tempfile::tempdir().unwrap();
2692        let memo = Tier1FileMemo::<usize>::default();
2693        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2694
2695        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2696            let path = temp.path().join(format!("file-{index}.txt"));
2697            fs::write(&path, index.to_string()).unwrap();
2698            let value =
2699                memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2700            assert_eq!(value, index);
2701            paths.push(path);
2702        }
2703
2704        let recent_path = paths[0].clone();
2705        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2706            panic!("recently inserted entry should hit before eviction")
2707        });
2708        assert_eq!(recent_value, 0);
2709
2710        let evicting_path = temp.path().join("new-file.txt");
2711        fs::write(&evicting_path, "new").unwrap();
2712        let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
2713            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2714        });
2715        assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
2716
2717        let state = memo.state.lock().unwrap();
2718        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2719        assert!(state.entries.contains_key(&recent_path));
2720        assert!(state.entries.contains_key(&evicting_path));
2721        assert!(!state.entries.contains_key(&paths[1]));
2722        drop(state);
2723
2724        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2725            panic!("recently used entry should survive eviction")
2726        });
2727        assert_eq!(recent_value, 0);
2728    }
2729
2730    #[test]
2731    fn tier1_file_memo_full_scope_prunes_paths_no_longer_present() {
2732        let temp = tempfile::tempdir().unwrap();
2733        let retained_path = temp.path().join("retained.txt");
2734        let removed_path = temp.path().join("removed.txt");
2735        fs::write(&retained_path, "retained").unwrap();
2736        fs::write(&removed_path, "removed").unwrap();
2737        let memo = Tier1FileMemo::<usize>::default();
2738
2739        memo.reserve_for_scan(2);
2740        memo.get_or_insert_with(&retained_path, |path| (Some(collect_freshness(path)), 1));
2741        memo.get_or_insert_with(&removed_path, |path| (Some(collect_freshness(path)), 2));
2742        memo.prune_to_scope(temp.path(), std::slice::from_ref(&retained_path));
2743
2744        let state = memo.state.lock().unwrap();
2745        assert!(state.entries.contains_key(&retained_path));
2746        assert!(!state.entries.contains_key(&removed_path));
2747        assert_eq!(state.capacity, TIER1_FILE_MEMO_MAX_ENTRIES);
2748        drop(state);
2749
2750        let rescanned = Cell::new(false);
2751        let value = memo.get_or_insert_with(&removed_path, |path| {
2752            rescanned.set(true);
2753            (Some(collect_freshness(path)), 3)
2754        });
2755        assert!(
2756            rescanned.get(),
2757            "a path outside the latest full scope must be evicted"
2758        );
2759        assert_eq!(value, 3);
2760    }
2761
2762    #[test]
2763    fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
2764        let temp = tempfile::tempdir().unwrap();
2765        let memo = Tier1FileMemo::<usize>::default();
2766        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2767
2768        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2769            let path = temp.path().join(format!("file-{index}.txt"));
2770            fs::write(&path, index.to_string()).unwrap();
2771            memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2772            paths.push(path);
2773        }
2774
2775        for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
2776            let value = memo.get_or_insert_with(&paths[0], |_| {
2777                panic!("hot entry should stay cached while it is repeatedly touched")
2778            });
2779            assert_eq!(value, 0);
2780        }
2781
2782        let evicting_path = temp.path().join("new-file.txt");
2783        fs::write(&evicting_path, "new").unwrap();
2784        memo.get_or_insert_with(&evicting_path, |path| {
2785            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2786        });
2787
2788        let state = memo.state.lock().unwrap();
2789        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2790        assert!(state.entries.contains_key(&paths[0]));
2791        assert!(state.entries.contains_key(&evicting_path));
2792        assert!(!state.entries.contains_key(&paths[1]));
2793        assert!(
2794            state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
2795            "lazy LRU queue should be compacted instead of growing without bound"
2796        );
2797    }
2798
2799    #[test]
2800    fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
2801        let temp = tempfile::tempdir().unwrap();
2802        let path = temp.path().join("memo.txt");
2803        fs::write(&path, "first").unwrap();
2804
2805        let memo = Tier1FileMemo::<String>::default();
2806        let scans = Cell::new(0);
2807
2808        let first = memo.get_or_insert_with(&path, |path| {
2809            scans.set(scans.get() + 1);
2810            (Some(collect_freshness(path)), "first scan".to_string())
2811        });
2812        assert_eq!(first, "first scan");
2813        assert_eq!(scans.get(), 1);
2814
2815        let unchanged =
2816            memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
2817        assert_eq!(unchanged, "first scan");
2818        assert_eq!(scans.get(), 1);
2819
2820        fs::write(&path, "changed file contents").unwrap();
2821        let changed = memo.get_or_insert_with(&path, |path| {
2822            scans.set(scans.get() + 1);
2823            (Some(collect_freshness(path)), "second scan".to_string())
2824        });
2825        assert_eq!(changed, "second scan");
2826        assert_eq!(scans.get(), 2);
2827
2828        let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
2829            panic!("rescanned file should reuse refreshed Tier-1 memo")
2830        });
2831        assert_eq!(fresh_after_rescan, "second scan");
2832        assert_eq!(scans.get(), 2);
2833    }
2834
2835    #[derive(serde::Deserialize, serde::Serialize)]
2836    struct RoundTripContributionRecord {
2837        category: String,
2838        file_path: PathBuf,
2839        contribution: serde_json::Value,
2840        type_ref_names: BTreeSet<String>,
2841    }
2842
2843    impl From<&ContributionRecord> for RoundTripContributionRecord {
2844        fn from(record: &ContributionRecord) -> Self {
2845            Self {
2846                category: record.category.as_str().to_string(),
2847                file_path: record.file_path.clone(),
2848                contribution: record.contribution.clone(),
2849                type_ref_names: record.type_ref_names.clone(),
2850            }
2851        }
2852    }
2853
2854    #[test]
2855    fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
2856        let temp = tempfile::tempdir().unwrap();
2857        let project_root = temp.path().join("project");
2858        let inspect_dir = temp.path().join("inspect");
2859        let source = project_root.join("src/lib.ts");
2860        fs::create_dir_all(source.parent().unwrap()).unwrap();
2861        fs::write(&source, "export interface Widget { id: string }\n").unwrap();
2862
2863        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2864        let contribution = FileContribution::new(
2865            InspectCategory::DeadCode,
2866            source.clone(),
2867            collect_freshness(&source),
2868            serde_json::json!({
2869                "file": "src/lib.ts",
2870                "exports": [{
2871                    "symbol": "Widget",
2872                    "kind": "interface",
2873                    "line": 1,
2874                    "is_type_like": true,
2875                    "is_entry_point": false,
2876                }],
2877                "internal_calls": [],
2878                "liveness_roots": [],
2879                "dispatched_method_names": ["render"],
2880                "macro_token_refs": [{
2881                    "caller_symbol": "render",
2882                    "line": 1,
2883                    "name": "Widget",
2884                    "shape": "struct"
2885                }],
2886                "cfg_test_ranges": [{"start_line": 10, "end_line": 20}],
2887                "type_ref_names": ["Widget"],
2888            }),
2889        )
2890        .with_type_ref_names(["Widget".to_string()]);
2891        cache
2892            .store_tier2_result(
2893                JobKey::for_project_category(InspectCategory::DeadCode),
2894                std::slice::from_ref(&source),
2895                &[contribution],
2896                serde_json::json!({ "count": 0, "items": [] }),
2897            )
2898            .unwrap();
2899        drop(cache);
2900
2901        let cache = InspectCache::open(inspect_dir, project_root).unwrap();
2902        let records = cache
2903            .load_tier2_contributions(InspectCategory::DeadCode)
2904            .unwrap();
2905        assert_eq!(records.len(), 1);
2906
2907        let serialized =
2908            serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
2909        let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
2910        assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
2911        assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
2912        assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
2913        assert_eq!(
2914            decoded.contribution["macro_token_refs"][0]["shape"],
2915            "struct"
2916        );
2917        assert_eq!(
2918            decoded.contribution["cfg_test_ranges"][0],
2919            serde_json::json!({"start_line": 10, "end_line": 20})
2920        );
2921        assert!(decoded.type_ref_names.contains("Widget"));
2922        assert_eq!(
2923            decoded.contribution["exports"][0]["is_type_like"].as_bool(),
2924            Some(true)
2925        );
2926        assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 33);
2927    }
2928
2929    #[test]
2930    fn complexity_cache_version_rejects_a_legacy_aggregate_hash() {
2931        let temp = tempfile::tempdir().unwrap();
2932        let project_root = temp.path().join("project");
2933        let source = project_root.join("src/hot.rs");
2934        fs::create_dir_all(source.parent().unwrap()).unwrap();
2935        fs::write(&source, "fn hot() {}\n").unwrap();
2936        let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
2937        let contribution = FileContribution::new(
2938            InspectCategory::Complexity,
2939            source.clone(),
2940            collect_freshness(&source),
2941            serde_json::json!({
2942                "file": "src/hot.rs",
2943                "language": "rust",
2944                "functions": [{ "function": "hot", "line": 1, "complexity": 10 }],
2945            }),
2946        );
2947        let config = Config::default();
2948        cache
2949            .store_tier2_result_for_config(
2950                JobKey::for_project_category(InspectCategory::Complexity),
2951                std::slice::from_ref(&source),
2952                &[contribution],
2953                serde_json::json!({ "count": 1, "items": [] }),
2954                &config,
2955            )
2956            .unwrap();
2957
2958        let current_hash = cache
2959            .contribution_set_hash_for_config(InspectCategory::Complexity, &config)
2960            .unwrap();
2961        let legacy_hash = contribution_set_hash_with_version(
2962            &cache.conn.lock().unwrap(),
2963            InspectCategory::Complexity,
2964            &cache.project_key,
2965            &cache.project_root,
2966            Some(&config),
2967            32,
2968        )
2969        .unwrap();
2970
2971        assert_ne!(current_hash, legacy_hash);
2972        assert!(cache
2973            .load_aggregate_if_hash_matches(InspectCategory::Complexity, &legacy_hash)
2974            .unwrap()
2975            .is_none());
2976        assert!(cache
2977            .load_aggregate_if_hash_matches(InspectCategory::Complexity, &current_hash)
2978            .unwrap()
2979            .is_some());
2980    }
2981
2982    #[test]
2983    fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
2984        let temp = tempfile::tempdir().unwrap();
2985        let project_root = temp.path().join("project");
2986        fs::create_dir_all(&project_root).unwrap();
2987        let left = project_root.join("plugin/a.ts");
2988        let right = project_root.join("pi-plugin/a.ts");
2989        fs::create_dir_all(left.parent().unwrap()).unwrap();
2990        fs::create_dir_all(right.parent().unwrap()).unwrap();
2991        fs::write(&left, "export const value = 1;\n").unwrap();
2992        fs::write(&right, "export const value = 1;\n").unwrap();
2993
2994        let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
2995        let contributions = vec![
2996            FileContribution::new(
2997                InspectCategory::Duplicates,
2998                left.clone(),
2999                collect_freshness(&left),
3000                serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
3001            ),
3002            FileContribution::new(
3003                InspectCategory::Duplicates,
3004                right.clone(),
3005                collect_freshness(&right),
3006                serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
3007            ),
3008        ];
3009        let config = Config::default();
3010        cache
3011            .store_tier2_result_for_config(
3012                JobKey::for_project_category(InspectCategory::Duplicates),
3013                &[left.clone(), right.clone()],
3014                &contributions,
3015                serde_json::json!({ "count": 0, "items": [] }),
3016                &config,
3017            )
3018            .unwrap();
3019
3020        let without_mirrors = cache
3021            .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
3022            .unwrap();
3023        let mut mirror_config = Config::default();
3024        mirror_config.inspect.duplicates.expected_mirrors =
3025            vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
3026        let with_mirrors = cache
3027            .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
3028            .unwrap();
3029
3030        assert_ne!(without_mirrors, with_mirrors);
3031    }
3032}
3033
3034#[cfg(test)]
3035mod memory_estimate_tests {
3036    use super::*;
3037
3038    #[test]
3039    fn inspect_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
3040        let root = tempfile::tempdir().expect("project root");
3041        let project_root = std::fs::canonicalize(root.path()).expect("canonical project root");
3042        let storage = tempfile::tempdir().expect("inspect storage");
3043        let cache = InspectCache::open(storage.path().to_path_buf(), project_root)
3044            .expect("open inspect cache");
3045        assert_eq!(cache.estimated_memory().estimated_bytes, Some(0));
3046
3047        cache
3048            .store_aggregated(
3049                JobKey::for_project_category(InspectCategory::Todos),
3050                serde_json::json!({"count": 1, "items": [{"text": "resident todo"}]}),
3051            )
3052            .expect("store memory aggregate");
3053        let estimate = cache.estimated_memory();
3054        assert!(estimate.estimated_bytes.unwrap() > 0);
3055        assert_eq!(estimate.counts["memory_aggregates"], 1);
3056        assert_eq!(estimate.counts["open_generation_handles"], 1);
3057    }
3058}