Skip to main content

frankensearch_embed/
model_cache.rs

1//! XDG-compliant model cache directory layout.
2//!
3//! Resolution order for the model cache root:
4//!
5//! 1. `FRANKENSEARCH_MODEL_DIR` — explicit override (CI, Docker, shared cache)
6//! 2. `FRANKENSEARCH_DATA_DIR` — override data home for all frankensearch data
7//! 3. `XDG_DATA_HOME/frankensearch/models/` — XDG spec (Linux)
8//! 4. `~/Library/Application Support/frankensearch/models/` — macOS (when XDG unset)
9//! 5. `~/.local/share/frankensearch/models/` — POSIX fallback
10//!
11//! Within the cache root, each model gets a base directory. Versioned
12//! subdirectories are also provisioned for forward-compatible migrations:
13//!
14//! ```text
15//! <root>/
16//!   potion-base-128M/
17//!   all-MiniLM-L6-v2/
18//!     onnx/model.onnx
19//!     tokenizer.json
20//!     config.json
21//!     manifest.json
22//!   potion-base-128M/
23//!     v1/
24//!       model.safetensors
25//!       tokenizer.json
26//!       config.json
27//!       manifest.json
28//! ```
29
30use std::path::{Path, PathBuf};
31
32use frankensearch_core::error::SearchResult;
33
34// ─── Constants ──────────────────────────────────────────────────────────────
35
36/// Environment variable: explicit model cache directory override.
37pub const ENV_MODEL_DIR: &str = "FRANKENSEARCH_MODEL_DIR";
38
39/// Environment variable: override data home for all frankensearch data.
40pub const ENV_DATA_DIR: &str = "FRANKENSEARCH_DATA_DIR";
41
42/// Environment variable: XDG data home (standard Linux convention).
43const ENV_XDG_DATA_HOME: &str = "XDG_DATA_HOME";
44
45/// Subdirectory under the data home for frankensearch.
46const FRANKENSEARCH_SUBDIR: &str = "frankensearch";
47
48/// Subdirectory within frankensearch data for model files.
49const MODELS_SUBDIR: &str = "models";
50
51/// Schema version for the cache layout format.
52pub const MODEL_CACHE_LAYOUT_VERSION: u32 = 1;
53
54/// Known model directory names and their current version tags.
55const KNOWN_MODELS: &[KnownModel] = &[
56    KnownModel {
57        dir_name: "potion-base-128M",
58        version: "v1",
59        description: "Potion 128M fast embedder (256d)",
60    },
61    KnownModel {
62        dir_name: "potion-multilingual-128M",
63        version: "v1",
64        description: "Potion multilingual 128M embedder (256d)",
65    },
66    KnownModel {
67        dir_name: "all-MiniLM-L6-v2",
68        version: "v1",
69        description: "MiniLM-L6-v2 quality embedder (384d)",
70    },
71    KnownModel {
72        dir_name: "ms-marco-MiniLM-L-6-v2",
73        version: "v1",
74        description: "MS MARCO MiniLM reranker",
75    },
76];
77
78// ─── Known Model Metadata ──────────────────────────────────────────────────
79
80/// Metadata for a known model in the cache layout.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct KnownModel {
83    /// Directory name under the cache root.
84    pub dir_name: &'static str,
85    /// Current version tag.
86    pub version: &'static str,
87    /// Human-readable description.
88    pub description: &'static str,
89}
90
91/// Return all known models in the cache layout.
92#[must_use]
93pub const fn known_models() -> &'static [KnownModel] {
94    KNOWN_MODELS
95}
96
97// ─── Cache Root Resolution ──────────────────────────────────────────────────
98
99/// Resolve the model cache root directory using the priority chain.
100///
101/// Does NOT create directories — use [`ensure_cache_layout`] for that.
102#[must_use]
103pub fn resolve_cache_root() -> PathBuf {
104    resolve_cache_root_with(&EnvReader::Real)
105}
106
107/// Resolve the cache root with a custom environment reader (for testing).
108fn resolve_cache_root_with(env: &dyn EnvLookup) -> PathBuf {
109    // 1. FRANKENSEARCH_MODEL_DIR
110    if let Some(path) = env_var_if_non_empty(env, ENV_MODEL_DIR) {
111        return PathBuf::from(path);
112    }
113
114    // 2. FRANKENSEARCH_DATA_DIR
115    if let Some(path) = env_var_if_non_empty(env, ENV_DATA_DIR) {
116        return PathBuf::from(path).join(MODELS_SUBDIR);
117    }
118
119    // 3. XDG_DATA_HOME
120    if let Some(path) = env_var_if_non_empty(env, ENV_XDG_DATA_HOME) {
121        return PathBuf::from(path)
122            .join(FRANKENSEARCH_SUBDIR)
123            .join(MODELS_SUBDIR);
124    }
125
126    // 4. macOS Application Support (when XDG unset)
127    #[cfg(target_os = "macos")]
128    {
129        if let Some(path) = dirs::data_local_dir() {
130            return path.join(FRANKENSEARCH_SUBDIR).join(MODELS_SUBDIR);
131        }
132    }
133
134    // 5. ~/.local/share/frankensearch/models/
135    if let Some(home) = dirs::home_dir() {
136        return home
137            .join(".local")
138            .join("share")
139            .join(FRANKENSEARCH_SUBDIR)
140            .join(MODELS_SUBDIR);
141    }
142
143    // Ultimate fallback: data_local_dir or ./models
144    dirs::data_local_dir().map_or_else(
145        || PathBuf::from(MODELS_SUBDIR),
146        |p| p.join(FRANKENSEARCH_SUBDIR).join(MODELS_SUBDIR),
147    )
148}
149
150// ─── Cache Layout ───────────────────────────────────────────────────────────
151
152/// Description of the full cache directory tree.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ModelCacheLayout {
155    /// Root directory of the model cache.
156    pub root: PathBuf,
157    /// Per-model versioned directories.
158    pub model_dirs: Vec<ModelDirEntry>,
159}
160
161/// One model's directory entry within the cache layout.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ModelDirEntry {
164    /// Model directory name.
165    pub name: String,
166    /// Version tag.
167    pub version: String,
168    /// Full path to the model base directory.
169    pub path: PathBuf,
170}
171
172impl ModelCacheLayout {
173    /// Build the layout description for a given root.
174    #[must_use]
175    pub fn for_root(root: PathBuf) -> Self {
176        let model_dirs = KNOWN_MODELS
177            .iter()
178            .map(|m| ModelDirEntry {
179                name: m.dir_name.to_string(),
180                version: m.version.to_string(),
181                path: root.join(m.dir_name),
182            })
183            .collect();
184        Self { root, model_dirs }
185    }
186
187    /// Build the layout using the default resolved root.
188    #[must_use]
189    pub fn default_layout() -> Self {
190        Self::for_root(resolve_cache_root())
191    }
192
193    /// Get the base path for a model by directory name.
194    #[must_use]
195    pub fn model_path(&self, dir_name: &str) -> Option<&Path> {
196        self.model_dirs
197            .iter()
198            .find(|e| e.name == dir_name)
199            .map(|e| e.path.as_path())
200    }
201
202    /// Get the versioned path for a model by directory name.
203    #[must_use]
204    pub fn versioned_model_path(&self, dir_name: &str) -> Option<PathBuf> {
205        self.model_dirs
206            .iter()
207            .find(|e| e.name == dir_name)
208            .map(|e| e.path.join(&e.version))
209    }
210
211    /// Get the model's parent directory (without the version suffix).
212    #[must_use]
213    pub fn model_base_path(&self, dir_name: &str) -> Option<PathBuf> {
214        self.model_dirs
215            .iter()
216            .find(|e| e.name == dir_name)
217            .map(|e| self.root.join(&e.name))
218    }
219}
220
221// ─── Ensure Layout Exists ──────────────────────────────────────────────────
222
223/// Ensure the cache layout directories exist on disk.
224///
225/// Creates the root and all known model version directories. Safe to call
226/// multiple times (idempotent).
227///
228/// # Errors
229///
230/// Returns `SearchError` if directory creation fails.
231pub fn ensure_cache_layout(layout: &ModelCacheLayout) -> SearchResult<()> {
232    std::fs::create_dir_all(&layout.root)?;
233
234    for entry in &layout.model_dirs {
235        std::fs::create_dir_all(&entry.path)?;
236        std::fs::create_dir_all(entry.path.join(&entry.version))?;
237    }
238
239    Ok(())
240}
241
242/// Resolve the cache root and ensure all directories exist.
243///
244/// This is the primary entry point for consumers who just want a working
245/// model cache.
246///
247/// # Errors
248///
249/// Returns `SearchError` if directory creation fails.
250pub fn ensure_default_cache() -> SearchResult<ModelCacheLayout> {
251    let layout = ModelCacheLayout::default_layout();
252    ensure_cache_layout(&layout)?;
253    Ok(layout)
254}
255
256// ─── Model Path Resolution ─────────────────────────────────────────────────
257
258/// Resolve the expected path for a specific model file within the cache.
259///
260/// Returns `None` if the model directory name is not in the known layout.
261#[must_use]
262pub fn model_file_path(
263    layout: &ModelCacheLayout,
264    model_dir: &str,
265    file_name: &str,
266) -> Option<PathBuf> {
267    layout.model_path(model_dir).map(|p| p.join(file_name))
268}
269
270/// Check whether a specific model appears installed (all expected files present).
271#[must_use]
272pub fn is_model_installed(model_versioned_dir: &Path, required_files: &[&str]) -> bool {
273    if !model_versioned_dir.is_dir() {
274        return false;
275    }
276    let all_present_in = |dir: &Path| required_files.iter().all(|f| dir.join(f).is_file());
277    if all_present_in(model_versioned_dir) {
278        return true;
279    }
280    let version_fallbacks = ["v1", "v2"];
281    version_fallbacks
282        .iter()
283        .map(|version| model_versioned_dir.join(version))
284        .any(|candidate| candidate.is_dir() && all_present_in(&candidate))
285}
286
287// ─── Environment Abstraction (for testing) ─────────────────────────────────
288
289trait EnvLookup {
290    fn var(&self, key: &str) -> Option<String>;
291}
292
293fn env_var_if_non_empty(env: &dyn EnvLookup, key: &str) -> Option<String> {
294    env.var(key).filter(|value| !value.trim().is_empty())
295}
296
297enum EnvReader {
298    Real,
299    #[cfg(test)]
300    Mock(std::collections::HashMap<String, String>),
301}
302
303impl EnvLookup for EnvReader {
304    fn var(&self, key: &str) -> Option<String> {
305        match self {
306            Self::Real => std::env::var(key).ok(),
307            #[cfg(test)]
308            Self::Mock(map) => map.get(key).cloned(),
309        }
310    }
311}
312
313// ─── Tests ──────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::collections::HashMap;
319
320    fn mock_env(pairs: &[(&str, &str)]) -> EnvReader {
321        let map: HashMap<String, String> = pairs
322            .iter()
323            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
324            .collect();
325        EnvReader::Mock(map)
326    }
327
328    #[test]
329    fn resolve_frankensearch_model_dir_takes_priority() {
330        let env = mock_env(&[
331            (ENV_MODEL_DIR, "/custom/models"),
332            (ENV_DATA_DIR, "/custom/data"),
333            (ENV_XDG_DATA_HOME, "/xdg"),
334        ]);
335        let root = resolve_cache_root_with(&env);
336        assert_eq!(root, PathBuf::from("/custom/models"));
337    }
338
339    #[test]
340    fn resolve_frankensearch_data_dir_adds_models_subdir() {
341        let env = mock_env(&[(ENV_DATA_DIR, "/custom/data")]);
342        let root = resolve_cache_root_with(&env);
343        assert_eq!(root, PathBuf::from("/custom/data/models"));
344    }
345
346    #[test]
347    fn resolve_empty_model_dir_falls_back_to_data_dir() {
348        let env = mock_env(&[(ENV_MODEL_DIR, ""), (ENV_DATA_DIR, "/custom/data")]);
349        let root = resolve_cache_root_with(&env);
350        assert_eq!(root, PathBuf::from("/custom/data/models"));
351    }
352
353    #[test]
354    fn resolve_empty_data_dir_falls_back_to_xdg() {
355        let env = mock_env(&[(ENV_DATA_DIR, "   "), (ENV_XDG_DATA_HOME, "/xdg/data")]);
356        let root = resolve_cache_root_with(&env);
357        assert_eq!(root, PathBuf::from("/xdg/data/frankensearch/models"));
358    }
359
360    #[test]
361    fn resolve_xdg_data_home_adds_frankensearch_models() {
362        let env = mock_env(&[(ENV_XDG_DATA_HOME, "/xdg/data")]);
363        let root = resolve_cache_root_with(&env);
364        assert_eq!(root, PathBuf::from("/xdg/data/frankensearch/models"));
365    }
366
367    #[test]
368    fn resolve_empty_env_falls_back_to_home() {
369        let env = mock_env(&[]);
370        let root = resolve_cache_root_with(&env);
371        // Should contain "frankensearch" and "models" somewhere in the path.
372        let path_str = root.to_string_lossy();
373        assert!(
374            path_str.contains("frankensearch") && path_str.contains("models"),
375            "expected frankensearch/models in path, got: {path_str}"
376        );
377    }
378
379    #[test]
380    fn layout_for_root_creates_correct_entries() {
381        let layout = ModelCacheLayout::for_root(PathBuf::from("/test/models"));
382        assert_eq!(layout.root, PathBuf::from("/test/models"));
383        assert_eq!(layout.model_dirs.len(), KNOWN_MODELS.len());
384
385        let potion = layout
386            .model_dirs
387            .iter()
388            .find(|e| e.name == "potion-base-128M")
389            .expect("potion entry");
390        assert_eq!(potion.version, "v1");
391        assert_eq!(potion.path, PathBuf::from("/test/models/potion-base-128M"));
392    }
393
394    #[test]
395    fn layout_model_path_returns_base_path() {
396        let layout = ModelCacheLayout::for_root(PathBuf::from("/m"));
397        let path = layout.model_path("all-MiniLM-L6-v2").unwrap();
398        assert_eq!(path, Path::new("/m/all-MiniLM-L6-v2"));
399    }
400
401    #[test]
402    fn layout_versioned_model_path_returns_versioned_path() {
403        let layout = ModelCacheLayout::for_root(PathBuf::from("/m"));
404        let path = layout.versioned_model_path("all-MiniLM-L6-v2").unwrap();
405        assert_eq!(path, Path::new("/m/all-MiniLM-L6-v2/v1"));
406    }
407
408    #[test]
409    fn layout_model_path_unknown_returns_none() {
410        let layout = ModelCacheLayout::for_root(PathBuf::from("/m"));
411        assert!(layout.model_path("nonexistent-model").is_none());
412    }
413
414    #[test]
415    fn layout_model_base_path_strips_version() {
416        let layout = ModelCacheLayout::for_root(PathBuf::from("/m"));
417        let base = layout.model_base_path("all-MiniLM-L6-v2").unwrap();
418        assert_eq!(base, PathBuf::from("/m/all-MiniLM-L6-v2"));
419    }
420
421    #[test]
422    fn model_file_path_resolves_correctly() {
423        let layout = ModelCacheLayout::for_root(PathBuf::from("/cache"));
424        let path = model_file_path(&layout, "all-MiniLM-L6-v2", "onnx/model.onnx");
425        assert_eq!(
426            path,
427            Some(PathBuf::from("/cache/all-MiniLM-L6-v2/onnx/model.onnx"))
428        );
429    }
430
431    #[test]
432    fn model_file_path_unknown_model_returns_none() {
433        let layout = ModelCacheLayout::for_root(PathBuf::from("/cache"));
434        assert!(model_file_path(&layout, "unknown", "file.bin").is_none());
435    }
436
437    #[test]
438    fn ensure_cache_layout_creates_directories() {
439        let temp = tempfile::tempdir().unwrap();
440        let layout = ModelCacheLayout::for_root(temp.path().join("models"));
441        ensure_cache_layout(&layout).unwrap();
442
443        assert!(layout.root.is_dir());
444        for entry in &layout.model_dirs {
445            assert!(
446                entry.path.is_dir(),
447                "expected dir: {}",
448                entry.path.display()
449            );
450            assert!(
451                entry.path.join(&entry.version).is_dir(),
452                "expected version dir: {}",
453                entry.path.join(&entry.version).display()
454            );
455        }
456    }
457
458    #[test]
459    fn ensure_cache_layout_idempotent() {
460        let temp = tempfile::tempdir().unwrap();
461        let layout = ModelCacheLayout::for_root(temp.path().join("models"));
462        ensure_cache_layout(&layout).unwrap();
463        ensure_cache_layout(&layout).unwrap();
464        assert!(layout.root.is_dir());
465    }
466
467    #[test]
468    fn ensure_default_cache_returns_working_layout() {
469        // Instead of mutating env (unsafe in edition 2024), test the
470        // ensure_cache_layout + for_root path which is what ensure_default_cache
471        // delegates to.
472        let temp = tempfile::tempdir().unwrap();
473        let root = temp.path().join("isolated-models");
474        let layout = ModelCacheLayout::for_root(root.clone());
475        ensure_cache_layout(&layout).unwrap();
476
477        assert_eq!(layout.root, root);
478        assert!(root.is_dir());
479        // Verify all model dirs were created.
480        for entry in &layout.model_dirs {
481            assert!(entry.path.is_dir());
482            assert!(entry.path.join(&entry.version).is_dir());
483        }
484    }
485
486    #[test]
487    fn is_model_installed_accepts_base_dir_with_version_subdir() {
488        let temp = tempfile::tempdir().unwrap();
489        let model_base = temp.path().join("model");
490        let versioned = model_base.join("v1");
491        std::fs::create_dir_all(&versioned).unwrap();
492        std::fs::write(versioned.join("tokenizer.json"), b"stub").unwrap();
493        std::fs::write(versioned.join("model.onnx"), b"stub").unwrap();
494        assert!(is_model_installed(
495            &model_base,
496            &["tokenizer.json", "model.onnx"]
497        ));
498    }
499
500    #[test]
501    fn is_model_installed_false_when_dir_missing() {
502        let temp = tempfile::tempdir().unwrap();
503        let missing = temp.path().join("nonexistent");
504        assert!(!is_model_installed(&missing, &["model.onnx"]));
505    }
506
507    #[test]
508    fn is_model_installed_false_when_files_missing() {
509        let temp = tempfile::tempdir().unwrap();
510        let model_dir = temp.path().join("model/v1");
511        std::fs::create_dir_all(&model_dir).unwrap();
512        std::fs::write(model_dir.join("tokenizer.json"), b"stub").unwrap();
513        assert!(!is_model_installed(
514            &model_dir,
515            &["tokenizer.json", "model.onnx"]
516        ));
517    }
518
519    #[test]
520    fn is_model_installed_true_when_all_present() {
521        let temp = tempfile::tempdir().unwrap();
522        let model_dir = temp.path().join("model/v1");
523        std::fs::create_dir_all(&model_dir).unwrap();
524        std::fs::write(model_dir.join("tokenizer.json"), b"stub").unwrap();
525        std::fs::write(model_dir.join("model.onnx"), b"stub").unwrap();
526        assert!(is_model_installed(
527            &model_dir,
528            &["tokenizer.json", "model.onnx"]
529        ));
530    }
531
532    #[test]
533    fn is_model_installed_handles_nested_files() {
534        let temp = tempfile::tempdir().unwrap();
535        let model_dir = temp.path().join("model/v1");
536        std::fs::create_dir_all(model_dir.join("onnx")).unwrap();
537        std::fs::write(model_dir.join("onnx/model.onnx"), b"stub").unwrap();
538        std::fs::write(model_dir.join("tokenizer.json"), b"stub").unwrap();
539        assert!(is_model_installed(
540            &model_dir,
541            &["onnx/model.onnx", "tokenizer.json"]
542        ));
543    }
544
545    #[test]
546    fn known_models_is_not_empty() {
547        assert!(!known_models().is_empty());
548        for m in known_models() {
549            assert!(!m.dir_name.is_empty());
550            assert!(!m.version.is_empty());
551            assert!(!m.description.is_empty());
552        }
553    }
554
555    #[test]
556    fn layout_schema_version() {
557        assert_eq!(MODEL_CACHE_LAYOUT_VERSION, 1);
558    }
559
560    #[test]
561    fn env_constants_match_expected_values() {
562        assert_eq!(ENV_MODEL_DIR, "FRANKENSEARCH_MODEL_DIR");
563        assert_eq!(ENV_DATA_DIR, "FRANKENSEARCH_DATA_DIR");
564    }
565}