1use std::collections::{BTreeSet, HashMap, VecDeque};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, RwLock};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
9
10use crate::cache_freshness::{FileFreshness, FreshnessVerdict};
11use crate::config::Config;
12use crate::jsonc::strip_jsonc;
13
14use super::job::{
15 contribution_with_type_ref_names, type_ref_names_from_contribution, FileContribution,
16 InspectCategory, JobKey,
17};
18
19#[derive(Debug, Default)]
20pub(crate) struct Tier2ContributionUpdates {
21 pub upserts: Vec<FileContribution>,
22 pub deletes: Vec<PathBuf>,
23 pub metadata_updates: Vec<(PathBuf, FileFreshness)>,
24}
25
26#[derive(Debug)]
27pub enum InspectCacheError {
28 Io(std::io::Error),
29 Sql(rusqlite::Error),
30 Json(serde_json::Error),
31 LockPoisoned(&'static str),
32 InvalidHash(String),
33}
34
35impl fmt::Display for InspectCacheError {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 InspectCacheError::Io(error) => write!(formatter, "inspect cache io error: {error}"),
39 InspectCacheError::Sql(error) => {
40 write!(formatter, "inspect cache sqlite error: {error}")
41 }
42 InspectCacheError::Json(error) => {
43 write!(formatter, "inspect cache json error: {error}")
44 }
45 InspectCacheError::LockPoisoned(name) => {
46 write!(formatter, "inspect cache lock poisoned: {name}")
47 }
48 InspectCacheError::InvalidHash(hash) => {
49 write!(formatter, "inspect cache invalid blake3 hash: {hash}")
50 }
51 }
52 }
53}
54
55impl std::error::Error for InspectCacheError {}
56
57impl From<std::io::Error> for InspectCacheError {
58 fn from(error: std::io::Error) -> Self {
59 Self::Io(error)
60 }
61}
62
63impl From<rusqlite::Error> for InspectCacheError {
64 fn from(error: rusqlite::Error) -> Self {
65 Self::Sql(error)
66 }
67}
68
69impl From<serde_json::Error> for InspectCacheError {
70 fn from(error: serde_json::Error) -> Self {
71 Self::Json(error)
72 }
73}
74
75pub(crate) const TIER2_CONTRIBUTION_CACHE_VERSION: u32 = 28;
137
138#[derive(Debug, Clone)]
139pub struct ContributionRecord {
140 pub category: InspectCategory,
141 pub file_path: PathBuf,
142 pub freshness: FileFreshness,
143 pub contribution: serde_json::Value,
144 pub type_ref_names: BTreeSet<String>,
145}
146
147#[derive(Debug, Clone)]
148struct MemoryAggregate {
149 payload: serde_json::Value,
150 generated_at: i64,
151 contribution_set_hash: Option<String>,
152}
153
154const TIER1_FILE_MEMO_MAX_ENTRIES: usize = 4_096;
155
156#[derive(Debug, Clone)]
157struct Tier1MemoEntry<T> {
158 freshness: FileFreshness,
159 value: T,
160 generation: u64,
161}
162
163#[derive(Debug, Clone)]
164struct LruNode {
165 path: PathBuf,
166 generation: u64,
167}
168
169#[derive(Debug)]
170struct Tier1MemoState<T> {
171 entries: HashMap<PathBuf, Tier1MemoEntry<T>>,
172 lru: VecDeque<LruNode>,
173 next_generation: u64,
174}
175
176impl<T> Default for Tier1MemoState<T> {
177 fn default() -> Self {
178 Self {
179 entries: HashMap::new(),
180 lru: VecDeque::new(),
181 next_generation: 0,
182 }
183 }
184}
185
186impl<T> Tier1MemoState<T> {
187 fn insert(&mut self, path: PathBuf, mut entry: Tier1MemoEntry<T>) {
188 let generation = self.allocate_generation();
189 entry.generation = generation;
190 self.entries.insert(path.clone(), entry);
191 self.lru.push_back(LruNode { path, generation });
192 self.compact_lru_if_needed();
193 self.evict_lru();
194 }
195
196 fn remove(&mut self, path: &Path) {
197 self.entries.remove(path);
198 self.compact_lru_if_needed();
199 }
200
201 fn touch(&mut self, path: &Path) {
202 if !self.entries.contains_key(path) {
203 return;
204 }
205
206 let generation = self.allocate_generation();
207 if let Some(entry) = self.entries.get_mut(path) {
208 entry.generation = generation;
209 self.lru.push_back(LruNode {
210 path: path.to_path_buf(),
211 generation,
212 });
213 }
214 self.compact_lru_if_needed();
215 }
216
217 fn allocate_generation(&mut self) -> u64 {
218 if self.next_generation == u64::MAX {
219 self.rebuild_lru();
220 }
221 let generation = self.next_generation;
222 self.next_generation += 1;
223 generation
224 }
225
226 fn compact_lru_if_needed(&mut self) {
227 let max_lru_nodes = TIER1_FILE_MEMO_MAX_ENTRIES
228 .saturating_mul(2)
229 .max(self.entries.len());
230 if self.lru.len() > max_lru_nodes {
231 self.rebuild_lru();
232 }
233 }
234
235 fn rebuild_lru(&mut self) {
236 let mut live_nodes = self
237 .entries
238 .iter()
239 .map(|(path, entry)| (entry.generation, path.clone()))
240 .collect::<Vec<_>>();
241 live_nodes.sort_by_key(|(generation, _)| *generation);
242
243 self.lru.clear();
244 for (generation, (_, path)) in live_nodes.into_iter().enumerate() {
245 let generation = generation as u64;
246 if let Some(entry) = self.entries.get_mut(&path) {
247 entry.generation = generation;
248 }
249 self.lru.push_back(LruNode { path, generation });
250 }
251 self.next_generation = self.lru.len() as u64;
252 }
253
254 fn evict_lru(&mut self) {
255 while self.entries.len() > TIER1_FILE_MEMO_MAX_ENTRIES {
256 let Some(node) = self.lru.pop_front() else {
257 break;
258 };
259 if self
260 .entries
261 .get(&node.path)
262 .is_some_and(|entry| entry.generation == node.generation)
263 {
264 self.entries.remove(&node.path);
265 }
266 }
267 self.compact_lru_if_needed();
268 }
269}
270
271#[derive(Debug)]
272pub(crate) struct Tier1FileMemo<T> {
273 state: Mutex<Tier1MemoState<T>>,
274}
275
276impl<T> Default for Tier1FileMemo<T> {
277 fn default() -> Self {
278 Self {
279 state: Mutex::new(Tier1MemoState::default()),
280 }
281 }
282}
283
284impl<T: Clone> Tier1FileMemo<T> {
285 pub(crate) fn get_or_insert_with<F>(&self, path: &Path, scan: F) -> T
286 where
287 F: FnOnce(&Path) -> (Option<FileFreshness>, T),
288 {
289 if let Some(cached) = self.cached_value(path) {
290 return cached;
291 }
292
293 let (freshness, value) = scan(path);
294 if let Ok(mut state) = self.state.lock() {
295 if let Some(freshness) = freshness {
296 state.insert(
297 path.to_path_buf(),
298 Tier1MemoEntry {
299 freshness,
300 value: value.clone(),
301 generation: 0,
302 },
303 );
304 } else {
305 state.remove(path);
306 }
307 }
308 value
309 }
310
311 fn cached_value(&self, path: &Path) -> Option<T> {
312 let mut cached = self
313 .state
314 .lock()
315 .ok()
316 .and_then(|state| state.entries.get(path).cloned())?;
317
318 match crate::cache_freshness::verify_file(path, &cached.freshness) {
319 FreshnessVerdict::HotFresh => {
320 if let Ok(mut state) = self.state.lock() {
321 state.touch(path);
322 }
323 Some(cached.value)
324 }
325 FreshnessVerdict::ContentFresh {
326 new_mtime,
327 new_size,
328 } => {
329 cached.freshness.mtime = new_mtime;
330 cached.freshness.size = new_size;
331 let value = cached.value.clone();
332 if let Ok(mut state) = self.state.lock() {
333 state.insert(path.to_path_buf(), cached);
334 }
335 Some(value)
336 }
337 FreshnessVerdict::Stale => None,
338 FreshnessVerdict::Deleted => {
339 if let Ok(mut state) = self.state.lock() {
340 state.remove(path);
341 }
342 None
343 }
344 }
345 }
346}
347
348#[derive(Debug)]
349pub struct InspectCache {
350 project_root: PathBuf,
351 project_key: String,
352 sqlite_path: PathBuf,
353 conn: Mutex<Connection>,
354 memory: RwLock<HashMap<JobKey, MemoryAggregate>>,
355}
356
357impl InspectCache {
358 pub fn open(inspect_dir: PathBuf, project_root: PathBuf) -> Result<Self, InspectCacheError> {
359 std::fs::create_dir_all(&inspect_dir)?;
360 let project_key = crate::search_index::artifact_cache_key(&project_root);
361 let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
362 let conn = Connection::open(&sqlite_path)?;
363 configure_connection(&conn)?;
364 initialize_schema(&conn)?;
365 Ok(Self::from_connection(
366 project_root,
367 project_key,
368 sqlite_path,
369 conn,
370 ))
371 }
372
373 pub fn open_readonly(
374 inspect_dir: PathBuf,
375 project_root: PathBuf,
376 ) -> Result<Option<Self>, InspectCacheError> {
377 let project_key = crate::search_index::artifact_cache_key(&project_root);
378 let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
379 if !sqlite_path.is_file() {
380 return Ok(None);
381 }
382 let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
383 conn.busy_timeout(Duration::from_millis(5_000))?;
384 Ok(Some(Self::from_connection(
385 project_root,
386 project_key,
387 sqlite_path,
388 conn,
389 )))
390 }
391
392 fn from_connection(
393 project_root: PathBuf,
394 project_key: String,
395 sqlite_path: PathBuf,
396 conn: Connection,
397 ) -> Self {
398 Self {
399 project_root,
400 project_key,
401 sqlite_path,
402 conn: Mutex::new(conn),
403 memory: RwLock::new(HashMap::new()),
404 }
405 }
406
407 pub fn project_root(&self) -> &Path {
408 &self.project_root
409 }
410
411 pub fn project_key(&self) -> &str {
412 &self.project_key
413 }
414
415 pub fn sqlite_path(&self) -> &Path {
416 &self.sqlite_path
417 }
418
419 pub fn store_aggregated(
420 &self,
421 key: JobKey,
422 payload: serde_json::Value,
423 ) -> Result<(), InspectCacheError> {
424 self.store_memory_aggregate(key, payload, None)
425 }
426
427 fn store_memory_aggregate(
428 &self,
429 key: JobKey,
430 payload: serde_json::Value,
431 contribution_set_hash: Option<String>,
432 ) -> Result<(), InspectCacheError> {
433 self.memory
434 .write()
435 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
436 .insert(
437 key,
438 MemoryAggregate {
439 payload,
440 generated_at: unix_seconds_now(),
441 contribution_set_hash,
442 },
443 );
444 Ok(())
445 }
446
447 pub fn get_aggregated(
448 &self,
449 key: &JobKey,
450 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
451 self.get_aggregated_with_config(key, None)
452 }
453
454 pub fn get_aggregated_for_config(
455 &self,
456 key: &JobKey,
457 config: &Config,
458 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
459 self.get_aggregated_with_config(key, Some(config))
460 }
461
462 fn get_aggregated_with_config(
463 &self,
464 key: &JobKey,
465 config: Option<&Config>,
466 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
467 if !key.category.is_tier2() {
468 return Ok(self
469 .memory
470 .read()
471 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
472 .get(key)
473 .map(|entry| entry.payload.clone()));
474 }
475
476 let current_hash = {
477 let conn = self
478 .conn
479 .lock()
480 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
481 contribution_set_hash_with_conn(
482 &conn,
483 key.category,
484 &self.project_key,
485 &self.project_root,
486 config,
487 )?
488 };
489
490 let memory_entry = {
491 self.memory
492 .read()
493 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
494 .get(key)
495 .cloned()
496 };
497 if let Some(entry) = memory_entry {
498 if entry.contribution_set_hash.as_deref() == Some(current_hash.as_str()) {
499 return Ok(Some(entry.payload));
500 }
501 self.memory
502 .write()
503 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
504 .remove(key);
505 }
506
507 let payload = {
508 let conn = self
509 .conn
510 .lock()
511 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
512 conn.query_row(
513 "SELECT aggregate FROM tier2_aggregates \
514 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
515 params![key.category.as_str(), self.project_key, current_hash],
516 |row| row.get::<_, Vec<u8>>(0),
517 )
518 .optional()?
519 };
520
521 match payload {
522 Some(bytes) => {
523 let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
524 self.store_memory_aggregate(key.clone(), value.clone(), Some(current_hash))?;
525 Ok(Some(value))
526 }
527 None => Ok(None),
528 }
529 }
530
531 pub fn store_tier2_result(
532 &self,
533 key: JobKey,
534 scanned_files: &[PathBuf],
535 contributions: &[FileContribution],
536 aggregate: serde_json::Value,
537 ) -> Result<(), InspectCacheError> {
538 self.store_tier2_result_with_config(key, scanned_files, contributions, aggregate, None)
539 }
540
541 pub fn store_tier2_result_for_config(
542 &self,
543 key: JobKey,
544 scanned_files: &[PathBuf],
545 contributions: &[FileContribution],
546 aggregate: serde_json::Value,
547 config: &Config,
548 ) -> Result<(), InspectCacheError> {
549 self.store_tier2_result_with_config(
550 key,
551 scanned_files,
552 contributions,
553 aggregate,
554 Some(config),
555 )
556 }
557
558 fn store_tier2_result_with_config(
559 &self,
560 key: JobKey,
561 scanned_files: &[PathBuf],
562 contributions: &[FileContribution],
563 aggregate: serde_json::Value,
564 config: Option<&Config>,
565 ) -> Result<(), InspectCacheError> {
566 if !key.category.is_tier2() {
567 self.store_aggregated(key, aggregate)?;
568 return Ok(());
569 }
570
571 let now = unix_seconds_now();
572 let mut conn = self
573 .conn
574 .lock()
575 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
576 let tx = conn.transaction()?;
577
578 let scanned_relative = scanned_files
579 .iter()
580 .map(|path| relative_string(&self.project_root, path))
581 .collect::<BTreeSet<_>>();
582 let existing = existing_contribution_paths(&tx, key.category, &self.project_key)?;
583 for file_path in existing {
584 if !scanned_relative.contains(&file_path) {
585 tx.execute(
586 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
587 params![key.category.as_str(), self.project_key, file_path],
588 )?;
589 }
590 }
591
592 for contribution in contributions {
593 let file_path = relative_string(&self.project_root, &contribution.file_path);
594 let blob = serde_json::to_vec(&contribution_with_type_ref_names(
595 contribution.contribution.clone(),
596 &contribution.type_ref_names,
597 ))?;
598 tx.execute(
599 "INSERT INTO tier2_contributions \
600 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
601 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
602 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
603 file_mtime_ns = excluded.file_mtime_ns, \
604 file_size = excluded.file_size, \
605 file_hash = excluded.file_hash, \
606 contribution = excluded.contribution, \
607 generated_at = excluded.generated_at",
608 params![
609 contribution.category.as_str(),
610 self.project_key,
611 file_path,
612 system_time_to_ns(contribution.freshness.mtime),
613 contribution.freshness.size as i64,
614 hash_to_hex(contribution.freshness.content_hash),
615 blob,
616 now,
617 ],
618 )?;
619 }
620
621 let contribution_set_hash = contribution_set_hash_with_conn(
622 &tx,
623 key.category,
624 &self.project_key,
625 &self.project_root,
626 config,
627 )?;
628 let aggregate_blob = serde_json::to_vec(&aggregate)?;
629 tx.execute(
630 "INSERT INTO tier2_aggregates \
631 (category, project_key, contribution_set_hash, aggregate, generated_at) \
632 VALUES (?1, ?2, ?3, ?4, ?5) \
633 ON CONFLICT(category, project_key) DO UPDATE SET \
634 contribution_set_hash = excluded.contribution_set_hash, \
635 aggregate = excluded.aggregate, \
636 generated_at = excluded.generated_at",
637 params![
638 key.category.as_str(),
639 self.project_key,
640 contribution_set_hash,
641 aggregate_blob,
642 now,
643 ],
644 )?;
645 tx.execute(
646 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
647 ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
648 params![key.category.as_str(), self.project_key, now],
649 )?;
650 tx.commit()?;
651
652 self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash))
653 }
654
655 pub(crate) fn apply_contribution_updates_for_config(
656 &self,
657 category: InspectCategory,
658 updates: Tier2ContributionUpdates,
659 config: &Config,
660 ) -> Result<String, InspectCacheError> {
661 self.apply_contribution_updates_with_config(category, updates, Some(config))
662 }
663
664 fn apply_contribution_updates_with_config(
665 &self,
666 category: InspectCategory,
667 updates: Tier2ContributionUpdates,
668 config: Option<&Config>,
669 ) -> Result<String, InspectCacheError> {
670 let now = unix_seconds_now();
671 let mut conn = self
672 .conn
673 .lock()
674 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
675 let tx = conn.transaction()?;
676
677 for relative_file in updates.deletes {
678 tx.execute(
679 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
680 params![
681 category.as_str(),
682 self.project_key,
683 relative_file.to_string_lossy().to_string()
684 ],
685 )?;
686 }
687
688 for (relative_file, freshness) in updates.metadata_updates {
689 tx.execute(
690 "UPDATE tier2_contributions \
691 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
692 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
693 params![
694 category.as_str(),
695 self.project_key,
696 relative_file.to_string_lossy().to_string(),
697 system_time_to_ns(freshness.mtime),
698 freshness.size as i64,
699 hash_to_hex(freshness.content_hash),
700 ],
701 )?;
702 }
703
704 for contribution in updates.upserts {
705 let file_path = relative_string(&self.project_root, &contribution.file_path);
706 let blob = serde_json::to_vec(&contribution_with_type_ref_names(
707 contribution.contribution.clone(),
708 &contribution.type_ref_names,
709 ))?;
710 tx.execute(
711 "INSERT INTO tier2_contributions \
712 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
713 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
714 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
715 file_mtime_ns = excluded.file_mtime_ns, \
716 file_size = excluded.file_size, \
717 file_hash = excluded.file_hash, \
718 contribution = excluded.contribution, \
719 generated_at = excluded.generated_at",
720 params![
721 contribution.category.as_str(),
722 self.project_key,
723 file_path,
724 system_time_to_ns(contribution.freshness.mtime),
725 contribution.freshness.size as i64,
726 hash_to_hex(contribution.freshness.content_hash),
727 blob,
728 now,
729 ],
730 )?;
731 }
732
733 let contribution_set_hash = contribution_set_hash_with_conn(
734 &tx,
735 category,
736 &self.project_key,
737 &self.project_root,
738 config,
739 )?;
740 tx.commit()?;
741
742 self.memory
743 .write()
744 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
745 .remove(&JobKey::for_project_category(category));
746
747 Ok(contribution_set_hash)
748 }
749
750 pub(crate) fn load_aggregate_if_hash_matches(
751 &self,
752 category: InspectCategory,
753 contribution_set_hash: &str,
754 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
755 let payload = {
756 let conn = self
757 .conn
758 .lock()
759 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
760 conn.query_row(
761 "SELECT aggregate FROM tier2_aggregates \
762 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
763 params![category.as_str(), self.project_key, contribution_set_hash],
764 |row| row.get::<_, Vec<u8>>(0),
765 )
766 .optional()?
767 };
768
769 match payload {
770 Some(bytes) => {
771 let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
772 self.store_memory_aggregate(
773 JobKey::for_project_category(category),
774 value.clone(),
775 Some(contribution_set_hash.to_string()),
776 )?;
777 Ok(Some(value))
778 }
779 None => Ok(None),
780 }
781 }
782
783 pub(crate) fn latest_aggregate_any_hash(
784 &self,
785 category: InspectCategory,
786 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
787 let payload = {
788 let conn = self
789 .conn
790 .lock()
791 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
792 conn.query_row(
793 "SELECT aggregate FROM tier2_aggregates \
794 WHERE category = ?1 AND project_key = ?2 \
795 ORDER BY generated_at DESC LIMIT 1",
796 params![category.as_str(), self.project_key],
797 |row| row.get::<_, Vec<u8>>(0),
798 )
799 .optional()?
800 };
801
802 match payload {
803 Some(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
804 .map(Some)
805 .map_err(InspectCacheError::from),
806 None => Ok(None),
807 }
808 }
809
810 pub(crate) fn touch_tier2_last_full_run(
811 &self,
812 category: InspectCategory,
813 ) -> Result<i64, InspectCacheError> {
814 let mut conn = self
815 .conn
816 .lock()
817 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
818 let tx = conn.transaction()?;
819 let previous = tx
820 .query_row(
821 "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
822 params![category.as_str(), self.project_key],
823 |row| row.get::<_, i64>(0),
824 )
825 .optional()?;
826 let now = unix_seconds_now();
827 let last_full_run = previous.map_or(now, |previous| now.max(previous.saturating_add(1)));
828 tx.execute(
829 "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",
830 params![category.as_str(), self.project_key, last_full_run],
831 )?;
832 tx.commit()?;
833 Ok(last_full_run)
834 }
835
836 pub(crate) fn store_tier2_aggregate(
837 &self,
838 key: JobKey,
839 contribution_set_hash: &str,
840 aggregate: serde_json::Value,
841 ) -> Result<(), InspectCacheError> {
842 if !key.category.is_tier2() {
843 self.store_aggregated(key, aggregate)?;
844 return Ok(());
845 }
846
847 let now = unix_seconds_now();
848 let aggregate_blob = serde_json::to_vec(&aggregate)?;
849 let mut conn = self
850 .conn
851 .lock()
852 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
853 let tx = conn.transaction()?;
854 tx.execute(
855 "INSERT INTO tier2_aggregates \
856 (category, project_key, contribution_set_hash, aggregate, generated_at) \
857 VALUES (?1, ?2, ?3, ?4, ?5) \
858 ON CONFLICT(category, project_key) DO UPDATE SET \
859 contribution_set_hash = excluded.contribution_set_hash, \
860 aggregate = excluded.aggregate, \
861 generated_at = excluded.generated_at",
862 params![
863 key.category.as_str(),
864 self.project_key,
865 contribution_set_hash,
866 aggregate_blob,
867 now,
868 ],
869 )?;
870 tx.execute(
871 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
872 ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
873 params![key.category.as_str(), self.project_key, now],
874 )?;
875 tx.commit()?;
876
877 self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash.to_string()))
878 }
879
880 pub fn load_tier2_contributions(
881 &self,
882 category: InspectCategory,
883 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
884 let conn = self
885 .conn
886 .lock()
887 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
888 let mut stmt = conn.prepare(
889 "SELECT file_path, file_mtime_ns, file_size, file_hash, contribution \
890 FROM tier2_contributions \
891 WHERE category = ?1 AND project_key = ?2 \
892 ORDER BY file_path ASC",
893 )?;
894 let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
895 let file_path: String = row.get(0)?;
896 let mtime_ns: i64 = row.get(1)?;
897 let file_size: i64 = row.get(2)?;
898 let file_hash: String = row.get(3)?;
899 let contribution: Vec<u8> = row.get(4)?;
900 Ok((file_path, mtime_ns, file_size, file_hash, contribution))
901 })?;
902
903 let mut records = Vec::new();
904 for row in rows {
905 let (file_path, mtime_ns, file_size, file_hash, contribution) = row?;
906 let contribution: serde_json::Value = serde_json::from_slice(&contribution)?;
907 let type_ref_names = type_ref_names_from_contribution(&contribution);
908 records.push(ContributionRecord {
909 category,
910 file_path: PathBuf::from(file_path),
911 freshness: FileFreshness {
912 mtime: ns_to_system_time(mtime_ns),
913 size: file_size.max(0) as u64,
914 content_hash: hash_from_hex(&file_hash)?,
915 },
916 contribution,
917 type_ref_names,
918 });
919 }
920 Ok(records)
921 }
922
923 pub fn delete_tier2_contribution(
924 &self,
925 category: InspectCategory,
926 relative_file: &Path,
927 ) -> Result<(), InspectCacheError> {
928 let conn = self
929 .conn
930 .lock()
931 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
932 conn.execute(
933 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
934 params![
935 category.as_str(),
936 self.project_key,
937 relative_file.to_string_lossy().to_string()
938 ],
939 )?;
940 Ok(())
941 }
942
943 pub fn update_content_fresh_metadata(
944 &self,
945 category: InspectCategory,
946 relative_file: &Path,
947 freshness: &FileFreshness,
948 ) -> Result<(), InspectCacheError> {
949 let conn = self
950 .conn
951 .lock()
952 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
953 conn.execute(
954 "UPDATE tier2_contributions \
955 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
956 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
957 params![
958 category.as_str(),
959 self.project_key,
960 relative_file.to_string_lossy().to_string(),
961 system_time_to_ns(freshness.mtime),
962 freshness.size as i64,
963 hash_to_hex(freshness.content_hash),
964 ],
965 )?;
966 Ok(())
967 }
968
969 pub(crate) fn contribution_freshness(
970 &self,
971 category: InspectCategory,
972 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
973 let conn = self
974 .conn
975 .lock()
976 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
977 let mut stmt = conn.prepare(
978 "SELECT file_path, file_mtime_ns, file_size, file_hash \
979 FROM tier2_contributions \
980 WHERE category = ?1 AND project_key = ?2 \
981 ORDER BY file_path ASC",
982 )?;
983 let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
984 Ok((
985 row.get::<_, String>(0)?,
986 row.get::<_, i64>(1)?,
987 row.get::<_, i64>(2)?,
988 row.get::<_, String>(3)?,
989 ))
990 })?;
991
992 let mut records = Vec::new();
993 for row in rows {
994 let (file_path, mtime_ns, file_size, file_hash) = row?;
995 records.push((
996 PathBuf::from(file_path),
997 FileFreshness {
998 mtime: ns_to_system_time(mtime_ns),
999 size: file_size.max(0) as u64,
1000 content_hash: hash_from_hex(&file_hash)?,
1001 },
1002 ));
1003 }
1004 Ok(records)
1005 }
1006
1007 pub fn contribution_set_hash(
1008 &self,
1009 category: InspectCategory,
1010 ) -> Result<String, InspectCacheError> {
1011 self.contribution_set_hash_with_config(category, None)
1012 }
1013
1014 pub fn contribution_set_hash_for_config(
1015 &self,
1016 category: InspectCategory,
1017 config: &Config,
1018 ) -> Result<String, InspectCacheError> {
1019 self.contribution_set_hash_with_config(category, Some(config))
1020 }
1021
1022 fn contribution_set_hash_with_config(
1023 &self,
1024 category: InspectCategory,
1025 config: Option<&Config>,
1026 ) -> Result<String, InspectCacheError> {
1027 let conn = self
1028 .conn
1029 .lock()
1030 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1031 contribution_set_hash_with_conn(
1032 &conn,
1033 category,
1034 &self.project_key,
1035 &self.project_root,
1036 config,
1037 )
1038 }
1039
1040 pub fn last_full_run(
1041 &self,
1042 category: InspectCategory,
1043 ) -> Result<Option<i64>, InspectCacheError> {
1044 let conn = self
1045 .conn
1046 .lock()
1047 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1048 conn.query_row(
1049 "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1050 params![category.as_str(), self.project_key],
1051 |row| row.get::<_, i64>(0),
1052 )
1053 .optional()
1054 .map_err(InspectCacheError::from)
1055 }
1056
1057 pub fn memory_generated_at(&self, key: &JobKey) -> Result<Option<i64>, InspectCacheError> {
1058 Ok(self
1059 .memory
1060 .read()
1061 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1062 .get(key)
1063 .map(|entry| entry.generated_at))
1064 }
1065}
1066
1067fn configure_connection(conn: &Connection) -> Result<(), InspectCacheError> {
1068 conn.pragma_update(None, "journal_mode", "WAL")?;
1069 conn.pragma_update(None, "busy_timeout", 5_000)?;
1070 Ok(())
1071}
1072
1073fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1074 conn.execute_batch(
1075 "CREATE TABLE IF NOT EXISTS tier2_contributions (
1076 category TEXT NOT NULL,
1077 project_key TEXT NOT NULL,
1078 file_path TEXT NOT NULL,
1079 file_mtime_ns INTEGER NOT NULL,
1080 file_size INTEGER NOT NULL,
1081 file_hash TEXT NOT NULL,
1082 contribution BLOB NOT NULL,
1083 generated_at INTEGER NOT NULL,
1084 PRIMARY KEY (category, project_key, file_path)
1085 );
1086
1087 CREATE TABLE IF NOT EXISTS tier2_aggregates (
1088 category TEXT NOT NULL,
1089 project_key TEXT NOT NULL,
1090 contribution_set_hash TEXT NOT NULL,
1091 aggregate BLOB NOT NULL,
1092 generated_at INTEGER NOT NULL,
1093 PRIMARY KEY (category, project_key)
1094 );
1095
1096 CREATE TABLE IF NOT EXISTS tier2_meta (
1097 category TEXT NOT NULL,
1098 project_key TEXT NOT NULL,
1099 last_full_run INTEGER NOT NULL,
1100 PRIMARY KEY (category, project_key)
1101 );",
1102 )?;
1103 Ok(())
1104}
1105
1106fn existing_contribution_paths(
1107 conn: &Connection,
1108 category: InspectCategory,
1109 project_key: &str,
1110) -> Result<Vec<String>, InspectCacheError> {
1111 let mut stmt = conn.prepare(
1112 "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1113 )?;
1114 let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1115 row.get::<_, String>(0)
1116 })?;
1117 rows.collect::<Result<Vec<_>, _>>()
1118 .map_err(InspectCacheError::from)
1119}
1120
1121fn contribution_set_hash_with_conn(
1122 conn: &Connection,
1123 category: InspectCategory,
1124 project_key: &str,
1125 project_root: &Path,
1126 config: Option<&Config>,
1127) -> Result<String, InspectCacheError> {
1128 let mut stmt = conn.prepare(
1129 "SELECT file_path, file_hash FROM tier2_contributions \
1130 WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
1131 )?;
1132 let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1133 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1134 })?;
1135
1136 let mut hasher = blake3::Hasher::new();
1137 hasher.update(b"tier2-contributions\0");
1138 hasher.update(&TIER2_CONTRIBUTION_CACHE_VERSION.to_le_bytes());
1139 hasher.update(b"\0");
1140 for row in rows {
1141 let (file_path, file_hash) = row?;
1142 hasher.update(file_path.as_bytes());
1143 hasher.update(b"\0");
1144 hasher.update(file_hash.as_bytes());
1145 hasher.update(b"\0");
1146 }
1147 update_manifest_fingerprint_hash(&mut hasher, project_root)?;
1148 if matches!(
1149 category,
1150 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
1151 ) {
1152 update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
1153 }
1154 update_inspect_config_fingerprint_hash(&mut hasher, category, config);
1155 Ok(hasher.finalize().to_hex().to_string())
1156}
1157
1158fn update_inspect_config_fingerprint_hash(
1159 hasher: &mut blake3::Hasher,
1160 category: InspectCategory,
1161 config: Option<&Config>,
1162) {
1163 if category != InspectCategory::Duplicates {
1164 return;
1165 }
1166
1167 hasher.update(b"inspect.duplicates.expected_mirrors\0");
1168 let Some(config) = config else {
1169 return;
1170 };
1171 for pair in &config.inspect.duplicates.expected_mirrors {
1172 hasher.update(pair[0].as_bytes());
1173 hasher.update(b"\0");
1174 hasher.update(pair[1].as_bytes());
1175 hasher.update(b"\0");
1176 }
1177}
1178
1179fn update_resolver_config_fingerprint_hash(
1180 hasher: &mut blake3::Hasher,
1181 project_root: &Path,
1182) -> Result<(), InspectCacheError> {
1183 let manifest_root =
1184 fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1185 hasher.update(b"ts-js-resolver-configs\0");
1186 for config in collect_resolver_config_dependency_files(project_root) {
1187 let relative_path = config
1188 .strip_prefix(&manifest_root)
1189 .unwrap_or(config.as_path())
1190 .to_string_lossy()
1191 .replace('\\', "/");
1192 let content_hash = blake3::hash(&fs::read(&config)?);
1193 hasher.update(relative_path.as_bytes());
1194 hasher.update(b"\0");
1195 hasher.update(content_hash.as_bytes());
1196 hasher.update(b"\0");
1197 }
1198 Ok(())
1199}
1200
1201struct ResolverConfigDependency {
1202 path: PathBuf,
1203 follow_extends: bool,
1204}
1205
1206impl ResolverConfigDependency {
1207 fn resolver_config(path: PathBuf) -> Self {
1208 Self {
1209 path,
1210 follow_extends: true,
1211 }
1212 }
1213
1214 fn hashed_file(path: PathBuf) -> Self {
1215 Self {
1216 path,
1217 follow_extends: false,
1218 }
1219 }
1220}
1221
1222fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
1223 let mut configs = walk_resolver_config_files(project_root);
1224 let mut pending = configs.iter().cloned().collect::<Vec<_>>();
1225 let mut queued = configs.clone();
1226 while let Some(config) = pending.pop() {
1227 for dependency in resolver_config_extends_targets(&config, project_root) {
1228 let ResolverConfigDependency {
1229 path,
1230 follow_extends,
1231 } = dependency;
1232 configs.insert(path.clone());
1233 if follow_extends && queued.insert(path.clone()) {
1234 pending.push(path);
1235 }
1236 }
1237 }
1238 configs
1239}
1240
1241fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
1242 let walker = ignore::WalkBuilder::new(project_root)
1243 .hidden(true)
1244 .git_ignore(true)
1245 .git_global(true)
1246 .git_exclude(true)
1247 .add_custom_ignore_filename(".aftignore")
1248 .filter_entry(|entry| {
1249 let name = entry.file_name().to_string_lossy();
1250 if entry
1251 .file_type()
1252 .is_some_and(|file_type| file_type.is_dir())
1253 {
1254 return !matches!(
1255 name.as_ref(),
1256 "node_modules"
1257 | "target"
1258 | "venv"
1259 | ".venv"
1260 | ".git"
1261 | "__pycache__"
1262 | ".tox"
1263 | "dist"
1264 | "build"
1265 );
1266 }
1267 true
1268 })
1269 .build();
1270
1271 walker
1272 .filter_map(Result::ok)
1273 .filter(|entry| {
1274 entry
1275 .file_type()
1276 .is_some_and(|file_type| file_type.is_file())
1277 })
1278 .map(|entry| entry.into_path())
1279 .filter(|path| {
1280 path.file_name()
1281 .and_then(|name| name.to_str())
1282 .is_some_and(is_resolver_config_file_name)
1283 })
1284 .filter_map(canonical_file_path)
1285 .collect()
1286}
1287
1288fn is_resolver_config_file_name(name: &str) -> bool {
1289 name == "tsconfig.json"
1290 || name == "jsconfig.json"
1291 || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
1292 && name.ends_with(".json"))
1293}
1294
1295fn resolver_config_extends_targets(
1296 config: &Path,
1297 project_root: &Path,
1298) -> Vec<ResolverConfigDependency> {
1299 let Ok(source) = fs::read_to_string(config) else {
1300 return Vec::new();
1301 };
1302 let Ok(value) = parse_resolver_config_json(&source) else {
1303 return Vec::new();
1304 };
1305
1306 let mut specs = Vec::new();
1307 collect_extends_specs(value.get("extends"), &mut specs);
1308 specs
1309 .into_iter()
1310 .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
1311 .collect()
1312}
1313
1314fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
1315 serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
1316}
1317
1318fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
1319 match value {
1320 Some(serde_json::Value::String(spec)) => specs.push(spec),
1321 Some(serde_json::Value::Array(values)) => {
1322 for value in values {
1323 collect_extends_specs(Some(value), specs);
1324 }
1325 }
1326 _ => {}
1327 }
1328}
1329
1330fn resolve_resolver_config_extends(
1331 config: &Path,
1332 project_root: &Path,
1333 spec: &str,
1334) -> Vec<ResolverConfigDependency> {
1335 let config_dir = config.parent().unwrap_or(project_root);
1336 let spec_path = Path::new(spec);
1337 if spec_path.is_absolute() || spec.starts_with('.') {
1338 return resolver_config_extends_target(&config_dir.join(spec_path))
1339 .map(ResolverConfigDependency::resolver_config)
1340 .into_iter()
1341 .collect();
1342 }
1343
1344 node_modules_resolver_config_dependencies(config_dir, project_root, spec)
1345}
1346
1347fn node_modules_resolver_config_dependencies(
1348 config_dir: &Path,
1349 project_root: &Path,
1350 spec: &str,
1351) -> Vec<ResolverConfigDependency> {
1352 let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1353 let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
1354 let enforce_project_boundary = config_dir.starts_with(&boundary);
1355 let is_bare_package = is_bare_package_extends_spec(spec);
1356 let mut dependencies = Vec::new();
1357 for ancestor in config_dir.ancestors() {
1358 let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
1359 if enforce_project_boundary && !ancestor.starts_with(&boundary) {
1360 break;
1361 }
1362 let package_dir = ancestor.join("node_modules").join(spec);
1363 let mut ancestor_dependencies = Vec::new();
1364 if is_bare_package {
1365 if let Some(mut package_dependencies) =
1366 package_json_resolver_config_dependencies(&package_dir)
1367 {
1368 let has_resolver_config = package_dependencies
1369 .iter()
1370 .any(|dependency| dependency.follow_extends);
1371 ancestor_dependencies.append(&mut package_dependencies);
1372 if has_resolver_config {
1373 dependencies.extend(ancestor_dependencies);
1374 return dependencies;
1375 }
1376 }
1377 }
1378 if let Some(target) = resolver_config_extends_target(&package_dir) {
1379 ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
1380 dependencies.extend(ancestor_dependencies);
1381 return dependencies;
1382 }
1383 dependencies.extend(ancestor_dependencies);
1384 }
1385 dependencies
1386}
1387
1388fn package_json_resolver_config_dependencies(
1389 package_dir: &Path,
1390) -> Option<Vec<ResolverConfigDependency>> {
1391 let package_json = canonical_file_path(package_dir.join("package.json"))?;
1392 let package_root = package_json
1393 .parent()
1394 .map(Path::to_path_buf)
1395 .unwrap_or_else(|| package_dir.to_path_buf());
1396 let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
1397
1398 let Ok(source) = fs::read_to_string(&package_json) else {
1399 return Some(dependencies);
1400 };
1401 let Ok(value) = parse_resolver_config_json(&source) else {
1402 return Some(dependencies);
1403 };
1404 let selected_config = value
1405 .get("tsconfig")
1406 .and_then(serde_json::Value::as_str)
1407 .map(str::trim)
1408 .filter(|value| !value.is_empty())
1409 .unwrap_or("tsconfig.json");
1410 if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
1411 dependencies.push(ResolverConfigDependency::resolver_config(target));
1412 }
1413
1414 Some(dependencies)
1415}
1416
1417fn is_bare_package_extends_spec(spec: &str) -> bool {
1418 let mut parts = spec.split('/').filter(|part| !part.is_empty());
1419 let Some(first) = parts.next() else {
1420 return false;
1421 };
1422 if first.starts_with('@') {
1423 parts.next().is_some() && parts.next().is_none()
1424 } else {
1425 parts.next().is_none()
1426 }
1427}
1428
1429fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
1430 resolver_config_extends_candidates(base)
1431 .into_iter()
1432 .find_map(canonical_file_path)
1433}
1434
1435fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
1436 let mut candidates = vec![base.to_path_buf()];
1437 if base.extension().is_none() {
1438 candidates.push(base.with_extension("json"));
1439 candidates.push(base.join("tsconfig.json"));
1440 }
1441 candidates
1442}
1443
1444fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
1445 if !path.is_file() {
1446 return None;
1447 }
1448 Some(fs::canonicalize(&path).unwrap_or(path))
1449}
1450
1451fn update_manifest_fingerprint_hash(
1452 hasher: &mut blake3::Hasher,
1453 project_root: &Path,
1454) -> Result<(), InspectCacheError> {
1455 let manifest_root =
1456 fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1457 hasher.update(b"entry-point-manifests\0");
1458 for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
1459 let relative_path = manifest
1460 .strip_prefix(&manifest_root)
1461 .unwrap_or(manifest.as_path())
1462 .to_string_lossy()
1463 .replace('\\', "/");
1464 let content_hash = blake3::hash(&fs::read(&manifest)?);
1465 hasher.update(relative_path.as_bytes());
1466 hasher.update(b"\0");
1467 hasher.update(content_hash.as_bytes());
1468 hasher.update(b"\0");
1469 }
1470 Ok(())
1471}
1472
1473fn relative_string(project_root: &Path, path: &Path) -> String {
1474 if let Ok(relative) = path.strip_prefix(project_root) {
1475 return relative.to_string_lossy().to_string();
1476 }
1477
1478 if let (Ok(canonical_root), Ok(canonical_path)) =
1479 (fs::canonicalize(project_root), fs::canonicalize(path))
1480 {
1481 if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
1482 return relative.to_string_lossy().to_string();
1483 }
1484 }
1485
1486 path.to_string_lossy().to_string()
1487}
1488
1489fn system_time_to_ns(time: SystemTime) -> i64 {
1490 let nanos = time
1491 .duration_since(UNIX_EPOCH)
1492 .unwrap_or_else(|_| Duration::from_secs(0))
1493 .as_nanos();
1494 nanos.min(i64::MAX as u128) as i64
1495}
1496
1497fn ns_to_system_time(value: i64) -> SystemTime {
1498 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
1499}
1500
1501fn hash_to_hex(hash: blake3::Hash) -> String {
1502 hash.to_hex().to_string()
1503}
1504
1505fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
1506 if value.len() != 64 {
1507 return Err(InspectCacheError::InvalidHash(value.to_string()));
1508 }
1509 let mut bytes = [0u8; 32];
1510 for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
1511 let hex = std::str::from_utf8(chunk)
1512 .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
1513 bytes[index] = u8::from_str_radix(hex, 16)
1514 .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
1515 }
1516 Ok(blake3::Hash::from_bytes(bytes))
1517}
1518
1519fn unix_seconds_now() -> i64 {
1520 SystemTime::now()
1521 .duration_since(UNIX_EPOCH)
1522 .unwrap_or_else(|_| Duration::from_secs(0))
1523 .as_secs()
1524 .min(i64::MAX as u64) as i64
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use std::cell::Cell;
1531 use std::fs;
1532 use std::path::{Path, PathBuf};
1533
1534 fn collect_freshness(path: &Path) -> FileFreshness {
1535 crate::cache_freshness::collect(path).unwrap()
1536 }
1537
1538 #[test]
1539 fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
1540 let temp = tempfile::tempdir().unwrap();
1541 let memo = Tier1FileMemo::<usize>::default();
1542 let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
1543
1544 for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
1545 let path = temp.path().join(format!("file-{index}.txt"));
1546 fs::write(&path, index.to_string()).unwrap();
1547 let value =
1548 memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
1549 assert_eq!(value, index);
1550 paths.push(path);
1551 }
1552
1553 let recent_path = paths[0].clone();
1554 let recent_value = memo.get_or_insert_with(&recent_path, |_| {
1555 panic!("recently inserted entry should hit before eviction")
1556 });
1557 assert_eq!(recent_value, 0);
1558
1559 let evicting_path = temp.path().join("new-file.txt");
1560 fs::write(&evicting_path, "new").unwrap();
1561 let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
1562 (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
1563 });
1564 assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
1565
1566 let state = memo.state.lock().unwrap();
1567 assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
1568 assert!(state.entries.contains_key(&recent_path));
1569 assert!(state.entries.contains_key(&evicting_path));
1570 assert!(!state.entries.contains_key(&paths[1]));
1571 drop(state);
1572
1573 let recent_value = memo.get_or_insert_with(&recent_path, |_| {
1574 panic!("recently used entry should survive eviction")
1575 });
1576 assert_eq!(recent_value, 0);
1577 }
1578
1579 #[test]
1580 fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
1581 let temp = tempfile::tempdir().unwrap();
1582 let memo = Tier1FileMemo::<usize>::default();
1583 let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
1584
1585 for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
1586 let path = temp.path().join(format!("file-{index}.txt"));
1587 fs::write(&path, index.to_string()).unwrap();
1588 memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
1589 paths.push(path);
1590 }
1591
1592 for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
1593 let value = memo.get_or_insert_with(&paths[0], |_| {
1594 panic!("hot entry should stay cached while it is repeatedly touched")
1595 });
1596 assert_eq!(value, 0);
1597 }
1598
1599 let evicting_path = temp.path().join("new-file.txt");
1600 fs::write(&evicting_path, "new").unwrap();
1601 memo.get_or_insert_with(&evicting_path, |path| {
1602 (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
1603 });
1604
1605 let state = memo.state.lock().unwrap();
1606 assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
1607 assert!(state.entries.contains_key(&paths[0]));
1608 assert!(state.entries.contains_key(&evicting_path));
1609 assert!(!state.entries.contains_key(&paths[1]));
1610 assert!(
1611 state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
1612 "lazy LRU queue should be compacted instead of growing without bound"
1613 );
1614 }
1615
1616 #[test]
1617 fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
1618 let temp = tempfile::tempdir().unwrap();
1619 let path = temp.path().join("memo.txt");
1620 fs::write(&path, "first").unwrap();
1621
1622 let memo = Tier1FileMemo::<String>::default();
1623 let scans = Cell::new(0);
1624
1625 let first = memo.get_or_insert_with(&path, |path| {
1626 scans.set(scans.get() + 1);
1627 (Some(collect_freshness(path)), "first scan".to_string())
1628 });
1629 assert_eq!(first, "first scan");
1630 assert_eq!(scans.get(), 1);
1631
1632 let unchanged =
1633 memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
1634 assert_eq!(unchanged, "first scan");
1635 assert_eq!(scans.get(), 1);
1636
1637 fs::write(&path, "changed file contents").unwrap();
1638 let changed = memo.get_or_insert_with(&path, |path| {
1639 scans.set(scans.get() + 1);
1640 (Some(collect_freshness(path)), "second scan".to_string())
1641 });
1642 assert_eq!(changed, "second scan");
1643 assert_eq!(scans.get(), 2);
1644
1645 let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
1646 panic!("rescanned file should reuse refreshed Tier-1 memo")
1647 });
1648 assert_eq!(fresh_after_rescan, "second scan");
1649 assert_eq!(scans.get(), 2);
1650 }
1651
1652 #[derive(serde::Deserialize, serde::Serialize)]
1653 struct RoundTripContributionRecord {
1654 category: String,
1655 file_path: PathBuf,
1656 contribution: serde_json::Value,
1657 type_ref_names: BTreeSet<String>,
1658 }
1659
1660 impl From<&ContributionRecord> for RoundTripContributionRecord {
1661 fn from(record: &ContributionRecord) -> Self {
1662 Self {
1663 category: record.category.as_str().to_string(),
1664 file_path: record.file_path.clone(),
1665 contribution: record.contribution.clone(),
1666 type_ref_names: record.type_ref_names.clone(),
1667 }
1668 }
1669 }
1670
1671 #[test]
1672 fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
1673 let temp = tempfile::tempdir().unwrap();
1674 let project_root = temp.path().join("project");
1675 let inspect_dir = temp.path().join("inspect");
1676 let source = project_root.join("src/lib.ts");
1677 fs::create_dir_all(source.parent().unwrap()).unwrap();
1678 fs::write(&source, "export interface Widget { id: string }\n").unwrap();
1679
1680 let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
1681 let contribution = FileContribution::new(
1682 InspectCategory::DeadCode,
1683 source.clone(),
1684 collect_freshness(&source),
1685 serde_json::json!({
1686 "file": "src/lib.ts",
1687 "exports": [{
1688 "symbol": "Widget",
1689 "kind": "interface",
1690 "line": 1,
1691 "is_type_like": true,
1692 "is_entry_point": false,
1693 }],
1694 "internal_calls": [],
1695 "liveness_roots": [],
1696 "dispatched_method_names": ["render"],
1697 "macro_token_refs": [{
1698 "caller_symbol": "render",
1699 "line": 1,
1700 "name": "Widget",
1701 "shape": "struct"
1702 }],
1703 "type_ref_names": ["Widget"],
1704 }),
1705 )
1706 .with_type_ref_names(["Widget".to_string()]);
1707 cache
1708 .store_tier2_result(
1709 JobKey::for_project_category(InspectCategory::DeadCode),
1710 std::slice::from_ref(&source),
1711 &[contribution],
1712 serde_json::json!({ "count": 0, "items": [] }),
1713 )
1714 .unwrap();
1715 drop(cache);
1716
1717 let cache = InspectCache::open(inspect_dir, project_root).unwrap();
1718 let records = cache
1719 .load_tier2_contributions(InspectCategory::DeadCode)
1720 .unwrap();
1721 assert_eq!(records.len(), 1);
1722
1723 let serialized =
1724 serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
1725 let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
1726 assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
1727 assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
1728 assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
1729 assert_eq!(
1730 decoded.contribution["macro_token_refs"][0]["shape"],
1731 "struct"
1732 );
1733 assert!(decoded.type_ref_names.contains("Widget"));
1734 assert_eq!(
1735 decoded.contribution["exports"][0]["is_type_like"].as_bool(),
1736 Some(true)
1737 );
1738 assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 28);
1739 }
1740
1741 #[test]
1742 fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
1743 let temp = tempfile::tempdir().unwrap();
1744 let project_root = temp.path().join("project");
1745 fs::create_dir_all(&project_root).unwrap();
1746 let left = project_root.join("plugin/a.ts");
1747 let right = project_root.join("pi-plugin/a.ts");
1748 fs::create_dir_all(left.parent().unwrap()).unwrap();
1749 fs::create_dir_all(right.parent().unwrap()).unwrap();
1750 fs::write(&left, "export const value = 1;\n").unwrap();
1751 fs::write(&right, "export const value = 1;\n").unwrap();
1752
1753 let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
1754 let contributions = vec![
1755 FileContribution::new(
1756 InspectCategory::Duplicates,
1757 left.clone(),
1758 collect_freshness(&left),
1759 serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
1760 ),
1761 FileContribution::new(
1762 InspectCategory::Duplicates,
1763 right.clone(),
1764 collect_freshness(&right),
1765 serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
1766 ),
1767 ];
1768 let config = Config::default();
1769 cache
1770 .store_tier2_result_for_config(
1771 JobKey::for_project_category(InspectCategory::Duplicates),
1772 &[left.clone(), right.clone()],
1773 &contributions,
1774 serde_json::json!({ "count": 0, "items": [] }),
1775 &config,
1776 )
1777 .unwrap();
1778
1779 let without_mirrors = cache
1780 .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
1781 .unwrap();
1782 let mut mirror_config = Config::default();
1783 mirror_config.inspect.duplicates.expected_mirrors =
1784 vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
1785 let with_mirrors = cache
1786 .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
1787 .unwrap();
1788
1789 assert_ne!(without_mirrors, with_mirrors);
1790 }
1791}