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