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