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