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