use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::EmbeddingError;
const CATALOG_FORMAT_VERSION: u32 = 1;
const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
const LOCK_POLL: Duration = Duration::from_millis(5);
const CHECKSUM_BUFFER: usize = 64 * 1024;
pub const CATALOG_PATH_ENV: &str = "LC_EMBEDDINGS_MODEL_CATALOG";
static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelLayout {
CandleBert,
Onnx,
}
impl ModelLayout {
pub const fn required_files(self) -> &'static [&'static str] {
match self {
Self::CandleBert => &["config.json", "tokenizer.json", "model.safetensors"],
Self::Onnx => &["model.onnx", "tokenizer.json"],
}
}
pub const fn weights_file(self) -> &'static str {
match self {
Self::CandleBert => "model.safetensors",
Self::Onnx => "model.onnx",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ModelSource {
Local,
HuggingFace {
repo_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelEntry {
pub name: String,
pub dir: PathBuf,
pub layout: ModelLayout,
pub dimension: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
pub registered_at: u64,
pub source: ModelSource,
}
impl ModelEntry {
pub fn new(
name: impl Into<String>,
dir: impl Into<PathBuf>,
layout: ModelLayout,
dimension: usize,
) -> Self {
Self {
name: name.into(),
dir: dir.into(),
layout,
dimension,
checksum: None,
registered_at: now_unix_secs(),
source: ModelSource::Local,
}
}
pub fn with_source(mut self, source: ModelSource) -> Self {
self.source = source;
self
}
pub fn with_checksum(mut self, checksum: impl Into<String>) -> Self {
self.checksum = Some(checksum.into());
self
}
pub fn model_path(&self) -> PathBuf {
self.dir.join(self.layout.weights_file())
}
pub fn tokenizer_path(&self) -> PathBuf {
self.dir.join("tokenizer.json")
}
pub fn config_path(&self) -> Option<PathBuf> {
match self.layout {
ModelLayout::CandleBert => Some(self.dir.join("config.json")),
ModelLayout::Onnx => None,
}
}
pub fn missing_files(&self) -> Vec<PathBuf> {
self.layout
.required_files()
.iter()
.map(|f| self.dir.join(f))
.filter(|p| !p.is_file())
.collect()
}
pub fn is_ready(&self) -> bool {
self.missing_files().is_empty()
}
pub fn compute_checksum(&self) -> Result<String, EmbeddingError> {
file_checksum(self.model_path())
}
pub fn verify_checksum(&self) -> Result<Option<bool>, EmbeddingError> {
match &self.checksum {
None => Ok(None),
Some(recorded) => Ok(Some(file_checksum(self.model_path())? == *recorded)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelSpec {
pub name: String,
pub dir: PathBuf,
pub layout: ModelLayout,
pub dimension: usize,
pub source: ModelSource,
}
impl ModelSpec {
pub fn new(
name: impl Into<String>,
dir: impl Into<PathBuf>,
layout: ModelLayout,
dimension: usize,
) -> Self {
Self {
name: name.into(),
dir: dir.into(),
layout,
dimension,
source: ModelSource::Local,
}
}
pub fn with_source(mut self, source: ModelSource) -> Self {
self.source = source;
self
}
}
pub trait ModelFetcher {
fn fetch(&self, spec: &ModelSpec) -> Result<PathBuf, EmbeddingError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LocalOnlyFetcher;
impl ModelFetcher for LocalOnlyFetcher {
fn fetch(&self, spec: &ModelSpec) -> Result<PathBuf, EmbeddingError> {
if !spec.dir.is_dir() {
return Err(EmbeddingError::Config(format!(
"offline prefetch: model directory '{}' for '{}' does not exist",
spec.dir.display(),
spec.name
)));
}
Ok(spec.dir.clone())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrefetchReport {
pub registered: Vec<String>,
pub skipped: Vec<String>,
}
impl PrefetchReport {
pub fn is_empty(&self) -> bool {
self.registered.is_empty() && self.skipped.is_empty()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CatalogFile {
version: u32,
entries: BTreeMap<String, ModelEntry>,
}
#[derive(Debug)]
pub struct ModelCatalog {
path: PathBuf,
entries: BTreeMap<String, ModelEntry>,
}
impl ModelCatalog {
pub fn open(path: impl Into<PathBuf>) -> Result<Self, EmbeddingError> {
let path = path.into();
let entries = Self::read_from(&path)?;
Ok(Self { path, entries })
}
pub fn default_path() -> PathBuf {
if let Some(explicit) = std::env::var_os(CATALOG_PATH_ENV).filter(|v| !v.is_empty()) {
return PathBuf::from(explicit);
}
let home = std::env::var_os("USERPROFILE")
.filter(|v| !v.is_empty())
.or_else(|| std::env::var_os("HOME").filter(|v| !v.is_empty()));
match home {
Some(home) => PathBuf::from(home)
.join(".lc")
.join("models")
.join("catalog.json"),
None => std::env::temp_dir().join("lc-embeddings-models.json"),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, name: &str) -> Option<&ModelEntry> {
self.entries.get(name)
}
pub fn iter(&self) -> impl Iterator<Item = &ModelEntry> {
self.entries.values()
}
pub fn resolve(&self, name: &str) -> Result<&ModelEntry, EmbeddingError> {
self.entries
.get(name)
.ok_or_else(|| EmbeddingError::ModelNotFound {
name: name.to_string(),
})
}
pub fn resolve_ready(&self, name: &str) -> Result<&ModelEntry, EmbeddingError> {
let entry = self.resolve(name)?;
let missing = entry.missing_files();
if !missing.is_empty() {
return Err(EmbeddingError::ModelNotReady {
name: name.to_string(),
missing: missing
.iter()
.map(|p| p.display().to_string())
.collect(),
});
}
Ok(entry)
}
pub fn register(&mut self, entry: ModelEntry) -> Result<(), EmbeddingError> {
validate(&entry)?;
let _lock = FileLock::acquire(&self.path, LOCK_TIMEOUT)?;
self.reload_locked()?;
self.entries.insert(entry.name.clone(), entry);
self.write_atomically()
}
pub fn unregister(&mut self, name: &str) -> Result<bool, EmbeddingError> {
let _lock = FileLock::acquire(&self.path, LOCK_TIMEOUT)?;
self.reload_locked()?;
if self.entries.remove(name).is_none() {
return Ok(false);
}
self.write_atomically()?;
Ok(true)
}
pub fn prefetch(
&mut self,
specs: &[ModelSpec],
fetcher: &dyn ModelFetcher,
) -> Result<PrefetchReport, EmbeddingError> {
let mut resolved: Vec<ModelEntry> = Vec::with_capacity(specs.len());
let mut skipped: Vec<String> = Vec::new();
for spec in specs {
if let Some(existing) = self.entries.get(&spec.name) {
if existing.is_ready() {
skipped.push(spec.name.clone());
continue;
}
}
let dir = fetcher.fetch(spec)?;
let mut entry =
ModelEntry::new(spec.name.clone(), dir, spec.layout, spec.dimension)
.with_source(spec.source.clone());
let missing = entry.missing_files();
if !missing.is_empty() {
return Err(EmbeddingError::Config(format!(
"prefetch of '{}' is incomplete: missing {}",
spec.name,
missing
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)));
}
entry.checksum = Some(entry.compute_checksum()?);
validate(&entry)?;
resolved.push(entry);
}
if resolved.is_empty() {
return Ok(PrefetchReport {
registered: Vec::new(),
skipped,
});
}
let _lock = FileLock::acquire(&self.path, LOCK_TIMEOUT)?;
self.reload_locked()?;
let mut registered = Vec::with_capacity(resolved.len());
for entry in resolved {
registered.push(entry.name.clone());
self.entries.insert(entry.name.clone(), entry);
}
self.write_atomically()?;
Ok(PrefetchReport {
registered,
skipped,
})
}
pub fn reload(&mut self) -> Result<(), EmbeddingError> {
let _lock = FileLock::acquire(&self.path, LOCK_TIMEOUT)?;
self.reload_locked()
}
pub fn save(&self) -> Result<(), EmbeddingError> {
let _lock = FileLock::acquire(&self.path, LOCK_TIMEOUT)?;
self.write_atomically()
}
fn reload_locked(&mut self) -> Result<(), EmbeddingError> {
self.entries = Self::read_from(&self.path)?;
Ok(())
}
fn read_from(path: &Path) -> Result<BTreeMap<String, ModelEntry>, EmbeddingError> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(BTreeMap::new());
}
Err(e) => {
return Err(EmbeddingError::Config(format!(
"failed to read model catalog '{}': {e}",
path.display()
)));
}
};
if bytes.is_empty() {
return Ok(BTreeMap::new());
}
let file: CatalogFile = serde_json::from_slice(&bytes).map_err(|e| {
EmbeddingError::Config(format!(
"failed to parse model catalog '{}': {e}",
path.display()
))
})?;
if file.version != CATALOG_FORMAT_VERSION {
return Err(EmbeddingError::Config(format!(
"model catalog '{}' declares format version {}, but this build understands \
version {CATALOG_FORMAT_VERSION}",
path.display(),
file.version
)));
}
Ok(file.entries)
}
fn write_atomically(&self) -> Result<(), EmbeddingError> {
if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(|e| {
EmbeddingError::Config(format!(
"failed to create model catalog directory '{}': {e}",
parent.display()
))
})?;
}
let document = CatalogFile {
version: CATALOG_FORMAT_VERSION,
entries: self.entries.clone(),
};
let json = serde_json::to_vec_pretty(&document).map_err(|e| {
EmbeddingError::Config(format!("failed to serialize model catalog: {e}"))
})?;
let seq = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = self
.path
.with_extension(format!("tmp-{}-{seq}", std::process::id()));
fs::write(&tmp, &json).map_err(|e| {
EmbeddingError::Config(format!(
"failed to write model catalog temp file '{}': {e}",
tmp.display()
))
})?;
fs::rename(&tmp, &self.path).map_err(|e| {
let _ = fs::remove_file(&tmp);
EmbeddingError::Config(format!(
"failed to commit model catalog '{}': {e}",
self.path.display()
))
})
}
}
fn validate(entry: &ModelEntry) -> Result<(), EmbeddingError> {
if entry.name.trim().is_empty() {
return Err(EmbeddingError::Config(
"model catalog entries need a non-empty name".to_string(),
));
}
if entry.dimension == 0 {
return Err(EmbeddingError::Config(format!(
"model catalog entry '{}' has dimension 0",
entry.name
)));
}
Ok(())
}
struct FileLock {
path: PathBuf,
}
impl FileLock {
fn acquire(target: &Path, timeout: Duration) -> Result<Self, EmbeddingError> {
let path = target.with_extension("lock");
let deadline = Instant::now() + timeout;
loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(_) => return Ok(Self { path }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if Instant::now() >= deadline {
return Err(EmbeddingError::Config(format!(
"timed out waiting for the model catalog lock '{}' after {:?}",
path.display(),
timeout
)));
}
std::thread::sleep(LOCK_POLL);
}
Err(e) => {
return Err(EmbeddingError::Config(format!(
"failed to create model catalog lock '{}': {e}",
path.display()
)));
}
}
}
}
}
impl Drop for FileLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
struct Fnv1a64(u64);
impl Fnv1a64 {
fn new() -> Self {
Self(0xcbf2_9ce4_8422_2325)
}
fn update(&mut self, bytes: &[u8]) {
for b in bytes {
self.0 ^= u64::from(*b);
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
}
}
fn finish(&self) -> u64 {
self.0
}
}
pub fn file_checksum(path: impl AsRef<Path>) -> Result<String, EmbeddingError> {
let path = path.as_ref();
let mut file = fs::File::open(path).map_err(|e| {
EmbeddingError::Config(format!(
"failed to open '{}' for checksum: {e}",
path.display()
))
})?;
let mut hasher = Fnv1a64::new();
let mut buf = vec![0u8; CHECKSUM_BUFFER];
loop {
let read = file.read(&mut buf).map_err(|e| {
EmbeddingError::Config(format!("failed to read '{}': {e}", path.display()))
})?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(format!("fnv1a64:{:016x}", hasher.finish()))
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicU64 as TestCounter;
static TEST_DIR_COUNTER: TestCounter = TestCounter::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let seq = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lc-embeddings-catalog-{}-{tag}-{seq}",
std::process::id()
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("scratch dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn ready_dir(root: &Path, name: &str, layout: ModelLayout) -> PathBuf {
let dir = root.join(name);
fs::create_dir_all(&dir).expect("model dir");
for file in layout.required_files() {
fs::write(dir.join(file), format!("{name}:{file}")).expect("model file");
}
dir
}
#[test]
fn register_persist_reload_roundtrip() {
let tmp = TempDir::new("roundtrip");
let catalog_path = tmp.path().join("models.json");
let dir = ready_dir(tmp.path(), "bge-small", ModelLayout::CandleBert);
let mut catalog = ModelCatalog::open(&catalog_path).unwrap();
assert!(catalog.is_empty());
let entry = ModelEntry::new("bge-small", &dir, ModelLayout::CandleBert, 384)
.with_source(ModelSource::HuggingFace {
repo_id: "BAAI/bge-small-en-v1.5".to_string(),
})
.with_checksum("fnv1a64:deadbeefdeadbeef");
catalog.register(entry.clone()).unwrap();
assert_eq!(catalog.len(), 1);
let reopened = ModelCatalog::open(&catalog_path).unwrap();
assert_eq!(reopened.len(), 1);
assert_eq!(reopened.get("bge-small"), Some(&entry));
assert_eq!(
reopened.resolve("bge-small").unwrap().dimension,
384,
"dimension survives the round-trip"
);
assert_eq!(
reopened.resolve("bge-small").unwrap().model_path(),
dir.join("model.safetensors")
);
}
#[test]
fn resolve_miss_is_explicit() {
let tmp = TempDir::new("miss");
let catalog = ModelCatalog::open(tmp.path().join("models.json")).unwrap();
let err = catalog.resolve("nope").unwrap_err();
assert!(matches!(err, EmbeddingError::ModelNotFound { name } if name == "nope"));
assert!(matches!(
catalog.resolve_ready("nope").unwrap_err(),
EmbeddingError::ModelNotFound { .. }
));
}
#[test]
fn resolve_ready_lists_missing_files() {
let tmp = TempDir::new("not-ready");
let dir = ready_dir(tmp.path(), "half", ModelLayout::Onnx);
fs::remove_file(dir.join("tokenizer.json")).unwrap();
let mut catalog = ModelCatalog::open(tmp.path().join("models.json")).unwrap();
catalog
.register(ModelEntry::new("half", &dir, ModelLayout::Onnx, 768))
.unwrap();
assert_eq!(catalog.resolve("half").unwrap().dimension, 768);
let err = catalog.resolve_ready("half").unwrap_err();
match err {
EmbeddingError::ModelNotReady { name, missing } => {
assert_eq!(name, "half");
assert_eq!(missing.len(), 1);
assert!(missing[0].ends_with("tokenizer.json"), "got {missing:?}");
}
other => panic!("expected ModelNotReady, got {other:?}"),
}
assert!(!catalog.resolve("half").unwrap().is_ready());
}
#[test]
fn malformed_catalog_is_an_error_not_an_empty_catalog() {
let tmp = TempDir::new("malformed");
let path = tmp.path().join("models.json");
fs::write(&path, b"{not json").unwrap();
let err = ModelCatalog::open(&path).unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
fs::write(&path, br#"{"version": 99, "entries": {}}"#).unwrap();
let err = ModelCatalog::open(&path).unwrap_err();
assert!(
err.to_string().contains("format version 99"),
"got {err}"
);
fs::write(&path, b"").unwrap();
assert!(ModelCatalog::open(&path).unwrap().is_empty());
}
#[test]
fn empty_name_and_zero_dimension_are_rejected() {
let tmp = TempDir::new("validate");
let dir = ready_dir(tmp.path(), "m", ModelLayout::CandleBert);
let mut catalog = ModelCatalog::open(tmp.path().join("models.json")).unwrap();
let blank = ModelEntry::new(" ", &dir, ModelLayout::CandleBert, 384);
assert!(matches!(
catalog.register(blank).unwrap_err(),
EmbeddingError::Config(_)
));
let zero = ModelEntry::new("m", &dir, ModelLayout::CandleBert, 0);
assert!(matches!(
catalog.register(zero).unwrap_err(),
EmbeddingError::Config(_)
));
assert!(catalog.is_empty(), "nothing invalid was persisted");
}
#[test]
fn unregister_removes_and_persists() {
let tmp = TempDir::new("unregister");
let path = tmp.path().join("models.json");
let dir = ready_dir(tmp.path(), "m", ModelLayout::CandleBert);
let mut catalog = ModelCatalog::open(&path).unwrap();
catalog
.register(ModelEntry::new("m", &dir, ModelLayout::CandleBert, 384))
.unwrap();
assert!(catalog.unregister("m").unwrap());
assert!(!catalog.unregister("m").unwrap(), "second removal is a no-op");
assert!(ModelCatalog::open(&path).unwrap().is_empty());
}
#[test]
fn concurrent_registrations_do_not_corrupt_or_lose_entries() {
const WRITERS: usize = 8;
let tmp = TempDir::new("concurrent");
let path = tmp.path().join("models.json");
let mut dirs = Vec::new();
for i in 0..WRITERS {
dirs.push(ready_dir(tmp.path(), &format!("m{i}"), ModelLayout::Onnx));
}
ModelCatalog::open(&path).unwrap().save().unwrap();
std::thread::scope(|scope| {
for (i, dir) in dirs.iter().enumerate() {
let path = path.clone();
scope.spawn(move || {
let mut catalog = ModelCatalog::open(&path).unwrap();
catalog
.register(ModelEntry::new(
format!("m{i}"),
dir,
ModelLayout::Onnx,
768,
))
.unwrap();
});
}
});
let catalog = ModelCatalog::open(&path).unwrap();
assert_eq!(catalog.len(), WRITERS, "no lost updates");
for i in 0..WRITERS {
let entry = catalog.resolve(&format!("m{i}")).unwrap();
assert_eq!(entry.dimension, 768);
}
assert!(!path.with_extension("lock").exists(), "lock removed on drop");
}
#[test]
fn checksum_is_stable_and_content_sensitive() {
let tmp = TempDir::new("checksum");
let dir = ready_dir(tmp.path(), "m", ModelLayout::CandleBert);
let entry = ModelEntry::new("m", &dir, ModelLayout::CandleBert, 384);
let first = entry.compute_checksum().unwrap();
assert!(first.starts_with("fnv1a64:"), "got {first}");
assert_eq!(first, entry.compute_checksum().unwrap(), "stable");
let stamped = entry.clone().with_checksum(first.clone());
assert_eq!(stamped.verify_checksum().unwrap(), Some(true));
assert_eq!(
entry.verify_checksum().unwrap(),
None,
"no recorded checksum is reported, not assumed valid"
);
fs::write(entry.model_path(), b"tampered").unwrap();
assert_eq!(stamped.verify_checksum().unwrap(), Some(false));
assert_ne!(first, stamped.compute_checksum().unwrap());
}
#[test]
fn checksum_of_missing_weights_errors() {
let tmp = TempDir::new("checksum-missing");
let entry = ModelEntry::new(
"ghost",
tmp.path().join("ghost"),
ModelLayout::Onnx,
384,
);
let err = entry.compute_checksum().unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
assert!(err.to_string().contains("model.onnx"), "got {err}");
}
#[test]
fn offline_prefetch_registers_metadata_and_is_idempotent() {
let tmp = TempDir::new("prefetch");
let path = tmp.path().join("models.json");
let small = ready_dir(tmp.path(), "bge-small", ModelLayout::CandleBert);
let onnx = ready_dir(tmp.path(), "mini", ModelLayout::Onnx);
let specs = [
ModelSpec::new("bge-small", &small, ModelLayout::CandleBert, 384).with_source(
ModelSource::HuggingFace {
repo_id: "BAAI/bge-small-en-v1.5".to_string(),
},
),
ModelSpec::new("mini", &onnx, ModelLayout::Onnx, 384),
];
let mut catalog = ModelCatalog::open(&path).unwrap();
let report = catalog.prefetch(&specs, &LocalOnlyFetcher).unwrap();
assert_eq!(report.registered, vec!["bge-small", "mini"]);
assert!(report.skipped.is_empty());
assert!(!report.is_empty());
let entry = catalog.resolve_ready("bge-small").unwrap();
assert_eq!(
entry.source,
ModelSource::HuggingFace {
repo_id: "BAAI/bge-small-en-v1.5".to_string()
}
);
assert_eq!(
entry.verify_checksum().unwrap(),
Some(true),
"prefetch records a verifiable checksum"
);
let report = catalog.prefetch(&specs, &LocalOnlyFetcher).unwrap();
assert!(report.registered.is_empty());
assert_eq!(report.skipped, vec!["bge-small", "mini"]);
assert_eq!(ModelCatalog::open(&path).unwrap().len(), 2);
}
#[test]
fn prefetch_failure_writes_nothing() {
let tmp = TempDir::new("prefetch-fail");
let path = tmp.path().join("models.json");
let good = ready_dir(tmp.path(), "good", ModelLayout::CandleBert);
let incomplete = ready_dir(tmp.path(), "incomplete", ModelLayout::Onnx);
fs::remove_file(incomplete.join("model.onnx")).unwrap();
let mut catalog = ModelCatalog::open(&path).unwrap();
let missing = ModelSpec::new(
"absent",
tmp.path().join("nope"),
ModelLayout::Onnx,
384,
);
let err = catalog
.prefetch(&[missing], &LocalOnlyFetcher)
.unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
assert!(catalog.is_empty());
let specs = [
ModelSpec::new("good", &good, ModelLayout::CandleBert, 384),
ModelSpec::new("incomplete", &incomplete, ModelLayout::Onnx, 384),
];
let err = catalog
.prefetch(&specs, &LocalOnlyFetcher)
.unwrap_err();
assert!(err.to_string().contains("incomplete"), "got {err}");
assert!(
catalog.is_empty(),
"all-or-nothing: nothing was registered"
);
assert!(ModelCatalog::open(&path).unwrap().is_empty());
}
#[test]
fn reload_picks_up_another_writers_entries() {
let tmp = TempDir::new("reload");
let path = tmp.path().join("models.json");
let dir = ready_dir(tmp.path(), "m", ModelLayout::CandleBert);
let mut first = ModelCatalog::open(&path).unwrap();
let mut second = ModelCatalog::open(&path).unwrap();
first
.register(ModelEntry::new("m", &dir, ModelLayout::CandleBert, 384))
.unwrap();
assert!(second.is_empty(), "second handle has stale state");
second.reload().unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second.resolve("m").unwrap().dimension, 384);
}
#[test]
fn default_path_honors_env_override() {
let _guard = crate::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::env::var_os(CATALOG_PATH_ENV);
std::env::set_var(CATALOG_PATH_ENV, "D:/tmp/explicit-catalog.json");
assert_eq!(
ModelCatalog::default_path(),
PathBuf::from("D:/tmp/explicit-catalog.json")
);
std::env::remove_var(CATALOG_PATH_ENV);
let fallback = ModelCatalog::default_path();
assert!(
fallback.ends_with("catalog.json") || fallback.ends_with("lc-embeddings-models.json"),
"unexpected default path {fallback:?}"
);
match previous {
Some(v) => std::env::set_var(CATALOG_PATH_ENV, v),
None => std::env::remove_var(CATALOG_PATH_ENV),
}
}
#[test]
fn entry_paths_follow_the_layout() {
let candle = ModelEntry::new("c", "models/c", ModelLayout::CandleBert, 384);
assert_eq!(
candle.model_path().file_name().unwrap(),
"model.safetensors"
);
assert_eq!(
candle.tokenizer_path().file_name().unwrap(),
"tokenizer.json"
);
assert_eq!(candle.config_path().unwrap().file_name().unwrap(), "config.json");
assert_eq!(candle.model_path().parent().unwrap(), candle.dir);
let onnx = ModelEntry::new("o", "models/o", ModelLayout::Onnx, 768);
assert_eq!(onnx.model_path().file_name().unwrap(), "model.onnx");
assert_eq!(onnx.config_path(), None);
}
}