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