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