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