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    // `nearest_existing_parent` returns an `fs::canonicalize` result, which on
76    // Windows is a verbatim (`\?\C:\...`) path. `git_toplevel` passes it to
77    // `Command::current_dir`, and `CreateProcess` rejects verbatim paths as
78    // `lpCurrentDirectory` (the same Win32 limitation that breaks LSP spawns in
79    // #174). Strip the prefix here so the git probe spawns under a non-verbatim
80    // cwd. On Unix `canonicalize_normalized` is identity-equivalent.
81    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(&current).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        // fixture_project returns a canonicalized root, which on Windows is a
432        // verbatim `\\?\` path. git cannot take verbatim paths as the clone
433        // source, so strip the prefix for the git invocation only.
434        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}