Skip to main content

aft/
readonly_artifacts.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4// These openers borrow cache artifacts that may be owned by a different AFT
5// session. They therefore only read and verify the opened snapshot; any repair,
6// migration, deletion, or rebuild must be left to the session that owns writes
7// for that project.
8use crate::cache_freshness::{artifact_generation, ArtifactGeneration};
9use crate::search_index::{
10    artifact_cache_key_with_memo, resolve_cache_dir, resolve_cache_dir_with_key, BorrowedIndexLoad,
11    BorrowedIndexLoadStop, SearchIndex,
12};
13use crate::semantic_index::SemanticIndex;
14
15#[derive(Clone, Debug)]
16pub(crate) enum ReadOnlyArtifact<T> {
17    Fresh(T),
18    Stale(ReadOnlyStale<T>),
19    Degraded(ReadOnlyDegradation),
20    Cancelled,
21    Absent,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub(crate) struct ReadOnlyDegradation {
26    pub reason: &'static str,
27}
28
29pub(crate) const BORROWED_SEARCH_LOAD_DEGRADATION: ReadOnlyDegradation = ReadOnlyDegradation {
30    reason: "borrowed_search_index_load_budget",
31};
32pub(crate) const BORROWED_SEMANTIC_LOAD_DEGRADATION: ReadOnlyDegradation = ReadOnlyDegradation {
33    reason: "borrowed_semantic_index_load_budget",
34};
35const BORROWED_SEMANTIC_MAX_BYTES: u64 = 64 * 1024 * 1024;
36
37#[cfg(test)]
38thread_local! {
39    static BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST: std::cell::Cell<Option<(usize, Duration)>> = const { std::cell::Cell::new(None) };
40}
41
42fn borrowed_search_load_limits() -> (usize, Duration) {
43    #[cfg(test)]
44    if let Some(limits) = BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST.with(std::cell::Cell::get) {
45        return limits;
46    }
47    (
48        crate::search_index::BORROWED_INDEX_LOAD_MAX_RECORDS,
49        crate::search_index::BORROWED_INDEX_LOAD_BUDGET,
50    )
51}
52
53#[cfg(test)]
54pub(crate) fn with_borrowed_search_load_limits_for_test<R>(
55    max_records: usize,
56    duration: Duration,
57    run: impl FnOnce() -> R,
58) -> R {
59    BORROWED_SEARCH_LOAD_LIMITS_FOR_TEST.with(|slot| {
60        let previous = slot.replace(Some((max_records, duration)));
61        let result = run();
62        slot.set(previous);
63        result
64    })
65}
66
67#[derive(Clone, Debug)]
68pub(crate) struct ReadOnlyStale<T> {
69    pub index: T,
70    pub drift_count: usize,
71    pub ignore_rules_differ: bool,
72}
73
74impl<T> ReadOnlyArtifact<T> {
75    pub(crate) fn map<U>(self, map: impl FnOnce(T) -> U) -> ReadOnlyArtifact<U> {
76        match self {
77            Self::Fresh(index) => ReadOnlyArtifact::Fresh(map(index)),
78            Self::Stale(stale) => ReadOnlyArtifact::Stale(ReadOnlyStale {
79                index: map(stale.index),
80                drift_count: stale.drift_count,
81                ignore_rules_differ: stale.ignore_rules_differ,
82            }),
83            Self::Degraded(degradation) => ReadOnlyArtifact::Degraded(degradation),
84            Self::Cancelled => ReadOnlyArtifact::Cancelled,
85            Self::Absent => ReadOnlyArtifact::Absent,
86        }
87    }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub(crate) struct BorrowedArtifactGeneration {
92    pub path: PathBuf,
93    pub generation: ArtifactGeneration,
94}
95
96#[derive(Debug)]
97pub(crate) enum GitRootResolutionError {
98    PathNotFound(PathBuf),
99    NotAGitRoot,
100    Other(String),
101}
102
103pub(crate) fn resolve_git_root_from_user_path(
104    project_root: &Path,
105    raw_path: &str,
106) -> Result<PathBuf, GitRootResolutionError> {
107    let expanded = expand_tilde(raw_path);
108    let requested = if expanded.is_absolute() {
109        expanded
110    } else {
111        project_root.join(expanded)
112    };
113    if !requested.exists() {
114        return Err(GitRootResolutionError::PathNotFound(requested));
115    }
116
117    let existing = nearest_existing_parent(&requested)
118        .ok_or_else(|| GitRootResolutionError::PathNotFound(requested.clone()))?;
119
120    // `nearest_existing_parent` returns an `fs::canonicalize` result, which on
121    // Windows is a verbatim (`\\?\C:\...`) path. Normalize before comparing the
122    // requested directory with the configured root so aliases such as `.` and
123    // symlinks remain local without requiring the workspace itself to be Git.
124    let existing = crate::inspect::job::canonicalize_normalized(&existing);
125    if existing == crate::inspect::job::canonicalize_normalized(project_root) {
126        return Ok(project_root.to_path_buf());
127    }
128
129    let git_base = if existing.is_file() {
130        existing.parent().unwrap_or(&existing).to_path_buf()
131    } else {
132        existing
133    };
134    git_toplevel(&git_base).map_err(|error| match error.as_str() {
135        "not_a_git_root" => GitRootResolutionError::NotAGitRoot,
136        _ => GitRootResolutionError::Other(error),
137    })
138}
139
140pub(crate) fn search_index_artifact_generation(
141    project_root: &Path,
142    storage_dir: Option<&Path>,
143) -> Option<BorrowedArtifactGeneration> {
144    let cache_dir = search_index_cache_dir(project_root, storage_dir)?;
145    search_artifact_generation_from_cache_dir(cache_dir)
146}
147
148pub(crate) fn search_index_artifact_generation_with_key(
149    project_key: &str,
150    storage_dir: Option<&Path>,
151) -> Option<BorrowedArtifactGeneration> {
152    search_artifact_generation_from_cache_dir(resolve_cache_dir_with_key(project_key, storage_dir))
153}
154
155fn search_artifact_generation_from_cache_dir(
156    cache_dir: PathBuf,
157) -> Option<BorrowedArtifactGeneration> {
158    let path = cache_dir.join("cache.bin");
159    let generation = artifact_generation(&path)?;
160    Some(BorrowedArtifactGeneration { path, generation })
161}
162
163pub(crate) fn open_search_index_read_only(
164    project_root: &Path,
165    storage_dir: Option<&Path>,
166) -> ReadOnlyArtifact<SearchIndex> {
167    let Some(cache_dir) = search_index_cache_dir(project_root, storage_dir) else {
168        return ReadOnlyArtifact::Absent;
169    };
170    open_search_index_from_cache_dir(project_root, cache_dir)
171}
172
173pub(crate) fn open_search_index_read_only_with_key(
174    project_root: &Path,
175    storage_dir: Option<&Path>,
176    project_key: &str,
177) -> ReadOnlyArtifact<SearchIndex> {
178    open_search_index_from_cache_dir(
179        project_root,
180        resolve_cache_dir_with_key(project_key, storage_dir),
181    )
182}
183
184fn open_search_index_from_cache_dir(
185    project_root: &Path,
186    cache_dir: PathBuf,
187) -> ReadOnlyArtifact<SearchIndex> {
188    let (max_records, duration) = borrowed_search_load_limits();
189    open_search_index_from_cache_dir_with_budget(project_root, cache_dir, max_records, duration)
190}
191
192fn open_search_index_from_cache_dir_with_budget(
193    project_root: &Path,
194    cache_dir: PathBuf,
195    max_records: usize,
196    duration: Duration,
197) -> ReadOnlyArtifact<SearchIndex> {
198    if !cache_dir.join("cache.bin").is_file() {
199        return ReadOnlyArtifact::Absent;
200    }
201
202    let (mut index, ignore_rules_differ) =
203        match SearchIndex::read_from_disk_borrow_tolerant_with_budget(
204            &cache_dir,
205            project_root,
206            max_records,
207            duration,
208        ) {
209            BorrowedIndexLoad::Loaded(index, ignore_rules_differ) => (index, ignore_rules_differ),
210            BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::BudgetExceeded) => {
211                return ReadOnlyArtifact::Degraded(BORROWED_SEARCH_LOAD_DEGRADATION);
212            }
213            BorrowedIndexLoad::Stopped(BorrowedIndexLoadStop::Cancelled) => {
214                return ReadOnlyArtifact::Cancelled;
215            }
216            BorrowedIndexLoad::Invalid => return ReadOnlyArtifact::Absent,
217        };
218
219    index.set_ready(true);
220    if ignore_rules_differ {
221        ReadOnlyArtifact::Stale(ReadOnlyStale {
222            index,
223            drift_count: 0,
224            ignore_rules_differ,
225        })
226    } else {
227        ReadOnlyArtifact::Fresh(index)
228    }
229}
230
231fn search_index_cache_dir(project_root: &Path, storage_dir: Option<&Path>) -> Option<PathBuf> {
232    match storage_dir {
233        Some(storage_dir) => {
234            match artifact_cache_key_with_memo(project_root, project_root, storage_dir, None) {
235                Ok(project_key) => {
236                    Some(resolve_cache_dir_with_key(&project_key, Some(storage_dir)))
237                }
238                Err(error) => {
239                    crate::slog_warn!("read-only search index unavailable: {}", error);
240                    None
241                }
242            }
243        }
244        None => Some(resolve_cache_dir(project_root, None)),
245    }
246}
247
248#[cfg(test)]
249pub(crate) fn semantic_index_artifact_generation(
250    project_root: &Path,
251    storage_dir: Option<&Path>,
252) -> Option<BorrowedArtifactGeneration> {
253    let (data_path, _) = semantic_index_location(project_root, storage_dir)?;
254    borrowed_artifact_generation(data_path)
255}
256
257pub(crate) fn semantic_index_artifact_generation_with_key(
258    project_key: &str,
259    storage_dir: Option<&Path>,
260) -> Option<BorrowedArtifactGeneration> {
261    let storage_dir = storage_dir?;
262    borrowed_artifact_generation(
263        storage_dir
264            .join("semantic")
265            .join(project_key)
266            .join("semantic.bin"),
267    )
268}
269
270fn borrowed_artifact_generation(path: PathBuf) -> Option<BorrowedArtifactGeneration> {
271    let generation = artifact_generation(&path)?;
272    Some(BorrowedArtifactGeneration { path, generation })
273}
274
275pub(crate) fn open_semantic_index_read_only(
276    project_root: &Path,
277    storage_dir: Option<&Path>,
278) -> ReadOnlyArtifact<SemanticIndex> {
279    let Some((data_path, project_key)) = semantic_index_location(project_root, storage_dir) else {
280        return ReadOnlyArtifact::Absent;
281    };
282    open_semantic_index_from_location(project_root, storage_dir, data_path, &project_key)
283}
284
285pub(crate) fn open_semantic_index_read_only_with_key(
286    project_root: &Path,
287    storage_dir: Option<&Path>,
288    project_key: &str,
289) -> ReadOnlyArtifact<SemanticIndex> {
290    let Some(storage_dir) = storage_dir else {
291        return ReadOnlyArtifact::Absent;
292    };
293    let data_path = storage_dir
294        .join("semantic")
295        .join(project_key)
296        .join("semantic.bin");
297    open_semantic_index_from_location(project_root, Some(storage_dir), data_path, project_key)
298}
299
300fn open_semantic_index_from_location(
301    project_root: &Path,
302    storage_dir: Option<&Path>,
303    data_path: PathBuf,
304    project_key: &str,
305) -> ReadOnlyArtifact<SemanticIndex> {
306    let Some(storage_dir) = storage_dir else {
307        return ReadOnlyArtifact::Absent;
308    };
309    let Ok(metadata) = data_path.metadata() else {
310        return ReadOnlyArtifact::Absent;
311    };
312    if metadata.len() > BORROWED_SEMANTIC_MAX_BYTES {
313        return ReadOnlyArtifact::Degraded(BORROWED_SEMANTIC_LOAD_DEGRADATION);
314    }
315    if search_cancelled() {
316        return ReadOnlyArtifact::Cancelled;
317    }
318
319    let opened =
320        SemanticIndex::read_from_disk_borrow_tolerant(storage_dir, project_key, project_root)
321            .map(ReadOnlyArtifact::Fresh)
322            .unwrap_or(ReadOnlyArtifact::Absent);
323    if search_cancelled() {
324        ReadOnlyArtifact::Cancelled
325    } else {
326        opened
327    }
328}
329
330fn search_cancelled() -> bool {
331    crate::executor::current_job_cancelled()
332}
333
334fn semantic_index_location(
335    project_root: &Path,
336    storage_dir: Option<&Path>,
337) -> Option<(PathBuf, String)> {
338    let storage_dir = storage_dir?;
339    let project_key =
340        match artifact_cache_key_with_memo(project_root, project_root, storage_dir, None) {
341            Ok(project_key) => project_key,
342            Err(error) => {
343                crate::slog_warn!("read-only semantic index unavailable: {}", error);
344                return None;
345            }
346        };
347    let data_path = storage_dir
348        .join("semantic")
349        .join(&project_key)
350        .join("semantic.bin");
351    Some((data_path, project_key))
352}
353
354fn expand_tilde(raw: &str) -> PathBuf {
355    if raw == "~" {
356        return home_dir().unwrap_or_else(|| PathBuf::from(raw));
357    }
358    if let Some(rest) = raw.strip_prefix("~/") {
359        if let Some(home) = home_dir() {
360            return home.join(rest);
361        }
362    }
363    PathBuf::from(raw)
364}
365
366fn home_dir() -> Option<PathBuf> {
367    crate::environment::non_empty_os_var("HOME")
368        .or_else(|| crate::environment::non_empty_os_var("USERPROFILE"))
369        .map(PathBuf::from)
370}
371
372fn nearest_existing_parent(path: &Path) -> Option<PathBuf> {
373    let mut current = path.to_path_buf();
374    loop {
375        if current.exists() {
376            return std::fs::canonicalize(&current).ok().or(Some(current));
377        }
378        if !current.pop() {
379            return None;
380        }
381    }
382}
383
384fn git_toplevel(base_dir: &Path) -> Result<PathBuf, String> {
385    let output = crate::effective_path::new_command("git")
386        .args(["rev-parse", "--show-toplevel"])
387        .current_dir(base_dir)
388        .output()
389        .map_err(|error| format!("failed to run git: {error}"))?;
390
391    if !output.status.success() {
392        let stderr = String::from_utf8_lossy(&output.stderr);
393        if stderr.contains("not a git repository") {
394            return Err("not_a_git_root".to_string());
395        }
396        return Err(format!("git rev-parse failed: {}", stderr.trim()));
397    }
398
399    let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
400    if toplevel.is_empty() {
401        return Err("git rev-parse returned an empty toplevel".to_string());
402    }
403    let toplevel = PathBuf::from(toplevel);
404    Ok(std::fs::canonicalize(&toplevel).unwrap_or(toplevel))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    use std::collections::BTreeMap;
412    use std::fs;
413    use std::process::Command;
414    use std::time::SystemTime;
415
416    use tempfile::TempDir;
417
418    use crate::context::BORROWED_INDEX_CACHE_CAPACITY;
419    use crate::search_index::artifact_cache_key;
420    use crate::semantic_index::{SemanticIndex, SemanticIndexFingerprint};
421
422    #[derive(Debug, PartialEq, Eq)]
423    struct FileSnapshot {
424        modified: SystemTime,
425        hash: blake3::Hash,
426    }
427
428    fn git_command(root: &Path) -> Command {
429        let mut command = Command::new("git");
430        crate::test_env::apply_hermetic_git_env(command.current_dir(root));
431        command
432    }
433
434    fn init_git(root: &Path) {
435        let status = git_command(root)
436            .args(["init"])
437            .status()
438            .expect("run git init");
439        assert!(status.success(), "git init failed");
440        let status = git_command(root)
441            .args(["config", "user.email", "test@example.com"])
442            .status()
443            .expect("configure git email");
444        assert!(status.success(), "git config email failed");
445        let status = git_command(root)
446            .args(["config", "user.name", "AFT Test"])
447            .status()
448            .expect("configure git name");
449        assert!(status.success(), "git config name failed");
450    }
451
452    fn commit_all(root: &Path) {
453        let status = git_command(root)
454            .args(["add", "."])
455            .status()
456            .expect("git add");
457        assert!(status.success(), "git add failed");
458        let status = git_command(root)
459            .args(["commit", "-m", "initial"])
460            .status()
461            .expect("git commit");
462        assert!(status.success(), "git commit failed");
463    }
464
465    fn fixture_project() -> (TempDir, PathBuf) {
466        let temp = tempfile::tempdir().expect("create project");
467        init_git(temp.path());
468        let source = temp.path().join("src/lib.rs");
469        fs::create_dir_all(source.parent().expect("source parent")).expect("create src");
470        fs::write(&source, "pub fn readonly_needle() -> bool { true }\n").expect("write source");
471        commit_all(temp.path());
472        let root = fs::canonicalize(temp.path()).expect("canonical project root");
473        (temp, root)
474    }
475
476    fn snapshot_dir(root: &Path) -> BTreeMap<PathBuf, FileSnapshot> {
477        fn visit(dir: &Path, out: &mut BTreeMap<PathBuf, FileSnapshot>, base: &Path) {
478            for entry in fs::read_dir(dir).expect("read dir") {
479                let entry = entry.expect("dir entry");
480                let path = entry.path();
481                let meta = entry.metadata().expect("entry metadata");
482                if meta.is_dir() {
483                    visit(&path, out, base);
484                } else if meta.is_file() {
485                    let bytes = fs::read(&path).expect("read snapshot file");
486                    out.insert(
487                        path.strip_prefix(base)
488                            .expect("relative snapshot path")
489                            .to_path_buf(),
490                        FileSnapshot {
491                            modified: meta.modified().expect("snapshot mtime"),
492                            hash: blake3::hash(&bytes),
493                        },
494                    );
495                }
496            }
497        }
498
499        let mut out = BTreeMap::new();
500        if root.exists() {
501            visit(root, &mut out, root);
502        }
503        out
504    }
505
506    fn build_search_artifact(root: &Path, storage: &Path) -> PathBuf {
507        let cache_dir = resolve_cache_dir(root, Some(storage));
508        let mut index = SearchIndex::build(root);
509        index.write_to_disk(
510            &cache_dir,
511            crate::search_index::current_git_head(root).as_deref(),
512        );
513        cache_dir
514    }
515
516    fn clone_checkout(root: &Path) -> (TempDir, PathBuf) {
517        let temp = tempfile::tempdir().expect("create clone dir");
518        let clone_root = temp.path().join("clone");
519        // fixture_project returns a canonicalized root, which on Windows is a
520        // verbatim `\\?\` path. git cannot take verbatim paths as the clone
521        // source, so strip the prefix for the git invocation only.
522        let clone_source = root
523            .to_string_lossy()
524            .trim_start_matches(r"\\?\")
525            .to_string();
526        let mut command = Command::new("git");
527        let status = crate::test_env::apply_hermetic_git_env(&mut command)
528            .arg("clone")
529            .arg("--quiet")
530            .arg(&clone_source)
531            .arg(&clone_root)
532            .status()
533            .expect("git clone");
534        assert!(status.success(), "git clone failed");
535        let clone_root = fs::canonicalize(clone_root).expect("canonical clone root");
536        (temp, clone_root)
537    }
538
539    fn build_semantic_artifact(root: &Path, storage: &Path) {
540        let source = root.join("src/lib.rs");
541        let fingerprint = SemanticIndexFingerprint {
542            backend: "openai_compatible".to_string(),
543            model: "readonly-test".to_string(),
544            base_url: "http://127.0.0.1".to_string(),
545            dimension: 3,
546            chunking_version: 1,
547            ..Default::default()
548        };
549        let mut embed =
550            |texts: Vec<String>| Ok::<_, String>(vec![vec![0.1, 0.2, 0.3]; texts.len()]);
551        let mut index =
552            SemanticIndex::build(root, &[source], &mut embed, 8).expect("build semantic index");
553        index.set_fingerprint(fingerprint);
554        index.write_to_disk(storage, &artifact_cache_key(root));
555    }
556
557    fn borrowed_context(root: &Path, storage: &Path) -> crate::context::AppContext {
558        crate::context::AppContext::new(
559            crate::context::default_language_provider_factory(),
560            crate::config::Config {
561                project_root: Some(root.to_path_buf()),
562                storage_dir: Some(storage.to_path_buf()),
563                ..crate::config::Config::default()
564            },
565        )
566    }
567
568    fn cached_search_index(
569        ctx: &crate::context::AppContext,
570        root: &Path,
571        storage: &Path,
572    ) -> std::sync::Arc<SearchIndex> {
573        match ctx.open_borrowed_search_index(root, Some(storage)) {
574            ReadOnlyArtifact::Fresh(index)
575            | ReadOnlyArtifact::Stale(ReadOnlyStale { index, .. }) => index,
576            other => panic!("expected borrowed search index, got {other:?}"),
577        }
578    }
579
580    #[cfg(debug_assertions)]
581    #[test]
582    fn repeated_borrowed_opens_cache_one_load_per_artifact_generation() {
583        let _git_env = crate::test_env::hermetic_git_env_guard();
584        let (_project, root) = fixture_project();
585        let storage = tempfile::tempdir().expect("storage");
586        build_search_artifact(&root, storage.path());
587        let ctx = borrowed_context(&root, storage.path());
588        search_index_artifact_generation(&root, Some(storage.path()))
589            .expect("seed borrowed artifact generation");
590        let artifact_before = snapshot_dir(storage.path());
591
592        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
593        let first = cached_search_index(&ctx, &root, storage.path());
594        let second = cached_search_index(&ctx, &root, storage.path());
595        assert!(std::sync::Arc::ptr_eq(&first, &second));
596        assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
597        assert_eq!(
598            ctx.artifact_cache_key_derivation_count_for_test(),
599            1,
600            "artifact identity should be derived only on the first external search"
601        );
602        assert_eq!(
603            crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
604            0,
605            "neither the first load nor a cache hit may run a corpus census"
606        );
607        assert_eq!(snapshot_dir(storage.path()), artifact_before);
608
609        let added = root.join("src/added.rs");
610        fs::write(&added, "pub fn generation_two() {}\n").expect("add generation file");
611        build_search_artifact(&root, storage.path());
612        let rebuilt_snapshot = snapshot_dir(storage.path());
613        let third = cached_search_index(&ctx, &root, storage.path());
614        assert!(
615            !std::sync::Arc::ptr_eq(&first, &third),
616            "an owner rebuild must invalidate the cached rerooted generation"
617        );
618        assert!(third.path_to_id.contains_key(&added));
619        assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
620        assert_eq!(ctx.artifact_cache_key_derivation_count_for_test(), 1);
621        assert_eq!(snapshot_dir(storage.path()), rebuilt_snapshot);
622
623        assert!(ctx.evict_idle_artifacts());
624        assert_eq!(ctx.borrowed_index_cache_len_for_test(), 0);
625    }
626
627    #[test]
628    fn repeated_borrowed_semantic_opens_reuse_rerooted_generation() {
629        let _git_env = crate::test_env::hermetic_git_env_guard();
630        let (_project, root) = fixture_project();
631        let storage = tempfile::tempdir().expect("storage");
632        build_semantic_artifact(&root, storage.path());
633        let ctx = borrowed_context(&root, storage.path());
634        semantic_index_artifact_generation(&root, Some(storage.path()))
635            .expect("seed semantic artifact generation");
636        let artifact_before = snapshot_dir(storage.path());
637
638        let first = match ctx.open_borrowed_semantic_index(&root, Some(storage.path())) {
639            ReadOnlyArtifact::Fresh(index) => index,
640            other => panic!("expected borrowed semantic index, got {other:?}"),
641        };
642        let second = match ctx.open_borrowed_semantic_index(&root, Some(storage.path())) {
643            ReadOnlyArtifact::Fresh(index) => index,
644            other => panic!("expected cached semantic index, got {other:?}"),
645        };
646        assert!(std::sync::Arc::ptr_eq(&first, &second));
647        assert_eq!(ctx.borrowed_index_cache_len_for_test(), 1);
648        assert_eq!(snapshot_dir(storage.path()), artifact_before);
649    }
650
651    #[test]
652    fn borrowed_index_cache_is_bounded_and_idle_evictable() {
653        let _git_env = crate::test_env::hermetic_git_env_guard();
654        let storage = tempfile::tempdir().expect("storage");
655        let mut projects = Vec::new();
656        for _ in 0..=BORROWED_INDEX_CACHE_CAPACITY {
657            let (project, root) = fixture_project();
658            build_search_artifact(&root, storage.path());
659            projects.push((project, root));
660        }
661        let ctx = borrowed_context(&projects[0].1, storage.path());
662        let first = cached_search_index(&ctx, &projects[0].1, storage.path());
663        for (_, root) in projects.iter().skip(1) {
664            cached_search_index(&ctx, root, storage.path());
665        }
666        assert_eq!(
667            ctx.borrowed_index_cache_len_for_test(),
668            BORROWED_INDEX_CACHE_CAPACITY
669        );
670        let reloaded_first = cached_search_index(&ctx, &projects[0].1, storage.path());
671        assert!(!std::sync::Arc::ptr_eq(&first, &reloaded_first));
672
673        assert!(ctx.evict_idle_artifacts());
674        assert_eq!(ctx.borrowed_index_cache_len_for_test(), 0);
675    }
676
677    #[test]
678    fn stale_posting_verification_stops_at_injected_file_budget() {
679        let _git_env = crate::test_env::hermetic_git_env_guard();
680        let (_project, root) = fixture_project();
681        for file_index in 0..20 {
682            fs::write(
683                root.join(format!("src/stale_{file_index}.rs")),
684                "pub fn stale_budget_needle() {}\n",
685            )
686            .expect("write indexed stale candidate");
687        }
688        let storage = tempfile::tempdir().expect("storage");
689        build_search_artifact(&root, storage.path());
690        for file_index in 0..20 {
691            fs::write(
692                root.join(format!("src/stale_{file_index}.rs")),
693                "pub fn changed_after_index_build() {}\n",
694            )
695            .expect("replace indexed stale candidate");
696        }
697        let index = match open_search_index_read_only(&root, Some(storage.path())) {
698            ReadOnlyArtifact::Fresh(index) => index,
699            other => panic!("expected borrowed index, got {other:?}"),
700        };
701        let compiled = match crate::pattern_compile::compile(
702            "stale_budget_needle",
703            crate::pattern_compile::CompileOpts {
704                literal: true,
705                ..crate::pattern_compile::CompileOpts::default()
706            },
707        ) {
708            crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
709            other => panic!("literal compile failed: {other:?}"),
710        };
711
712        let result = index.snapshot().search_grep_bounded(
713            &compiled,
714            &[],
715            &[],
716            &root,
717            10,
718            None,
719            1,
720            std::time::Duration::from_secs(1),
721        );
722        assert!(result.matches.is_empty());
723        assert!(result.files_searched <= 1);
724        assert!(result.truncated);
725        assert!(result.engine_capped);
726    }
727
728    #[test]
729    fn churned_borrowed_generation_stops_at_load_budget() {
730        let _git_env = crate::test_env::hermetic_git_env_guard();
731        let (_project, root) = fixture_project();
732        for file_index in 0..64 {
733            fs::write(
734                root.join(format!("src/module_{file_index}.rs")),
735                format!("pub fn borrowed_budget_{file_index}() {{}}\n"),
736            )
737            .expect("write borrowed corpus file");
738        }
739        let storage = tempfile::tempdir().expect("storage");
740        let cache_dir = build_search_artifact(&root, storage.path());
741        assert!(matches!(
742            open_search_index_from_cache_dir_with_budget(
743                &root,
744                cache_dir.clone(),
745                10_000,
746                Duration::from_secs(5),
747            ),
748            ReadOnlyArtifact::Fresh(_)
749        ));
750
751        for file_index in 0..40 {
752            fs::write(
753                root.join(format!("src/module_{file_index}.rs")),
754                format!("pub fn churned_borrowed_budget_{file_index}() {{}}\n"),
755            )
756            .expect("churn borrowed corpus file");
757        }
758        build_search_artifact(&root, storage.path());
759
760        let started = std::time::Instant::now();
761        let opened = open_search_index_from_cache_dir_with_budget(
762            &root,
763            cache_dir,
764            10,
765            Duration::from_secs(5),
766        );
767        assert_eq!(
768            started.elapsed() < Duration::from_secs(1),
769            true,
770            "record budget must stop before rerooting the churned corpus"
771        );
772        assert!(matches!(
773            opened,
774            ReadOnlyArtifact::Degraded(BORROWED_SEARCH_LOAD_DEGRADATION)
775        ));
776    }
777
778    #[test]
779    fn cancelled_borrowed_load_stops_at_checkpoint() {
780        let _git_env = crate::test_env::hermetic_git_env_guard();
781        let (_project, root) = fixture_project();
782        let storage = tempfile::tempdir().expect("storage");
783        let cache_dir = build_search_artifact(&root, storage.path());
784        let cancellation = crate::executor::JobCancellation::new();
785        cancellation.request_cancel();
786        let _installed = crate::executor::install_job_cancellation(cancellation);
787
788        assert!(matches!(
789            open_search_index_from_cache_dir_with_budget(
790                &root,
791                cache_dir,
792                10_000,
793                Duration::from_secs(5),
794            ),
795            ReadOnlyArtifact::Cancelled
796        ));
797    }
798
799    #[test]
800    fn oversized_borrowed_semantic_artifact_degrades_before_parse() {
801        let _git_env = crate::test_env::hermetic_git_env_guard();
802        let (_project, root) = fixture_project();
803        let storage = tempfile::tempdir().expect("storage");
804        let data_path = storage
805            .path()
806            .join("semantic")
807            .join(artifact_cache_key(&root))
808            .join("semantic.bin");
809        fs::create_dir_all(data_path.parent().expect("semantic parent"))
810            .expect("create semantic artifact directory");
811        let file = fs::File::create(&data_path).expect("create sparse semantic artifact");
812        file.set_len(BORROWED_SEMANTIC_MAX_BYTES + 1)
813            .expect("size sparse semantic artifact");
814
815        assert!(matches!(
816            open_semantic_index_read_only(&root, Some(storage.path())),
817            ReadOnlyArtifact::Degraded(BORROWED_SEMANTIC_LOAD_DEGRADATION)
818        ));
819    }
820
821    #[cfg(debug_assertions)]
822    #[test]
823    fn search_opener_skips_full_corpus_strict_census() {
824        let _git_env = crate::test_env::hermetic_git_env_guard();
825        let (_project, root) = fixture_project();
826        let storage = tempfile::tempdir().expect("storage");
827
828        assert!(matches!(
829            open_search_index_read_only(&root, Some(storage.path())),
830            ReadOnlyArtifact::Absent
831        ));
832
833        build_search_artifact(&root, storage.path());
834        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
835        assert!(matches!(
836            open_search_index_read_only(&root, Some(storage.path())),
837            ReadOnlyArtifact::Fresh(_)
838        ));
839        assert_eq!(
840            crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
841            0,
842            "a borrowed open must not restore the full-corpus strict hash census"
843        );
844
845        fs::write(
846            root.join("src/lib.rs"),
847            "pub fn readonly_needle() -> bool { false }\n",
848        )
849        .expect("mutate fixture");
850        match open_search_index_read_only(&root, Some(storage.path())) {
851            ReadOnlyArtifact::Fresh(index) => {
852                assert!(index.stored_git_head().is_some());
853            }
854            other => panic!("expected silently served borrowed artifact, got {other:?}"),
855        }
856        assert_eq!(
857            crate::cache_freshness::verify_file_strict_count_under_for_debug(&root),
858            0
859        );
860    }
861
862    #[test]
863    fn search_opener_marks_cross_checkout_ignore_rule_mismatch_as_stale() {
864        let _git_env = crate::test_env::hermetic_git_env_guard();
865        let (_project, root) = fixture_project();
866        let storage = tempfile::tempdir().expect("storage");
867        let owner_only_ignore = root.join(".foo/.gitignore");
868        fs::create_dir_all(owner_only_ignore.parent().expect("ignore parent"))
869            .expect("create ignore dir");
870        fs::write(&owner_only_ignore, "# owner-only ignore file\n").expect("write ignore file");
871        build_search_artifact(&root, storage.path());
872
873        let (_clone, sibling_root) = clone_checkout(&root);
874        match open_search_index_read_only(&sibling_root, Some(storage.path())) {
875            ReadOnlyArtifact::Stale(stale) => {
876                assert!(stale.ignore_rules_differ);
877                assert!(stale.index.stored_git_head().is_some());
878            }
879            other => panic!("expected stale borrowed artifact, got {other:?}"),
880        }
881    }
882
883    #[test]
884    fn owner_search_loader_stays_strict_on_ignore_rule_mismatch() {
885        let _git_env = crate::test_env::hermetic_git_env_guard();
886        let (_project, root) = fixture_project();
887        let storage = tempfile::tempdir().expect("storage");
888        let cache_dir = build_search_artifact(&root, storage.path());
889        let owner_only_ignore = root.join(".foo/.gitignore");
890        fs::create_dir_all(owner_only_ignore.parent().expect("ignore parent"))
891            .expect("create ignore dir");
892        fs::write(&owner_only_ignore, "# owner-only ignore file\n").expect("write ignore file");
893
894        assert!(SearchIndex::read_from_disk(&cache_dir, &root).is_none());
895    }
896
897    #[test]
898    fn read_only_openers_never_modify_artifact_directory() {
899        let _git_env = crate::test_env::hermetic_git_env_guard();
900        let (_project, root) = fixture_project();
901        let storage = tempfile::tempdir().expect("storage");
902        let search_cache_dir = build_search_artifact(&root, storage.path());
903        build_semantic_artifact(&root, storage.path());
904        artifact_cache_key_with_memo(&root, &root, storage.path(), None)
905            .expect("seed cache-key memo before read-only snapshot");
906        let semantic_cache_dir = storage
907            .path()
908            .join("semantic")
909            .join(artifact_cache_key(&root));
910
911        let before = snapshot_dir(storage.path());
912        assert!(matches!(
913            open_search_index_read_only(&root, Some(storage.path())),
914            ReadOnlyArtifact::Fresh(_)
915        ));
916        assert!(matches!(
917            open_semantic_index_read_only(&root, Some(storage.path())),
918            ReadOnlyArtifact::Fresh(_)
919        ));
920        assert_eq!(snapshot_dir(storage.path()), before);
921
922        fs::write(
923            root.join("src/lib.rs"),
924            "pub fn readonly_needle() -> bool { false }\n",
925        )
926        .expect("mutate fixture");
927        let stale_before = snapshot_dir(storage.path());
928        assert!(matches!(
929            open_search_index_read_only(&root, Some(storage.path())),
930            ReadOnlyArtifact::Fresh(_)
931        ));
932        assert!(matches!(
933            open_semantic_index_read_only(&root, Some(storage.path())),
934            ReadOnlyArtifact::Fresh(_)
935        ));
936        assert_eq!(snapshot_dir(storage.path()), stale_before);
937        assert!(search_cache_dir.join("cache.bin").is_file());
938        assert!(semantic_cache_dir.join("semantic.bin").is_file());
939    }
940}