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