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