Skip to main content

aft/inspect/
cache.rs

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