1use std::path::{Path, PathBuf};
31
32use frankensearch_core::error::SearchResult;
33
34pub const ENV_MODEL_DIR: &str = "FRANKENSEARCH_MODEL_DIR";
38
39pub const ENV_DATA_DIR: &str = "FRANKENSEARCH_DATA_DIR";
41
42const ENV_XDG_DATA_HOME: &str = "XDG_DATA_HOME";
44
45const FRANKENSEARCH_SUBDIR: &str = "frankensearch";
47
48const MODELS_SUBDIR: &str = "models";
50
51pub const MODEL_CACHE_LAYOUT_VERSION: u32 = 1;
53
54const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct KnownModel {
83 pub dir_name: &'static str,
85 pub version: &'static str,
87 pub description: &'static str,
89}
90
91#[must_use]
93pub const fn known_models() -> &'static [KnownModel] {
94 KNOWN_MODELS
95}
96
97#[must_use]
103pub fn resolve_cache_root() -> PathBuf {
104 resolve_cache_root_with(&EnvReader::Real)
105}
106
107fn resolve_cache_root_with(env: &dyn EnvLookup) -> PathBuf {
109 if let Some(path) = env_var_if_non_empty(env, ENV_MODEL_DIR) {
111 return PathBuf::from(path);
112 }
113
114 if let Some(path) = env_var_if_non_empty(env, ENV_DATA_DIR) {
116 return PathBuf::from(path).join(MODELS_SUBDIR);
117 }
118
119 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 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ModelCacheLayout {
155 pub root: PathBuf,
157 pub model_dirs: Vec<ModelDirEntry>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ModelDirEntry {
164 pub name: String,
166 pub version: String,
168 pub path: PathBuf,
170}
171
172impl ModelCacheLayout {
173 #[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 #[must_use]
189 pub fn default_layout() -> Self {
190 Self::for_root(resolve_cache_root())
191 }
192
193 #[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 #[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 #[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
221pub 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
242pub fn ensure_default_cache() -> SearchResult<ModelCacheLayout> {
251 let layout = ModelCacheLayout::default_layout();
252 ensure_cache_layout(&layout)?;
253 Ok(layout)
254}
255
256#[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#[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
287trait 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#[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 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 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 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}