1use std::path::{Path, PathBuf};
8use std::time::Instant;
9
10use crate::backend::EmbedBackend;
11use crate::cache::diff;
12use crate::cache::file_cache::FileCache;
13use crate::cache::manifest::Manifest;
14use crate::cache::store::ObjectStore;
15use crate::chunk::CodeChunk;
16use crate::embed::SearchConfig;
17use crate::hybrid::HybridIndex;
18use crate::profile::Profiler;
19
20#[derive(Debug)]
22pub struct ReindexStats {
23 pub chunks_total: usize,
25 pub chunks_reembedded: usize,
27 pub files_unchanged: usize,
29 pub files_changed: usize,
31 pub files_deleted: usize,
33 pub duration_ms: u64,
35}
36
37pub fn incremental_index(
48 root: &Path,
49 backends: &[&dyn EmbedBackend],
50 tokenizer: &tokenizers::Tokenizer,
51 cfg: &SearchConfig,
52 profiler: &Profiler,
53 model_repo: &str,
54 cache_dir_override: Option<&Path>,
55 repo_level: bool,
56) -> crate::Result<(HybridIndex, ReindexStats)> {
57 let start = Instant::now();
58 tracing::info!(root = %root.display(), model = model_repo, "incremental_index starting");
59
60 if backends.is_empty() {
61 return Err(crate::Error::Other(anyhow::anyhow!(
62 "no embedding backends provided"
63 )));
64 }
65
66 {
67 let guard = profiler.phase("cache_prepare");
68 if repo_level {
71 let ripvec_dir = root.join(".ripvec");
72 let config_path = ripvec_dir.join("config.toml");
73 if !config_path.exists() {
74 let config = crate::cache::config::RepoConfig::new(
75 model_repo,
76 crate::cache::manifest::MANIFEST_VERSION.to_string(),
77 );
78 config.save(&ripvec_dir)?;
79 }
80 let gitignore_path = ripvec_dir.join(".gitignore");
83 if !gitignore_path.exists() {
84 let _ = std::fs::write(&gitignore_path, "cache/manifest.json\n");
85 }
86 }
87 guard.set_detail(format!("repo_level={repo_level}"));
88 }
89
90 let cache_dir = resolve_cache_dir(root, model_repo, cache_dir_override);
91 let portable = is_repo_local(&cache_dir);
92 let manifest_path = cache_dir.join("manifest.json");
93 let objects_dir = cache_dir.join("objects");
94 let store = ObjectStore::new(&objects_dir);
95
96 tracing::info!(
97 cache_dir = %cache_dir.display(),
98 portable,
99 manifest = %manifest_path.display(),
100 "cache resolved"
101 );
102
103 let existing_manifest = {
105 let guard = profiler.phase("cache_manifest");
106 let manifest = Manifest::load(&manifest_path)
107 .ok()
108 .or_else(|| rebuild_manifest_from_objects(&cache_dir, root, model_repo));
109 guard.set_detail(match &manifest {
110 Some(m) => format!("{} files", m.files.len()),
111 None => "none".to_string(),
112 });
113 manifest
114 };
115
116 if let Some(manifest) = existing_manifest.filter(|m| m.is_compatible(model_repo)) {
117 tracing::info!(
118 files = manifest.files.len(),
119 "manifest loaded, running incremental diff"
120 );
121 incremental_path(
123 root, backends, tokenizer, cfg, profiler, model_repo, &cache_dir, &store, manifest,
124 start, portable,
125 )
126 } else {
127 full_index_path(
129 root, backends, tokenizer, cfg, profiler, model_repo, &cache_dir, &store, start,
130 portable,
131 )
132 }
133}
134
135#[expect(clippy::too_many_arguments, reason = "pipeline state passed through")]
137#[expect(
138 clippy::too_many_lines,
139 reason = "incremental cache pipeline orchestration with diagnostic phase boundaries"
140)]
141#[expect(
142 clippy::cast_possible_truncation,
143 reason = "duration in ms won't exceed u64"
144)]
145fn incremental_path(
146 root: &Path,
147 backends: &[&dyn EmbedBackend],
148 tokenizer: &tokenizers::Tokenizer,
149 cfg: &SearchConfig,
150 profiler: &Profiler,
151 _model_repo: &str,
152 cache_dir: &Path,
153 store: &ObjectStore,
154 mut manifest: Manifest,
155 start: Instant,
156 portable: bool,
157) -> crate::Result<(HybridIndex, ReindexStats)> {
158 let diff_result = {
159 let guard = profiler.phase("cache_diff");
160 let diff_result = diff::compute_diff(root, &manifest)?;
161 guard.set_detail(format!(
162 "{} changed, {} deleted, {} unchanged",
163 diff_result.dirty.len(),
164 diff_result.deleted.len(),
165 diff_result.unchanged,
166 ));
167 diff_result
168 };
169
170 let files_changed = diff_result.dirty.len();
171 let files_deleted = diff_result.deleted.len();
172 let files_unchanged = diff_result.unchanged;
173
174 tracing::info!(
175 changed = files_changed,
176 deleted = files_deleted,
177 unchanged = files_unchanged,
178 "diff complete"
179 );
180
181 for deleted in &diff_result.deleted {
183 manifest.remove_file(deleted);
184 }
185
186 let mut new_chunks_count = 0;
188 {
189 let guard = profiler.phase("reembed_dirty_files");
190 tracing::info!(files = files_changed, "re-embedding changed files");
191 for dirty_path in &diff_result.dirty {
192 let relative = dirty_path
193 .strip_prefix(root)
194 .unwrap_or(dirty_path)
195 .to_string_lossy()
196 .to_string();
197
198 manifest.remove_file(&relative);
200
201 let Some(source) = crate::embed::read_source(dirty_path) else {
203 continue;
204 };
205
206 let chunks =
207 crate::chunk::chunk_source_for_path(dirty_path, &source, cfg.text_mode, &cfg.chunk);
208 profiler.chunk_thread_report(chunks.len());
209 profiler.chunk_batch(&chunks);
210
211 if chunks.is_empty() {
212 tracing::debug!(file = %relative, "dirty file produced no chunks");
213 continue;
214 }
215 tracing::debug!(file = %relative, chunks = chunks.len(), "embedding dirty file");
216
217 let model_max = backends[0].max_tokens();
219 let encodings: Vec<Option<crate::backend::Encoding>> = chunks
220 .iter()
221 .map(|chunk| {
222 crate::tokenize::tokenize_query(&chunk.enriched_content, tokenizer, model_max)
223 .ok()
224 })
225 .collect();
226
227 let embeddings =
229 crate::embed::embed_distributed(&encodings, backends, cfg.batch_size, profiler)?;
230
231 let (good_chunks, good_embeddings): (Vec<_>, Vec<_>) = chunks
233 .into_iter()
234 .zip(embeddings)
235 .filter(|(_, emb)| !emb.is_empty())
236 .unzip();
237
238 let hidden_dim = good_embeddings.first().map_or(384, Vec::len);
239
240 let content_hash = diff::hash_file(dirty_path)?;
242 let file_cache = FileCache {
243 chunks: good_chunks.clone(),
244 embeddings: good_embeddings.iter().flatten().copied().collect(),
245 hidden_dim,
246 };
247 let bytes = if portable {
248 file_cache.to_portable_bytes()
249 } else {
250 file_cache.to_bytes()
251 };
252 store.write(&content_hash, &bytes)?;
253
254 let mtime = diff::mtime_secs(dirty_path);
256 let size = std::fs::metadata(dirty_path).map_or(0, |m| m.len());
257 manifest.add_file(&relative, mtime, size, &content_hash, good_chunks.len());
258 new_chunks_count += good_chunks.len();
259 }
260 guard.set_detail(format!("{files_changed} files, {new_chunks_count} chunks"));
261 }
262
263 heal_manifest_mtimes(root, &mut manifest);
267
268 manifest.recompute_hashes();
270
271 tracing::info!("loading cached objects from store");
274 let (all_chunks, all_embeddings) = {
275 let guard = profiler.phase("cache_load_objects");
276 let result = load_all_from_store(store, &mut manifest);
277 guard.set_detail(format!("{} chunks", result.0.len()));
278 result
279 };
280
281 {
283 let guard = profiler.phase("cache_gc");
284 let referenced = manifest.referenced_hashes();
285 store.gc(&referenced)?;
286 guard.set_detail(format!("{} referenced objects", referenced.len()));
287 }
288
289 {
291 let guard = profiler.phase("cache_manifest_save");
292 manifest.save(&cache_dir.join("manifest.json"))?;
293 guard.set_detail(format!("{} files", manifest.files.len()));
294 }
295 let chunks_total = all_chunks.len();
296 tracing::info!(
297 chunks = chunks_total,
298 "building HybridIndex (BM25 + PolarQuant)"
299 );
300 let hybrid = {
301 let guard = profiler.phase("build_hybrid_index");
302 let hybrid = HybridIndex::new(all_chunks, &all_embeddings, None)?;
303 guard.set_detail(format!("{chunks_total} chunks"));
304 hybrid
305 };
306 tracing::info!("HybridIndex ready");
307
308 Ok((
309 hybrid,
310 ReindexStats {
311 chunks_total,
312 chunks_reembedded: new_chunks_count,
313 files_unchanged,
314 files_changed,
315 files_deleted,
316 duration_ms: start.elapsed().as_millis() as u64,
317 },
318 ))
319}
320
321#[expect(clippy::too_many_arguments, reason = "pipeline state passed through")]
323#[expect(
324 clippy::cast_possible_truncation,
325 reason = "duration in ms won't exceed u64"
326)]
327fn full_index_path(
328 root: &Path,
329 backends: &[&dyn EmbedBackend],
330 tokenizer: &tokenizers::Tokenizer,
331 cfg: &SearchConfig,
332 profiler: &Profiler,
333 model_repo: &str,
334 cache_dir: &Path,
335 store: &ObjectStore,
336 start: Instant,
337 portable: bool,
338) -> crate::Result<(HybridIndex, ReindexStats)> {
339 tracing::info!("no compatible manifest; building full index from source");
340 let (chunks, embeddings) = crate::embed::embed_all(root, backends, tokenizer, cfg, profiler)?;
341
342 let hidden_dim = embeddings.first().map_or(384, Vec::len);
343
344 let mut manifest = Manifest::new(model_repo);
346 let mut file_groups: std::collections::BTreeMap<String, (Vec<CodeChunk>, Vec<Vec<f32>>)> =
347 std::collections::BTreeMap::new();
348
349 for (chunk, emb) in chunks.iter().zip(embeddings.iter()) {
350 file_groups
351 .entry(chunk.file_path.clone())
352 .or_default()
353 .0
354 .push(chunk.clone());
355 file_groups
356 .entry(chunk.file_path.clone())
357 .or_default()
358 .1
359 .push(emb.clone());
360 }
361
362 {
363 let guard = profiler.phase("cache_write_objects");
364 for (file_path, (file_chunks, file_embeddings)) in &file_groups {
365 let file_path_buf = PathBuf::from(file_path);
367
368 let content_hash = diff::hash_file(&file_path_buf).unwrap_or_else(|_| {
369 blake3::hash(file_chunks[0].content.as_bytes())
371 .to_hex()
372 .to_string()
373 });
374
375 let flat_emb: Vec<f32> = file_embeddings.iter().flatten().copied().collect();
376 let fc = FileCache {
377 chunks: file_chunks.clone(),
378 embeddings: flat_emb,
379 hidden_dim,
380 };
381 let bytes = if portable {
382 fc.to_portable_bytes()
383 } else {
384 fc.to_bytes()
385 };
386 store.write(&content_hash, &bytes)?;
387
388 let relative = file_path_buf
389 .strip_prefix(root)
390 .unwrap_or(&file_path_buf)
391 .to_string_lossy()
392 .to_string();
393 let mtime = diff::mtime_secs(&file_path_buf);
394 let size = std::fs::metadata(&file_path_buf).map_or(0, |m| m.len());
395 manifest.add_file(&relative, mtime, size, &content_hash, file_chunks.len());
396 }
397 guard.set_detail(format!("{} files", file_groups.len()));
398 }
399
400 {
401 let guard = profiler.phase("cache_manifest_save");
402 manifest.recompute_hashes();
403 manifest.save(&cache_dir.join("manifest.json"))?;
404 guard.set_detail(format!("{} files", manifest.files.len()));
405 }
406
407 let chunks_total = chunks.len();
408 let files_changed = file_groups.len();
409 let hybrid = {
410 let guard = profiler.phase("build_hybrid_index");
411 let hybrid = HybridIndex::new(chunks, &embeddings, None)?;
412 guard.set_detail(format!("{chunks_total} chunks"));
413 hybrid
414 };
415
416 Ok((
417 hybrid,
418 ReindexStats {
419 chunks_total,
420 chunks_reembedded: chunks_total,
421 files_unchanged: 0,
422 files_changed,
423 files_deleted: 0,
424 duration_ms: start.elapsed().as_millis() as u64,
425 },
426 ))
427}
428
429#[must_use]
431pub fn is_repo_local(cache_dir: &Path) -> bool {
432 cache_dir.components().any(|c| c.as_os_str() == ".ripvec")
433}
434
435pub fn heal_manifest_mtimes(root: &Path, manifest: &mut Manifest) {
441 for (relative, entry) in &mut manifest.files {
442 let file_path = root.join(relative);
443 let mtime = diff::mtime_secs(&file_path);
444 if mtime != entry.mtime_secs {
445 entry.mtime_secs = mtime;
446 }
447 }
448}
449
450#[must_use]
456pub fn check_auto_stash(root: &Path) -> Option<String> {
457 use std::process::Command;
458
459 let ripvec_dir = root.join(".ripvec");
460 let config = crate::cache::config::RepoConfig::load(&ripvec_dir).ok()?;
461 if !config.cache.local {
462 return None;
463 }
464
465 if config.cache.auto_stash.is_some() {
467 return None;
468 }
469
470 let git_check = Command::new("git")
472 .args(["config", "--local", "pull.autoStash"])
473 .current_dir(root)
474 .stdout(std::process::Stdio::piped())
475 .stderr(std::process::Stdio::null())
476 .output()
477 .ok()?;
478 if git_check.status.success() {
479 let val = String::from_utf8_lossy(&git_check.stdout)
481 .trim()
482 .eq_ignore_ascii_case("true");
483 let _ = apply_auto_stash(root, val);
484 return None;
485 }
486
487 Some(
488 "ripvec: Repo-local cache can dirty the worktree and block `git pull`.\n\
489 Enable `pull.autoStash` for this repo? (git stashes dirty files before pull, pops after)"
490 .to_string(),
491 )
492}
493
494pub fn apply_auto_stash(root: &Path, enable: bool) -> crate::Result<()> {
503 use std::process::Command;
504
505 let ripvec_dir = root.join(".ripvec");
506 let mut config = crate::cache::config::RepoConfig::load(&ripvec_dir)?;
507 config.cache.auto_stash = Some(enable);
508 config.save(&ripvec_dir)?;
509
510 if enable {
511 let _ = Command::new("git")
512 .args(["config", "--local", "pull.autoStash", "true"])
513 .current_dir(root)
514 .stdout(std::process::Stdio::null())
515 .stderr(std::process::Stdio::null())
516 .status();
517 }
518
519 Ok(())
520}
521
522fn load_file_cache(bytes: &[u8]) -> crate::Result<FileCache> {
525 if bytes.len() >= 2 && bytes[..2] == [0x42, 0x43] {
526 FileCache::from_portable_bytes(bytes)
527 } else {
528 FileCache::from_bytes(bytes)
529 }
530}
531
532fn load_all_from_store(
539 store: &ObjectStore,
540 manifest: &mut Manifest,
541) -> (Vec<CodeChunk>, Vec<Vec<f32>>) {
542 let mut all_chunks = Vec::new();
543 let mut all_embeddings = Vec::new();
544 let mut dangling: Vec<String> = Vec::new();
545
546 let total = manifest.files.len();
547 tracing::info!(objects = total, "reading cached objects");
548 for (idx, (path, entry)) in manifest.files.iter().enumerate() {
549 let current = idx + 1;
550 if current == 1 || current % 1000 == 0 || current == total {
551 tracing::debug!(current, total, path = %path, "reading cached object");
552 }
553 let bytes = match store.read(&entry.content_hash) {
554 Ok(b) => b,
555 Err(e) => {
556 tracing::warn!(
557 path = %path,
558 hash = %entry.content_hash,
559 error = %e,
560 "cache object missing or unreadable — will re-embed"
561 );
562 dangling.push(path.clone());
563 continue;
564 }
565 };
566 let fc = match load_file_cache(&bytes) {
567 Ok(fc) => fc,
568 Err(e) => {
569 tracing::warn!(
570 path = %path,
571 hash = %entry.content_hash,
572 error = %e,
573 "cache object corrupt — will re-embed"
574 );
575 dangling.push(path.clone());
576 continue;
577 }
578 };
579 let dim = fc.hidden_dim;
580
581 for (i, chunk) in fc.chunks.into_iter().enumerate() {
582 let start = i * dim;
583 let end = start + dim;
584 if end <= fc.embeddings.len() {
585 all_embeddings.push(fc.embeddings[start..end].to_vec());
586 all_chunks.push(chunk);
587 }
588 }
589 }
590
591 for path in &dangling {
594 manifest.files.remove(path);
595 }
596 if !dangling.is_empty() {
597 tracing::warn!(
598 count = dangling.len(),
599 "pruned dangling manifest entries; these files will be re-embedded on next run"
600 );
601 }
602
603 (all_chunks, all_embeddings)
604}
605
606#[must_use]
615pub fn load_cached_index(root: &Path, model_repo: &str) -> Option<HybridIndex> {
616 let cache_dir = resolve_cache_dir(root, model_repo, None);
617 let manifest_path = cache_dir.join("manifest.json");
618 let objects_dir = cache_dir.join("objects");
619 let lock_path = cache_dir.join("manifest.lock");
620
621 if !manifest_path.exists() {
623 return None;
624 }
625
626 let lock_file = std::fs::OpenOptions::new()
628 .create(true)
629 .truncate(false)
630 .write(true)
631 .read(true)
632 .open(&lock_path)
633 .ok()?;
634 let lock = fd_lock::RwLock::new(lock_file);
635 let _guard = lock.read().ok()?;
636
637 let mut manifest = Manifest::load(&manifest_path)
638 .ok()
639 .or_else(|| rebuild_manifest_from_objects(&cache_dir, root, model_repo))?;
640 if !manifest.is_compatible(model_repo) {
641 return None;
642 }
643
644 let store = ObjectStore::new(&objects_dir);
645 let (chunks, embeddings) = load_all_from_store(&store, &mut manifest);
646 HybridIndex::new(chunks, &embeddings, None).ok()
647}
648
649#[must_use]
662pub fn resolve_cache_dir(root: &Path, model_repo: &str, override_dir: Option<&Path>) -> PathBuf {
663 if let Some(dir) = override_dir {
665 let project_hash = hash_project_root(root);
666 let version_dir = format_version_dir(model_repo);
667 return dir.join(&project_hash).join(version_dir);
668 }
669
670 if let Some(ripvec_dir) = crate::cache::config::find_repo_config(root)
672 && let Ok(config) = crate::cache::config::RepoConfig::load(&ripvec_dir)
673 {
674 if config.cache.model == model_repo {
675 return ripvec_dir.join("cache");
676 }
677 eprintln!(
678 "[ripvec] repo-local index model mismatch: config has '{}', runtime wants '{}' — falling back to user cache",
679 config.cache.model, model_repo
680 );
681 }
682
683 let project_hash = hash_project_root(root);
685 let version_dir = format_version_dir(model_repo);
686
687 let base = if let Ok(env_dir) = std::env::var("RIPVEC_CACHE") {
688 PathBuf::from(env_dir).join(&project_hash)
689 } else {
690 dirs::cache_dir()
691 .unwrap_or_else(|| PathBuf::from("/tmp"))
692 .join("ripvec")
693 .join(&project_hash)
694 };
695
696 base.join(version_dir)
697}
698
699fn hash_project_root(root: &Path) -> String {
701 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
702 blake3::hash(canonical.to_string_lossy().as_bytes())
703 .to_hex()
704 .to_string()
705}
706
707fn format_version_dir(model_repo: &str) -> String {
709 let model_slug = model_repo
710 .rsplit('/')
711 .next()
712 .unwrap_or(model_repo)
713 .to_lowercase();
714 format!("v{}-{model_slug}", crate::cache::manifest::MANIFEST_VERSION)
715}
716
717#[must_use]
725pub fn rebuild_manifest_from_objects(
726 cache_dir: &std::path::Path,
727 root: &std::path::Path,
728 model_repo: &str,
729) -> Option<super::manifest::Manifest> {
730 use super::file_cache::FileCache;
731 use super::manifest::{FileEntry, MANIFEST_VERSION, Manifest};
732 use super::store::ObjectStore;
733 use std::collections::BTreeMap;
734
735 let store = ObjectStore::new(&cache_dir.join("objects"));
736 let hashes = store.list_hashes();
737 if hashes.is_empty() {
738 return None;
739 }
740
741 tracing::info!(
742 objects = hashes.len(),
743 "rebuilding manifest from object store"
744 );
745
746 let mut files = BTreeMap::new();
747
748 for hash in &hashes {
749 let Ok(bytes) = store.read(hash) else {
750 continue;
751 };
752 let Ok(fc) =
753 FileCache::from_portable_bytes(&bytes).or_else(|_| FileCache::from_bytes(&bytes))
754 else {
755 continue;
756 };
757 let Some(first_chunk) = fc.chunks.first() else {
758 continue;
759 };
760
761 let chunk_path = std::path::Path::new(&first_chunk.file_path);
764 let rel_path = chunk_path
765 .strip_prefix(root)
766 .unwrap_or(chunk_path)
767 .to_string_lossy()
768 .to_string();
769
770 let abs_path = root.join(&rel_path);
772 let (mtime_secs, size) = if let Ok(meta) = std::fs::metadata(&abs_path) {
773 let mtime = meta
774 .modified()
775 .ok()
776 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
777 .map_or(0, |d| d.as_secs());
778 (mtime, meta.len())
779 } else {
780 (0, 0) };
782
783 files.insert(
784 rel_path,
785 FileEntry {
786 mtime_secs,
787 size,
788 content_hash: hash.clone(),
789 chunk_count: fc.chunks.len(),
790 },
791 );
792 }
793
794 if files.is_empty() {
795 return None;
796 }
797
798 let manifest = Manifest {
799 version: MANIFEST_VERSION,
800 model_repo: model_repo.to_string(),
801 root_hash: String::new(), directories: BTreeMap::new(), files,
804 };
805
806 tracing::info!(
807 files = manifest.files.len(),
808 "manifest rebuilt from objects"
809 );
810
811 let manifest_path = cache_dir.join("manifest.json");
813 if let Ok(json) = serde_json::to_string_pretty(&manifest) {
814 let _ = std::fs::write(&manifest_path, json);
815 }
816
817 Some(manifest)
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use tempfile::TempDir;
824
825 #[test]
826 fn heal_stale_mtimes() {
827 use crate::cache::diff;
828 use crate::cache::manifest::Manifest;
829 use std::io::Write;
830
831 let dir = TempDir::new().unwrap();
832 let file_path = dir.path().join("test.rs");
833 let content = "fn main() {}";
834 {
835 let mut f = std::fs::File::create(&file_path).unwrap();
836 f.write_all(content.as_bytes()).unwrap();
837 }
838
839 let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
841 let mut manifest = Manifest::new("test-model");
842 manifest.add_file(
843 "test.rs",
844 9_999_999, content.len() as u64,
846 &content_hash,
847 1,
848 );
849
850 heal_manifest_mtimes(dir.path(), &mut manifest);
852 let actual_mtime = diff::mtime_secs(&file_path);
853 assert_eq!(manifest.files["test.rs"].mtime_secs, actual_mtime);
854 }
855
856 #[test]
857 fn resolve_uses_repo_local_when_present() {
858 let dir = TempDir::new().unwrap();
859 let cfg = crate::cache::config::RepoConfig::new("nomic-ai/modernbert-embed-base", "3");
860 cfg.save(&dir.path().join(".ripvec")).unwrap();
861
862 let result = resolve_cache_dir(dir.path(), "nomic-ai/modernbert-embed-base", None);
863 assert!(
864 result.starts_with(dir.path().join(".ripvec").join("cache")),
865 "expected repo-local cache dir, got: {result:?}"
866 );
867 }
868
869 #[test]
870 fn resolve_falls_back_to_user_cache_when_no_config() {
871 let dir = TempDir::new().unwrap();
872 let result = resolve_cache_dir(dir.path(), "nomic-ai/modernbert-embed-base", None);
873 assert!(
874 !result.to_string_lossy().contains(".ripvec"),
875 "should not use repo-local without config, got: {result:?}"
876 );
877 }
878
879 #[test]
880 fn resolve_override_takes_priority_over_repo_local() {
881 let dir = TempDir::new().unwrap();
882 let override_dir = TempDir::new().unwrap();
883
884 let cfg = crate::cache::config::RepoConfig::new("nomic-ai/modernbert-embed-base", "3");
885 cfg.save(&dir.path().join(".ripvec")).unwrap();
886
887 let result = resolve_cache_dir(
888 dir.path(),
889 "nomic-ai/modernbert-embed-base",
890 Some(override_dir.path()),
891 );
892 assert!(
893 !result.starts_with(dir.path().join(".ripvec")),
894 "override should win over repo-local, got: {result:?}"
895 );
896 }
897}