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