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
71    // `nearest_existing_parent` returns an `fs::canonicalize` result, which on
72    // Windows is a verbatim (`\\?\C:\...`) path. Normalize before comparing the
73    // requested directory with the configured root so aliases such as `.` and
74    // symlinks remain local without requiring the workspace itself to be Git.
75    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(&current).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        // fixture_project returns a canonicalized root, which on Windows is a
435        // verbatim `\\?\` path. git cannot take verbatim paths as the clone
436        // source, so strip the prefix for the git invocation only.
437        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}