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