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