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