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