Skip to main content

aft/
readonly_artifacts.rs

1use std::path::{Path, PathBuf};
2
3// These openers borrow cache artifacts that may be owned by a different AFT
4// session. They therefore only read and verify the opened snapshot; any repair,
5// migration, deletion, or rebuild must be left to the session that owns writes
6// for that project.
7use 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(&current).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        // fixture_project returns a canonicalized root, which on Windows is a
425        // verbatim `\\?\` path. git cannot take verbatim paths as the clone
426        // source, so strip the prefix for the git invocation only.
427        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}