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