1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use crate::cache_freshness::{artifact_generation, ArtifactGeneration};
9use crate::search_index::{
10 artifact_cache_key_with_memo, resolve_cache_dir, resolve_cache_dir_with_key, BorrowedIndexLoad,
11 BorrowedIndexLoadStop, SearchIndex,
12};
13use crate::semantic_index::SemanticIndex;
14
15#[derive(Clone, Debug)]
16pub(crate) enum ReadOnlyArtifact<T> {
17 Fresh(T),
18 Stale(ReadOnlyStale<T>),
19 Degraded(ReadOnlyDegradation),
20 Cancelled,
21 Absent,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub(crate) struct ReadOnlyDegradation {
26 pub reason: &'static str,
27}
28
29pub(crate) const BORROWED_SEARCH_LOAD_DEGRADATION: ReadOnlyDegradation = ReadOnlyDegradation {
30 reason: "borrowed_search_index_load_budget",
31};
32pub(crate) const BORROWED_SEMANTIC_LOAD_DEGRADATION: ReadOnlyDegradation = ReadOnlyDegradation {
33 reason: "borrowed_semantic_index_load_budget",
34};
35const BORROWED_SEMANTIC_MAX_BYTES: u64 = 64 * 1024 * 1024;
36
37#[cfg(test)]
38thread_local! {
39 static BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST: std::cell::Cell<Option<(usize, Duration)>> = const { std::cell::Cell::new(None) };
40}
41
42fn borrowed_search_load_limits() -> (usize, Duration) {
43 #[cfg(test)]
44 if let Some(limits) = BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST.with(std::cell::Cell::get) {
45 return limits;
46 }
47 (
48 crate::search_index::BORROWED_INDEX_LOAD_MAX_RECORDS,
49 crate::search_index::BORROWED_INDEX_LOAD_BUDGET,
50 )
51}
52
53#[cfg(test)]
54pub(crate) fn with_borrowed_search_load_limits_for_test<R>(
55 max_records: usize,
56 duration: Duration,
57 run: impl FnOnce() -> R,
58) -> R {
59 BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST.with(|slot| {
60 let previous = slot.replace(Some((max_records, duration)));
61 let result = run();
62 slot.set(previous);
63 result
64 })
65}
66
67#[derive(Clone, Debug)]
68pub(crate) struct ReadOnlyStale<T> {
69 pub index: T,
70 pub drift_count: usize,
71 pub ignore_rules_differ: bool,
72}
73
74impl<T> ReadOnlyArtifact<T> {
75 pub(crate) fn map<U>(self, map: impl FnOnce(T) -> U) -> ReadOnlyArtifact<U> {
76 match self {
77 Self::Fresh(index) => ReadOnlyArtifact::Fresh(map(index)),
78 Self::Stale(stale) => ReadOnlyArtifact::Stale(ReadOnlyStale {
79 index: map(stale.index),
80 drift_count: stale.drift_count,
81 ignore_rules_differ: stale.ignore_rules_differ,
82 }),
83 Self::Degraded(degradation) => ReadOnlyArtifact::Degraded(degradation),
84 Self::Cancelled => ReadOnlyArtifact::Cancelled,
85 Self::Absent => ReadOnlyArtifact::Absent,
86 }
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub(crate) struct BorrowedArtifactGeneration {
92 pub path: PathBuf,
93 pub generation: ArtifactGeneration,
94}
95
96#[derive(Debug)]
97pub(crate) enum GitRootResolutionError {
98 PathNotFound(PathBuf),
99 NotAGitRoot,
100 Other(String),
101}
102
103pub(crate) fn resolve_git_root_from_user_path(
104 project_root: &Path,
105 raw_path: &str,
106) -> Result<PathBuf, GitRootResolutionError> {
107 let expanded = expand_tilde(raw_path);
108 let requested = if expanded.is_absolute() {
109 expanded
110 } else {
111 project_root.join(expanded)
112 };
113 if !requested.exists() {
114 return Err(GitRootResolutionError::PathNotFound(requested));
115 }
116
117 let existing = nearest_existing_parent(&requested)
118 .ok_or_else(|| GitRootResolutionError::PathNotFound(requested.clone()))?;
119
120 let existing = crate::inspect::job::canonicalize_normalized(&existing);
125 if existing == crate::inspect::job::canonicalize_normalized(project_root) {
126 return Ok(project_root.to_path_buf());
127 }
128
129 let git_base = if existing.is_file() {
130 existing.parent().unwrap_or(&existing).to_path_buf()
131 } else {
132 existing
133 };
134 git_toplevel(&git_base).map_err(|error| match error.as_str() {
135 "not_a_git_root" => GitRootResolutionError::NotAGitRoot,
136 _ => GitRootResolutionError::Other(error),
137 })
138}
139
140pub(crate) fn search_index_artifact_generation(
141 project_root: &Path,
142 storage_dir: Option<&Path>,
143) -> Option<BorrowedArtifactGeneration> {
144 let cache_dir = search_index_cache_dir(project_root, storage_dir)?;
145 search_artifact_generation_from_cache_dir(cache_dir)
146}
147
148pub(crate) fn search_index_artifact_generation_with_key(
149 project_key: &str,
150 storage_dir: Option<&Path>,
151) -> Option<BorrowedArtifactGeneration> {
152 search_artifact_generation_from_cache_dir(resolve_cache_dir_with_key(project_key, storage_dir))
153}
154
155fn search_artifact_generation_from_cache_dir(
156 cache_dir: PathBuf,
157) -> Option<BorrowedArtifactGeneration> {
158 let path = cache_dir.join("cache.bin");
159 let generation = artifact_generation(&path)?;
160 Some(BorrowedArtifactGeneration { path, generation })
161}
162
163pub(crate) fn open_search_index_read_only(
164 project_root: &Path,
165 storage_dir: Option<&Path>,
166) -> ReadOnlyArtifact<SearchIndex> {
167 let Some(cache_dir) = search_index_cache_dir(project_root, storage_dir) else {
168 return ReadOnlyArtifact::Absent;
169 };
170 open_search_index_from_cache_dir(project_root, cache_dir)
171}
172
173pub(crate) fn open_search_index_read_only_with_key(
174 project_root: &Path,
175 storage_dir: Option<&Path>,
176 project_key: &str,
177) -> ReadOnlyArtifact<SearchIndex> {
178 open_search_index_from_cache_dir(
179 project_root,
180 resolve_cache_dir_with_key(project_key, storage_dir),
181 )
182}
183
184fn open_search_index_from_cache_dir(
185 project_root: &Path,
186 cache_dir: PathBuf,
187) -> ReadOnlyArtifact<SearchIndex> {
188 let (max_records, duration) = borrowed_search_load_limits();
189 open_search_index_from_cache_dir_with_budget(project_root, cache_dir, max_records, duration)
190}
191
192fn open_search_index_from_cache_dir_with_budget(
193 project_root: &Path,
194 cache_dir: PathBuf,
195 max_records: usize,
196 duration: Duration,
197) -> ReadOnlyArtifact<SearchIndex> {
198 if !cache_dir.join("cache.bin").is_file() {
199 return ReadOnlyArtifact::Absent;
200 }
201
202 let (mut index, ignore_rules_differ) =
203 match SearchIndex::read_from_disk_borrow_tolerant_with_budget(
204 &cache_dir,
205 project_root,
206 max_records,
207 duration,
208 ) {
209 BorrowedIndexLoad::Loaded(index, ignore_rules_differ) => (index, ignore_rules_differ),
210 BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::BudgetExceeded) => {
211 return ReadOnlyArtifact::Degraded(BORROWED_SEARCH_LOAD_DEGRADATION);
212 }
213 BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::Cancelled) => {
214 return ReadOnlyArtifact::Cancelled;
215 }
216 BorrowedIndexLoad::Invalid => return ReadOnlyArtifact::Absent,
217 };
218
219 index.set_ready(true);
220 if ignore_rules_differ {
221 ReadOnlyArtifact::Stale(ReadOnlyStale {
222 index,
223 drift_count: 0,
224 ignore_rules_differ,
225 })
226 } else {
227 ReadOnlyArtifact::Fresh(index)
228 }
229}
230
231fn search_index_cache_dir(project_root: &Path, storage_dir: Option<&Path>) -> Option<PathBuf> {
232 match storage_dir {
233 Some(storage_dir) => {
234 match artifact_cache_key_with_memo(project_root, project_root, storage_dir, None) {
235 Ok(project_key) => {
236 Some(resolve_cache_dir_with_key(&project_key, Some(storage_dir)))
237 }
238 Err(error) => {
239 crate::slog_warn!("read-only search index unavailable: {}", error);
240 None
241 }
242 }
243 }
244 None => Some(resolve_cache_dir(project_root, None)),
245 }
246}
247
248#[cfg(test)]
249pub(crate) fn semantic_index_artifact_generation(
250 project_root: &Path,
251 storage_dir: Option<&Path>,
252) -> Option<BorrowedArtifactGeneration> {
253 let (data_path, _) = semantic_index_location(project_root, storage_dir)?;
254 borrowed_artifact_generation(data_path)
255}
256
257pub(crate) fn semantic_index_artifact_generation_with_key(
258 project_key: &str,
259 storage_dir: Option<&Path>,
260) -> Option<BorrowedArtifactGeneration> {
261 let storage_dir = storage_dir?;
262 borrowed_artifact_generation(
263 storage_dir
264 .join("semantic")
265 .join(project_key)
266 .join("semantic.bin"),
267 )
268}
269
270fn borrowed_artifact_generation(path: PathBuf) -> Option<BorrowedArtifactGeneration> {
271 let generation = artifact_generation(&path)?;
272 Some(BorrowedArtifactGeneration { path, generation })
273}
274
275pub(crate) fn open_semantic_index_read_only(
276 project_root: &Path,
277 storage_dir: Option<&Path>,
278) -> ReadOnlyArtifact<SemanticIndex> {
279 let Some((data_path, project_key)) = semantic_index_location(project_root, storage_dir) else {
280 return ReadOnlyArtifact::Absent;
281 };
282 open_semantic_index_from_location(project_root, storage_dir, data_path, &project_key)
283}
284
285pub(crate) fn open_semantic_index_read_only_with_key(
286 project_root: &Path,
287 storage_dir: Option<&Path>,
288 project_key: &str,
289) -> ReadOnlyArtifact<SemanticIndex> {
290 let Some(storage_dir) = storage_dir else {
291 return ReadOnlyArtifact::Absent;
292 };
293 let data_path = storage_dir
294 .join("semantic")
295 .join(project_key)
296 .join("semantic.bin");
297 open_semantic_index_from_location(project_root, Some(storage_dir), data_path, project_key)
298}
299
300fn open_semantic_index_from_location(
301 project_root: &Path,
302 storage_dir: Option<&Path>,
303 data_path: PathBuf,
304 project_key: &str,
305) -> ReadOnlyArtifact<SemanticIndex> {
306 let Some(storage_dir) = storage_dir else {
307 return ReadOnlyArtifact::Absent;
308 };
309 let Ok(metadata) = data_path.metadata() else {
310 return ReadOnlyArtifact::Absent;
311 };
312 if metadata.len() > BORROWED_SEMANTIC_MAX_BYTES {
313 return ReadOnlyArtifact::Degraded(BORROWED_SEMANTIC_LOAD_DEGRADATION);
314 }
315 if search_cancelled() {
316 return ReadOnlyArtifact::Cancelled;
317 }
318
319 let opened =
320 SemanticIndex::read_from_disk_borrow_tolerant(storage_dir, project_key, project_root)
321 .map(ReadOnlyArtifact::Fresh)
322 .unwrap_or(ReadOnlyArtifact::Absent);
323 if search_cancelled() {
324 ReadOnlyArtifact::Cancelled
325 } else {
326 opened
327 }
328}
329
330fn search_cancelled() -> bool {
331 crate::executor::current_job_cancelled()
332}
333
334fn semantic_index_location(
335 project_root: &Path,
336 storage_dir: Option<&Path>,
337) -> Option<(PathBuf, String)> {
338 let storage_dir = storage_dir?;
339 let project_key =
340 match artifact_cache_key_with_memo(project_root, project_root, storage_dir, None) {
341 Ok(project_key) => project_key,
342 Err(error) => {
343 crate::slog_warn!("read-only semantic index unavailable: {}", error);
344 return None;
345 }
346 };
347 let data_path = storage_dir
348 .join("semantic")
349 .join(&project_key)
350 .join("semantic.bin");
351 Some((data_path, project_key))
352}
353
354fn expand_tilde(raw: &str) -> PathBuf {
355 if raw == "~" {
356 return home_dir().unwrap_or_else(|| PathBuf::from(raw));
357 }
358 if let Some(rest) = raw.strip_prefix("~/") {
359 if let Some(home) = home_dir() {
360 return home.join(rest);
361 }
362 }
363 PathBuf::from(raw)
364}
365
366fn home_dir() -> Option<PathBuf> {
367 crate::environment::non_empty_os_var("HOME")
368 .or_else(|| crate::environment::non_empty_os_var("USERPROFILE"))
369 .map(PathBuf::from)
370}
371
372fn nearest_existing_parent(path: &Path) -> Option<PathBuf> {
373 let mut current = path.to_path_buf();
374 loop {
375 if current.exists() {
376 return std::fs::canonicalize(¤t).ok().or(Some(current));
377 }
378 if !current.pop() {
379 return None;
380 }
381 }
382}
383
384fn git_toplevel(base_dir: &Path) -> Result<PathBuf, String> {
385 let output = crate::effective_path::new_command("git")
386 .args(["rev-parse", "--show-toplevel"])
387 .current_dir(base_dir)
388 .output()
389 .map_err(|error| format!("failed to run git: {error}"))?;
390
391 if !output.status.success() {
392 let stderr = String::from_utf8_lossy(&output.stderr);
393 if stderr.contains("not a git repository") {
394 return Err("not_a_git_root".to_string());
395 }
396 return Err(format!("git rev-parse failed: {}", stderr.trim()));
397 }
398
399 let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
400 if toplevel.is_empty() {
401 return Err("git rev-parse returned an empty toplevel".to_string());
402 }
403 let toplevel = PathBuf::from(toplevel);
404 Ok(std::fs::canonicalize(&toplevel).unwrap_or(toplevel))
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 use std::collections::BTreeMap;
412 use std::fs;
413 use std::process::Command;
414 use std::time::SystemTime;
415
416 use tempfile::TempDir;
417
418 use crate::context::BORROWED_INDEX_CACHE_CAPACITY;
419 use crate::search_index::artifact_cache_key;
420 use crate::semantic_index::{SemanticIndex, SemanticIndexFingerprint};
421
422 #[derive(Debug, PartialEq, Eq)]
423 struct FileSnapshot {
424 modified: SystemTime,
425 hash: blake3::Hash,
426 }
427
428 fn git_command(root: &Path) -> Command {
429 let mut command = Command::new("git");
430 crate::test_env::apply_hermetic_git_env(command.current_dir(root));
431 command
432 }
433
434 fn init_git(root: &Path) {
435 let status = git_command(root)
436 .args(["init"])
437 .status()
438 .expect("run git init");
439 assert!(status.success(), "git init failed");
440 let status = git_command(root)
441 .args(["config", "user.email", "test@example.com"])
442 .status()
443 .expect("configure git email");
444 assert!(status.success(), "git config email failed");
445 let status = git_command(root)
446 .args(["config", "user.name", "AFT Test"])
447 .status()
448 .expect("configure git name");
449 assert!(status.success(), "git config name failed");
450 }
451
452 fn commit_all(root: &Path) {
453 let status = git_command(root)
454 .args(["add", "."])
455 .status()
456 .expect("git add");
457 assert!(status.success(), "git add failed");
458 let status = git_command(root)
459 .args(["commit", "-m", "initial"])
460 .status()
461 .expect("git commit");
462 assert!(status.success(), "git commit failed");
463 }
464
465 fn fixture_project() -> (TempDir, PathBuf) {
466 let temp = tempfile::tempdir().expect("create project");
467 init_git(temp.path());
468 let source = temp.path().join("src/lib.rs");
469 fs::create_dir_all(source.parent().expect("source parent")).expect("create src");
470 fs::write(&source, "pub fn readonly_needle() -> bool { true }\n").expect("write source");
471 commit_all(temp.path());
472 let root = fs::canonicalize(temp.path()).expect("canonical project root");
473 (temp, root)
474 }
475
476 fn snapshot_dir(root: &Path) -> BTreeMap<PathBuf, FileSnapshot> {
477 fn visit(dir: &Path, out: &mut BTreeMap<PathBuf, FileSnapshot>, base: &Path) {
478 for entry in fs::read_dir(dir).expect("read dir") {
479 let entry = entry.expect("dir entry");
480 let path = entry.path();
481 let meta = entry.metadata().expect("entry metadata");
482 if meta.is_dir() {
483 visit(&path, out, base);
484 } else if meta.is_file() {
485 let bytes = fs::read(&path).expect("read snapshot file");
486 out.insert(
487 path.strip_prefix(base)
488 .expect("relative snapshot path")
489 .to_path_buf(),
490 FileSnapshot {
491 modified: meta.modified().expect("snapshot mtime"),
492 hash: blake3::hash(&bytes),
493 },
494 );
495 }
496 }
497 }
498
499 let mut out = BTreeMap::new();
500 if root.exists() {
501 visit(root, &mut out, root);
502 }
503 out
504 }
505
506 fn build_search_artifact(root: &Path, storage: &Path) -> PathBuf {
507 let cache_dir = resolve_cache_dir(root, Some(storage));
508 let mut index = SearchIndex::build(root);
509 index.write_to_disk(
510 &cache_dir,
511 crate::search_index::current_git_head(root).as_deref(),
512 );
513 cache_dir
514 }
515
516 fn clone_checkout(root: &Path) -> (TempDir, PathBuf) {
517 let temp = tempfile::tempdir().expect("create clone dir");
518 let clone_root = temp.path().join("clone");
519 let clone_source = root
523 .to_string_lossy()
524 .trim_start_matches(r"\\?\")
525 .to_string();
526 let mut command = Command::new("git");
527 let status = crate::test_env::apply_hermetic_git_env(&mut command)
528 .arg("clone")
529 .arg("--quiet")
530 .arg(&clone_source)
531 .arg(&clone_root)
532 .status()
533 .expect("git clone");
534 assert!(status.success(), "git clone failed");
535 let clone_root = fs::canonicalize(clone_root).expect("canonical clone root");
536 (temp, clone_root)
537 }
538
539 fn build_semantic_artifact(root: &Path, storage: &Path) {
540 let source = root.join("src/lib.rs");
541 let fingerprint = SemanticIndexFingerprint {
542 backend: "openai_compatible".to_string(),
543 model: "readonly-test".to_string(),
544 base_url: "http://127.0.0.1".to_string(),
545 dimension: 3,
546 chunking_version: 1,
547 ..Default::default()
548 };
549 let mut embed =
550 |texts: Vec<String>| Ok::<_, String>(vec![vec![0.1, 0.2, 0.3]; texts.len()]);
551 let mut index =
552 SemanticIndex::build(root, &[source], &mut embed, 8).expect("build semantic index");
553 index.set_fingerprint(fingerprint);
554 index.write_to_disk(storage, &artifact_cache_key(root));
555 }
556
557 fn borrowed_context(root: &Path, storage: &Path) -> crate::context::AppContext {
558 crate::context::AppContext::new(
559 crate::context::default_language_provider_factory(),
560 crate::config::Config {
561 project_root: Some(root.to_path_buf()),
562 storage_dir: Some(storage.to_path_buf()),
563 ..crate::config::Config::default()
564 },
565 )
566 }
567
568 fn cached_search_index(
569 ctx: &crate::context::AppContext,
570 root: &Path,
571 storage: &Path,
572 ) -> std::sync::Arc<SearchIndex> {
573 match ctx.open_borrowed_search_index(root, Some(storage)) {
574 ReadOnlyArtifact::Fresh(index)
575 | ReadOnlyArtifact::Stale(ReadOnlyStale { index, .. }) => index,
576 other => panic!("expected borrowed search index, got {other:?}"),
577 }
578 }
579
580 #[cfg(debug_assertions)]
581 #[test]
582 fn repeated_borrowed_opens_cache_one_load_per_artifact_generation() {
583 let _git_env = crate::test_env::hermetic_git_env_guard();
584 let (_project, root) = fixture_project();
585 let storage = tempfile::tempdir().expect("storage");
586 build_search_artifact(&root, storage.path());
587 let ctx = borrowed_context(&root, storage.path());
588 search_index_artifact_generation(&root, Some(storage.path()))
589 .expect("seed borrowed artifact generation");
590 let artifact_before = snapshot_dir(storage.path());
591
592 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
593 let first = cached_search_index(&ctx, &root, storage.path());
594 let second = cached_search_index(&ctx, &root, storage.path());
595 assert!(std::sync::Arc::ptr_eq(&first, &second));
596 assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
597 assert_eq!(
598 ctx.artifact_cache_key_derivation_count_for_test(),
599 1,
600 "artifact identity should be derived only on the first external search"
601 );
602 assert_eq!(
603 crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
604 0,
605 "neither the first load nor a cache hit may run a corpus census"
606 );
607 assert_eq!(snapshot_dir(storage.path()), artifact_before);
608
609 let added = root.join("src/added.rs");
610 fs::write(&added, "pub fn generation_two() {}\n").expect("add generation file");
611 build_search_artifact(&root, storage.path());
612 let rebuilt_snapshot = snapshot_dir(storage.path());
613 let third = cached_search_index(&ctx, &root, storage.path());
614 assert!(
615 !std::sync::Arc::ptr_eq(&first, &third),
616 "an owner rebuild must invalidate the cached rerooted generation"
617 );
618 assert!(third.path_to_id.contains_key(&added));
619 assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
620 assert_eq!(ctx.artifact_cache_key_derivation_count_for_test(), 1);
621 assert_eq!(snapshot_dir(storage.path()), rebuilt_snapshot);
622
623 assert!(ctx.evict_idle_artifacts());
624 assert_eq!(ctx.borrowed_index_cache_len_for_test(), 0);
625 }
626
627 #[test]
628 fn repeated_borrowed_semantic_opens_reuse_rerooted_generation() {
629 let _git_env = crate::test_env::hermetic_git_env_guard();
630 let (_project, root) = fixture_project();
631 let storage = tempfile::tempdir().expect("storage");
632 build_semantic_artifact(&root, storage.path());
633 let ctx = borrowed_context(&root, storage.path());
634 semantic_index_artifact_generation(&root, Some(storage.path()))
635 .expect("seed semantic artifact generation");
636 let artifact_before = snapshot_dir(storage.path());
637
638 let first = match ctx.open_borrowed_semantic_index(&root, Some(storage.path())) {
639 ReadOnlyArtifact::Fresh(index) => index,
640 other => panic!("expected borrowed semantic index, got {other:?}"),
641 };
642 let second = match ctx.open_borrowed_semantic_index(&root, Some(storage.path())) {
643 ReadOnlyArtifact::Fresh(index) => index,
644 other => panic!("expected cached semantic index, got {other:?}"),
645 };
646 assert!(std::sync::Arc::ptr_eq(&first, &second));
647 assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
648 assert_eq!(snapshot_dir(storage.path()), artifact_before);
649 }
650
651 #[test]
652 fn borrowed_index_cache_is_bounded_and_idle_evictable() {
653 let _git_env = crate::test_env::hermetic_git_env_guard();
654 let storage = tempfile::tempdir().expect("storage");
655 let mut projects = Vec::new();
656 for _ in 0..=BORROWED_INDEX_CACHE_CAPACITY {
657 let (project, root) = fixture_project();
658 build_search_artifact(&root, storage.path());
659 projects.push((project, root));
660 }
661 let ctx = borrowed_context(&projects[0].1, storage.path());
662 let first = cached_search_index(&ctx, &projects[0].1, storage.path());
663 for (_, root) in projects.iter().skip(1) {
664 cached_search_index(&ctx, root, storage.path());
665 }
666 assert_eq!(
667 ctx.borrowed_index_cache_len_for_test(),
668 BORROWED_INDEX_CACHE_CAPACITY
669 );
670 let reloaded_first = cached_search_index(&ctx, &projects[0].1, storage.path());
671 assert!(!std::sync::Arc::ptr_eq(&first, &reloaded_first));
672
673 assert!(ctx.evict_idle_artifacts());
674 assert_eq!(ctx.borrowed_index_cache_len_for_test(), 0);
675 }
676
677 #[test]
678 fn stale_posting_verification_stops_at_injected_file_budget() {
679 let _git_env = crate::test_env::hermetic_git_env_guard();
680 let (_project, root) = fixture_project();
681 for file_index in 0..20 {
682 fs::write(
683 root.join(format!("src/stale_{file_index}.rs")),
684 "pub fn stale_budget_needle() {}\n",
685 )
686 .expect("write indexed stale candidate");
687 }
688 let storage = tempfile::tempdir().expect("storage");
689 build_search_artifact(&root, storage.path());
690 for file_index in 0..20 {
691 fs::write(
692 root.join(format!("src/stale_{file_index}.rs")),
693 "pub fn changed_after_index_build() {}\n",
694 )
695 .expect("replace indexed stale candidate");
696 }
697 let index = match open_search_index_read_only(&root, Some(storage.path())) {
698 ReadOnlyArtifact::Fresh(index) => index,
699 other => panic!("expected borrowed index, got {other:?}"),
700 };
701 let compiled = match crate::pattern_compile::compile(
702 "stale_budget_needle",
703 crate::pattern_compile::CompileOpts {
704 literal: true,
705 ..crate::pattern_compile::CompileOpts::default()
706 },
707 ) {
708 crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
709 other => panic!("literal compile failed: {other:?}"),
710 };
711
712 let result = index.snapshot().search_grep_bounded(
713 &compiled,
714 &[],
715 &[],
716 &root,
717 10,
718 None,
719 1,
720 std::time::Duration::from_secs(1),
721 );
722 assert!(result.matches.is_empty());
723 assert!(result.files_searched <= 1);
724 assert!(result.truncated);
725 assert!(result.engine_capped);
726 }
727
728 #[test]
729 fn churned_borrowed_generation_stops_at_load_budget() {
730 let _git_env = crate::test_env::hermetic_git_env_guard();
731 let (_project, root) = fixture_project();
732 for file_index in 0..64 {
733 fs::write(
734 root.join(format!("src/module_{file_index}.rs")),
735 format!("pub fn borrowed_budget_{file_index}() {{}}\n"),
736 )
737 .expect("write borrowed corpus file");
738 }
739 let storage = tempfile::tempdir().expect("storage");
740 let cache_dir = build_search_artifact(&root, storage.path());
741 assert!(matches!(
742 open_search_index_from_cache_dir_with_budget(
743 &root,
744 cache_dir.clone(),
745 10_000,
746 Duration::from_secs(5),
747 ),
748 ReadOnlyArtifact::Fresh(_)
749 ));
750
751 for file_index in 0..40 {
752 fs::write(
753 root.join(format!("src/module_{file_index}.rs")),
754 format!("pub fn churned_borrowed_budget_{file_index}() {{}}\n"),
755 )
756 .expect("churn borrowed corpus file");
757 }
758 build_search_artifact(&root, storage.path());
759
760 let started = std::time::Instant::now();
761 let opened = open_search_index_from_cache_dir_with_budget(
762 &root,
763 cache_dir,
764 10,
765 Duration::from_secs(5),
766 );
767 assert_eq!(
768 started.elapsed() < Duration::from_secs(1),
769 true,
770 "record budget must stop before rerooting the churned corpus"
771 );
772 assert!(matches!(
773 opened,
774 ReadOnlyArtifact::Degraded(BORROWED_SEARCH_LOAD_DEGRADATION)
775 ));
776 }
777
778 #[test]
779 fn cancelled_borrowed_load_stops_at_checkpoint() {
780 let _git_env = crate::test_env::hermetic_git_env_guard();
781 let (_project, root) = fixture_project();
782 let storage = tempfile::tempdir().expect("storage");
783 let cache_dir = build_search_artifact(&root, storage.path());
784 let cancellation = crate::executor::JobCancellation::new();
785 cancellation.request_cancel();
786 let _installed = crate::executor::install_job_cancellation(cancellation);
787
788 assert!(matches!(
789 open_search_index_from_cache_dir_with_budget(
790 &root,
791 cache_dir,
792 10_000,
793 Duration::from_secs(5),
794 ),
795 ReadOnlyArtifact::Cancelled
796 ));
797 }
798
799 #[test]
800 fn oversized_borrowed_semantic_artifact_degrades_before_parse() {
801 let _git_env = crate::test_env::hermetic_git_env_guard();
802 let (_project, root) = fixture_project();
803 let storage = tempfile::tempdir().expect("storage");
804 let data_path = storage
805 .path()
806 .join("semantic")
807 .join(artifact_cache_key(&root))
808 .join("semantic.bin");
809 fs::create_dir_all(data_path.parent().expect("semantic parent"))
810 .expect("create semantic artifact directory");
811 let file = fs::File::create(&data_path).expect("create sparse semantic artifact");
812 file.set_len(BORROWED_SEMANTIC_MAX_BYTES + 1)
813 .expect("size sparse semantic artifact");
814
815 assert!(matches!(
816 open_semantic_index_read_only(&root, Some(storage.path())),
817 ReadOnlyArtifact::Degraded(BORROWED_SEMANTIC_LOAD_DEGRADATION)
818 ));
819 }
820
821 #[cfg(debug_assertions)]
822 #[test]
823 fn search_opener_skips_full_corpus_strict_census() {
824 let _git_env = crate::test_env::hermetic_git_env_guard();
825 let (_project, root) = fixture_project();
826 let storage = tempfile::tempdir().expect("storage");
827
828 assert!(matches!(
829 open_search_index_read_only(&root, Some(storage.path())),
830 ReadOnlyArtifact::Absent
831 ));
832
833 build_search_artifact(&root, storage.path());
834 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
835 assert!(matches!(
836 open_search_index_read_only(&root, Some(storage.path())),
837 ReadOnlyArtifact::Fresh(_)
838 ));
839 assert_eq!(
840 crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
841 0,
842 "a borrowed open must not restore the full-corpus strict hash census"
843 );
844
845 fs::write(
846 root.join("src/lib.rs"),
847 "pub fn readonly_needle() -> bool { false }\n",
848 )
849 .expect("mutate fixture");
850 match open_search_index_read_only(&root, Some(storage.path())) {
851 ReadOnlyArtifact::Fresh(index) => {
852 assert!(index.stored_git_head().is_some());
853 }
854 other => panic!("expected silently served borrowed artifact, got {other:?}"),
855 }
856 assert_eq!(
857 crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
858 0
859 );
860 }
861
862 #[test]
863 fn search_opener_marks_cross_checkout_ignore_rule_mismatch_as_stale() {
864 let _git_env = crate::test_env::hermetic_git_env_guard();
865 let (_project, root) = fixture_project();
866 let storage = tempfile::tempdir().expect("storage");
867 let owner_only_ignore = root.join(".foo/.gitignore");
868 fs::create_dir_all(owner_only_ignore.parent().expect("ignore parent"))
869 .expect("create ignore dir");
870 fs::write(&owner_only_ignore, "# owner-only ignore file\n").expect("write ignore file");
871 build_search_artifact(&root, storage.path());
872
873 let (_clone, sibling_root) = clone_checkout(&root);
874 match open_search_index_read_only(&sibling_root, Some(storage.path())) {
875 ReadOnlyArtifact::Stale(stale) => {
876 assert!(stale.ignore_rules_differ);
877 assert!(stale.index.stored_git_head().is_some());
878 }
879 other => panic!("expected stale borrowed artifact, got {other:?}"),
880 }
881 }
882
883 #[test]
884 fn owner_search_loader_stays_strict_on_ignore_rule_mismatch() {
885 let _git_env = crate::test_env::hermetic_git_env_guard();
886 let (_project, root) = fixture_project();
887 let storage = tempfile::tempdir().expect("storage");
888 let cache_dir = build_search_artifact(&root, storage.path());
889 let owner_only_ignore = root.join(".foo/.gitignore");
890 fs::create_dir_all(owner_only_ignore.parent().expect("ignore parent"))
891 .expect("create ignore dir");
892 fs::write(&owner_only_ignore, "# owner-only ignore file\n").expect("write ignore file");
893
894 assert!(SearchIndex::read_from_disk(&cache_dir, &root).is_none());
895 }
896
897 #[test]
898 fn read_only_openers_never_modify_artifact_directory() {
899 let _git_env = crate::test_env::hermetic_git_env_guard();
900 let (_project, root) = fixture_project();
901 let storage = tempfile::tempdir().expect("storage");
902 let search_cache_dir = build_search_artifact(&root, storage.path());
903 build_semantic_artifact(&root, storage.path());
904 artifact_cache_key_with_memo(&root, &root, storage.path(), None)
905 .expect("seed cache-key memo before read-only snapshot");
906 let semantic_cache_dir = storage
907 .path()
908 .join("semantic")
909 .join(artifact_cache_key(&root));
910
911 let before = snapshot_dir(storage.path());
912 assert!(matches!(
913 open_search_index_read_only(&root, Some(storage.path())),
914 ReadOnlyArtifact::Fresh(_)
915 ));
916 assert!(matches!(
917 open_semantic_index_read_only(&root, Some(storage.path())),
918 ReadOnlyArtifact::Fresh(_)
919 ));
920 assert_eq!(snapshot_dir(storage.path()), before);
921
922 fs::write(
923 root.join("src/lib.rs"),
924 "pub fn readonly_needle() -> bool { false }\n",
925 )
926 .expect("mutate fixture");
927 let stale_before = snapshot_dir(storage.path());
928 assert!(matches!(
929 open_search_index_read_only(&root, Some(storage.path())),
930 ReadOnlyArtifact::Fresh(_)
931 ));
932 assert!(matches!(
933 open_semantic_index_read_only(&root, Some(storage.path())),
934 ReadOnlyArtifact::Fresh(_)
935 ));
936 assert_eq!(snapshot_dir(storage.path()), stale_before);
937 assert!(search_cache_dir.join("cache.bin").is_file());
938 assert!(semantic_cache_dir.join("semantic.bin").is_file());
939 }
940}