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