use super::layout::{CacheEntryInfo, CacheLayout};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use xybrid_core::bundler::XyBundle;
use crate::model::SdkError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStatus {
pub total_models: u32,
pub total_size_bytes: u64,
pub local_models: u32,
pub cloud_models: u32,
pub available_models: Vec<String>,
}
#[derive(Debug, Clone)]
struct CacheEntry {
id: String,
version: String,
cache_type: CacheType,
path: PathBuf,
size_bytes: u64,
cached_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CacheType {
Local,
Cloud,
}
const CLOUD_TTL_SECONDS: u64 = 24 * 60 * 60;
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug)]
pub struct CacheManager {
cache_dir: PathBuf,
entries: HashMap<String, CacheEntry>,
}
impl CacheManager {
pub fn new() -> Result<Self, SdkError> {
let cache_dir = Self::get_cache_dir()?;
std::fs::create_dir_all(&cache_dir)
.map_err(|e| SdkError::cache_src("Failed to create cache directory", e))?;
let mut manager = Self {
cache_dir,
entries: HashMap::new(),
};
manager.scan_cache()?;
Ok(manager)
}
pub fn with_dir(cache_dir: PathBuf) -> Result<Self, SdkError> {
std::fs::create_dir_all(&cache_dir)
.map_err(|e| SdkError::cache_src("Failed to create cache directory", e))?;
let mut manager = Self {
cache_dir,
entries: HashMap::new(),
};
manager.scan_cache()?;
Ok(manager)
}
fn get_cache_dir() -> Result<PathBuf, SdkError> {
if let Some(cache_dir) = crate::get_sdk_cache_dir() {
return Ok(cache_dir);
}
#[cfg(target_os = "ios")]
{
let home = std::env::var("HOME")
.map_err(|_| SdkError::cache("HOME environment variable not set"))?;
Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("Xybrid")
.join("Models"))
}
#[cfg(target_os = "android")]
{
Err(SdkError::cache(
"Android requires cache directory to be configured. \
Call init_sdk_cache_dir() with a path from path_provider before loading models. \
Example: initSdkCacheDir('${appDir.path}/xybrid/models')",
))
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
{
let home =
dirs::home_dir().ok_or_else(|| SdkError::cache("Home directory not found"))?;
Ok(home.join(".xybrid").join("cache").join("models"))
}
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub(crate) fn layout_from_config() -> Result<CacheLayout, SdkError> {
Ok(CacheLayout::from_registry_root(Self::get_cache_dir()?))
}
fn layout(&self) -> CacheLayout {
CacheLayout::from_registry_root(self.cache_dir.clone())
}
pub(crate) fn registry_bundle_path(&self, hf_repo: &str, file: &str) -> PathBuf {
self.layout().registry_bundle_path(hf_repo, file)
}
pub(crate) fn cache_entries(&self) -> Result<Vec<CacheEntryInfo>, SdkError> {
self.layout().cache_entries()
}
fn scan_cache(&mut self) -> Result<(), SdkError> {
if !self.cache_dir.exists() {
return Ok(());
}
let entries = std::fs::read_dir(&self.cache_dir)
.map_err(|e| SdkError::cache_src("Failed to read cache directory", e))?;
for entry in entries {
let entry = entry.map_err(|e| SdkError::cache_src("Failed to read cache entry", e))?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("xyb") {
if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
if let Some((id, version)) = file_stem.split_once('@') {
let metadata = std::fs::metadata(&path)
.map_err(|e| SdkError::cache_src("Failed to read metadata", e))?;
let cached_at = metadata
.modified()
.or_else(|_| metadata.created())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let cache_type = CacheType::Local;
let cache_entry = CacheEntry {
id: id.to_string(),
version: version.to_string(),
cache_type,
path: path.clone(),
size_bytes: metadata.len(),
cached_at,
};
let key = format!("{}@{}", id, version);
self.entries.insert(key, cache_entry);
}
}
}
}
Ok(())
}
pub fn status(&self) -> Result<CacheStatus, SdkError> {
let now = now_unix_secs();
let valid_entries: Vec<_> = self
.entries
.values()
.filter(|entry| match entry.cache_type {
CacheType::Local => true,
CacheType::Cloud => now.saturating_sub(entry.cached_at) < CLOUD_TTL_SECONDS,
})
.collect();
let total_models = valid_entries.len() as u32;
let total_size_bytes: u64 = valid_entries.iter().map(|e| e.size_bytes).sum();
let local_models = valid_entries
.iter()
.filter(|e| e.cache_type == CacheType::Local)
.count() as u32;
let cloud_models = valid_entries
.iter()
.filter(|e| e.cache_type == CacheType::Cloud)
.count() as u32;
let available_models: Vec<String> = valid_entries
.iter()
.map(|e| format!("{}@{}", e.id, e.version))
.collect();
Ok(CacheStatus {
total_models,
total_size_bytes,
local_models,
cloud_models,
available_models,
})
}
pub fn is_cached(&self, model_id: &str) -> bool {
if self.entries.contains_key(model_id) {
return true;
}
self.entries
.keys()
.any(|key| key.starts_with(&format!("{}@", model_id)))
}
pub fn get_cached_path(&self, model_id: &str) -> Option<PathBuf> {
if let Some(entry) = self.entries.get(model_id) {
return Some(entry.path.clone());
}
let prefix = format!("{}@", model_id);
self.entries
.iter()
.filter(|(key, _)| key.starts_with(&prefix))
.max_by_key(|(key, _)| *key)
.map(|(_, entry)| entry.path.clone())
}
pub fn decompress_bundle(&self, bundle_path: &Path) -> Result<PathBuf, SdkError> {
if !bundle_path.exists() {
return Err(SdkError::cache(format!(
"Bundle not found: {}",
bundle_path.display()
)));
}
let file_stem = bundle_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| SdkError::cache("Invalid bundle filename"))?;
let (id, version) = file_stem
.split_once('@')
.ok_or_else(|| SdkError::cache("Bundle filename must be in format id@version.xyb"))?;
let decompressed_dir = self.cache_dir.join(format!("{}_{}", id, version));
std::fs::create_dir_all(&decompressed_dir)
.map_err(|e| SdkError::cache_src("Failed to create decompressed directory", e))?;
let bundle = XyBundle::load(bundle_path)
.map_err(|e| SdkError::cache_src("Failed to load bundle", e))?;
bundle
.extract_to(&decompressed_dir)
.map_err(|e| SdkError::cache_src("Failed to extract bundle", e))?;
let manifest_path = decompressed_dir.join("manifest.json");
let manifest_json = serde_json::to_string_pretty(bundle.manifest())
.map_err(|e| SdkError::cache_src("Failed to serialize manifest", e))?;
std::fs::write(&manifest_path, manifest_json)
.map_err(|e| SdkError::cache_src("Failed to write manifest", e))?;
Ok(decompressed_dir)
}
pub fn extraction_dir(&self, model_id: &str) -> PathBuf {
self.layout().extraction_dir(model_id)
}
pub fn is_extracted(&self, model_id: &str) -> bool {
self.existing_extraction_dir(model_id).is_some()
}
pub(crate) fn existing_extraction_dir(&self, model_id: &str) -> Option<PathBuf> {
self.layout()
.extraction_dirs(model_id)
.into_iter()
.find(|extract_dir| Self::extraction_is_ready(extract_dir))
}
fn extraction_is_ready(extract_dir: &Path) -> bool {
use xybrid_core::execution::ModelMetadata;
let metadata_path = extract_dir.join("model_metadata.json");
let Ok(metadata_json) = std::fs::read_to_string(&metadata_path) else {
return false;
};
let Ok(metadata) = serde_json::from_str::<ModelMetadata>(&metadata_json) else {
return false;
};
metadata
.files
.iter()
.all(|file| extract_dir.join(file).exists())
}
pub fn list_extracted_model_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self
.layout()
.extracted_roots()
.into_iter()
.filter_map(|root| std::fs::read_dir(root).ok())
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter(|entry| Self::extraction_is_ready(&entry.path()))
.filter_map(|entry| {
entry
.file_name()
.into_string()
.ok()
.filter(|name| !name.starts_with('.'))
})
.collect();
ids.sort();
ids.dedup();
ids
}
pub fn ensure_extracted(&self, xyb_path: &Path) -> Result<PathBuf, SdkError> {
use xybrid_core::execution::ModelMetadata;
if !xyb_path.exists() {
return Err(SdkError::cache(format!(
"Bundle not found: {}",
xyb_path.display()
)));
}
let bundle = XyBundle::load(xyb_path)
.map_err(|e| SdkError::cache_src("Failed to load bundle", e))?;
let metadata_json = bundle
.get_metadata_json()
.map_err(|e| SdkError::cache_src("Failed to read bundle metadata", e))?
.ok_or_else(|| SdkError::cache("Bundle has no model_metadata.json"))?;
let metadata: ModelMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| SdkError::cache_src("Failed to parse model metadata", e))?;
if let Some(extract_dir) = self.existing_extraction_dir(&metadata.model_id) {
log::debug!(
"Bundle already extracted for '{}' at {}",
metadata.model_id,
extract_dir.display()
);
return Ok(extract_dir);
}
let extract_dir = self.extraction_dir(&metadata.model_id);
std::fs::create_dir_all(&extract_dir)
.map_err(|e| SdkError::cache_src("Failed to create extraction directory", e))?;
log::info!(
"Extracting bundle '{}' to {}",
metadata.model_id,
extract_dir.display()
);
bundle
.extract_to(&extract_dir)
.map_err(|e| SdkError::cache_src("Failed to extract bundle", e))?;
Ok(extract_dir)
}
pub fn ensure_extracted_with_id(
&self,
xyb_path: &Path,
model_id: &str,
) -> Result<PathBuf, SdkError> {
if let Some(extract_dir) = self.existing_extraction_dir(model_id) {
log::debug!(
"Bundle already extracted for '{}' at {}",
model_id,
extract_dir.display()
);
return Ok(extract_dir);
}
let extract_dir = self.extraction_dir(model_id);
if !xyb_path.exists() {
return Err(SdkError::cache(format!(
"Bundle not found: {}",
xyb_path.display()
)));
}
let bundle = XyBundle::load(xyb_path)
.map_err(|e| SdkError::cache_src("Failed to load bundle", e))?;
std::fs::create_dir_all(&extract_dir)
.map_err(|e| SdkError::cache_src("Failed to create extraction directory", e))?;
log::info!(
"Extracting bundle '{}' to {}",
model_id,
extract_dir.display()
);
bundle
.extract_to(&extract_dir)
.map_err(|e| SdkError::cache_src("Failed to extract bundle", e))?;
Ok(extract_dir)
}
pub fn clean_expired(&mut self) -> Result<u32, SdkError> {
let now = now_unix_secs();
let mut removed_count = 0;
let mut to_remove = Vec::new();
for (key, entry) in &self.entries {
if entry.cache_type == CacheType::Cloud
&& now.saturating_sub(entry.cached_at) >= CLOUD_TTL_SECONDS
{
to_remove.push(key.clone());
}
}
for key in to_remove {
if let Some(entry) = self.entries.remove(&key) {
if entry.path.exists() {
std::fs::remove_file(&entry.path)
.map_err(|e| SdkError::cache_src("Failed to remove expired bundle", e))?;
}
removed_count += 1;
}
}
Ok(removed_count)
}
pub(crate) fn clear_model_roots(&mut self, model_id: &str) -> Result<u32, SdkError> {
if model_id.is_empty()
|| !Path::new(model_id)
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Err(SdkError::cache(format!(
"Invalid cache model identifier: {}",
model_id
)));
}
let mut roots = self.layout().model_roots(model_id);
let mut entry_keys = Vec::new();
for (key, entry) in &self.entries {
if entry.id == model_id {
entry_keys.push(key.clone());
roots.push(entry.path.clone());
}
}
let mut removed_count = 0;
let mut seen = HashSet::new();
for root in roots {
if !seen.insert(root.clone()) {
continue;
}
if Self::remove_cache_path(&root)? {
removed_count += 1;
}
}
for key in entry_keys {
self.entries.remove(&key);
}
Ok(removed_count)
}
pub fn clear(&mut self) -> Result<u32, SdkError> {
let mut removed_count = 0;
for root in self.layout().entry_roots() {
if Self::remove_cache_path(&root.path)? {
removed_count += 1;
}
}
std::fs::create_dir_all(&self.cache_dir)
.map_err(|e| SdkError::cache_src("Failed to recreate cache directory", e))?;
self.entries.clear();
Ok(removed_count)
}
fn remove_cache_path(path: &Path) -> Result<bool, SdkError> {
if path.is_dir() {
std::fs::remove_dir_all(path)
.map_err(|e| SdkError::cache_src("Failed to remove cache directory", e))?;
Ok(true)
} else if path.exists() {
std::fs::remove_file(path)
.map_err(|e| SdkError::cache_src("Failed to remove cache file", e))?;
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use xybrid_core::bundler::XyBundle;
#[test]
fn test_cache_status_empty() {
let temp_dir = TempDir::new().unwrap();
let manager = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
let status = manager.status().unwrap();
assert_eq!(status.total_models, 0);
assert_eq!(status.total_size_bytes, 0);
}
#[test]
fn now_unix_secs_is_monotonic_and_nonzero_on_sane_clock() {
assert!(now_unix_secs() > 0);
}
#[test]
fn cloud_ttl_math_does_not_underflow_for_future_cached_at() {
let now: u64 = 1_000;
let cached_in_future: u64 = 5_000;
let elapsed = now.saturating_sub(cached_in_future);
assert_eq!(elapsed, 0, "future cached_at must floor to 0 elapsed");
assert!(
elapsed < CLOUD_TTL_SECONDS,
"a future-stamped entry must not be treated as expired"
);
}
#[test]
fn test_is_cached_empty() {
let temp_dir = TempDir::new().unwrap();
let manager = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
assert!(!manager.is_cached("test-model"));
}
#[test]
fn test_get_cached_path_empty() {
let temp_dir = TempDir::new().unwrap();
let manager = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
assert!(manager.get_cached_path("test-model").is_none());
}
#[test]
fn clear_removes_managed_model_cache_roots_when_registry_entries_are_unscanned() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("cache");
let models_dir = cache_root.join("models");
let registry_model_dir = models_dir.join("Kokoro-82M-v1.0-ONNX");
let extracted_model_dir = cache_root.join("extracted").join("kokoro-82m");
let hf_model_dir = cache_root.join("hf").join("owner--repo");
let hf_hub_model_dir = cache_root.join("hf-hub").join("models--owner--repo");
fs::create_dir_all(®istry_model_dir).unwrap();
fs::create_dir_all(&extracted_model_dir).unwrap();
fs::create_dir_all(&hf_model_dir).unwrap();
fs::create_dir_all(&hf_hub_model_dir).unwrap();
fs::write(registry_model_dir.join("universal.xyb"), b"bundle").unwrap();
fs::write(extracted_model_dir.join("model_metadata.json"), b"{}").unwrap();
fs::write(hf_model_dir.join("model.gguf"), b"weights").unwrap();
fs::write(hf_hub_model_dir.join("blob"), b"weights").unwrap();
let mut manager = CacheManager::with_dir(models_dir.clone()).unwrap();
manager.clear().unwrap();
assert!(
!registry_model_dir.exists(),
"registry model cache should be removed by clear()"
);
assert!(
!extracted_model_dir.exists(),
"extracted runtime cache should be removed by clear()"
);
assert!(
!hf_model_dir.exists(),
"direct HuggingFace model cache should be removed by clear()"
);
assert!(
!hf_hub_model_dir.exists(),
"owned HuggingFace blob cache should be removed by clear()"
);
}
#[test]
fn clear_model_roots_removes_registry_and_extracted_cache_for_id() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("cache");
let models_dir = cache_root.join("models");
let model_dir = models_dir.join("kokoro-82m");
let extracted_model_dir = cache_root.join("extracted").join("kokoro-82m");
let other_model_dir = models_dir.join("other-model");
fs::create_dir_all(&model_dir).unwrap();
fs::create_dir_all(&extracted_model_dir).unwrap();
fs::create_dir_all(&other_model_dir).unwrap();
fs::write(model_dir.join("universal.xyb"), b"bundle").unwrap();
fs::write(extracted_model_dir.join("model_metadata.json"), b"{}").unwrap();
fs::write(other_model_dir.join("universal.xyb"), b"bundle").unwrap();
let mut manager = CacheManager::with_dir(models_dir).unwrap();
let removed = manager.clear_model_roots("kokoro-82m").unwrap();
assert_eq!(removed, 2);
assert!(
!model_dir.exists(),
"registry model cache should be removed"
);
assert!(
!extracted_model_dir.exists(),
"extracted model cache should be removed"
);
assert!(other_model_dir.exists(), "unrelated model should remain");
}
#[test]
fn clear_model_roots_removes_direct_hf_cache_for_repo_id() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("cache");
let models_dir = cache_root.join("models");
let hf_model_dir = cache_root.join("hf").join("owner--repo");
let hf_hub_model_dir = cache_root.join("hf-hub").join("models--owner--repo");
fs::create_dir_all(&hf_model_dir).unwrap();
fs::create_dir_all(&hf_hub_model_dir).unwrap();
fs::write(hf_model_dir.join("model.gguf"), b"weights").unwrap();
fs::write(hf_hub_model_dir.join("blob"), b"weights").unwrap();
let mut manager = CacheManager::with_dir(models_dir).unwrap();
let removed = manager.clear_model_roots("owner/repo").unwrap();
assert_eq!(removed, 2);
assert!(
!hf_model_dir.exists(),
"direct HuggingFace model cache should be removed"
);
assert!(
!hf_hub_model_dir.exists(),
"owned HuggingFace blob cache should be removed"
);
}
#[test]
fn custom_root_resolves_legacy_parent_extracted_cache() {
let temp_dir = TempDir::new().unwrap();
let custom_cache = temp_dir.path().join("custom-cache");
let legacy_extracted_model = temp_dir.path().join("extracted").join("legacy-model");
write_ready_extracted_model(&legacy_extracted_model, "legacy-model");
let manager = CacheManager::with_dir(custom_cache).unwrap();
assert!(manager.is_extracted("legacy-model"));
assert_eq!(
manager.list_extracted_model_ids(),
vec!["legacy-model".to_string()]
);
}
#[test]
fn custom_root_clear_model_removes_legacy_parent_extracted_cache() {
let temp_dir = TempDir::new().unwrap();
let custom_cache = temp_dir.path().join("custom-cache");
let legacy_extracted_model = temp_dir.path().join("extracted").join("legacy-model");
let sibling_sentinel = temp_dir.path().join("sibling");
write_ready_extracted_model(&legacy_extracted_model, "legacy-model");
fs::create_dir_all(&sibling_sentinel).unwrap();
fs::write(sibling_sentinel.join("keep"), b"keep").unwrap();
let mut manager = CacheManager::with_dir(custom_cache).unwrap();
let removed = manager.clear_model_roots("legacy-model").unwrap();
assert_eq!(removed, 1);
assert!(!legacy_extracted_model.exists());
assert!(sibling_sentinel.join("keep").exists());
}
#[test]
fn clear_model_roots_rejects_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("cache");
let models_dir = cache_root.join("models");
let outside_dir = cache_root.join("outside");
fs::create_dir_all(&models_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
fs::write(outside_dir.join("sentinel"), b"keep").unwrap();
let mut manager = CacheManager::with_dir(models_dir).unwrap();
let result = manager.clear_model_roots("../outside");
assert!(result.is_err());
assert!(
outside_dir.join("sentinel").exists(),
"invalid model IDs must not delete outside cache paths"
);
}
#[test]
fn clear_model_roots_removes_legacy_bundle_from_memory_index() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("models");
fs::create_dir_all(&cache_dir).unwrap();
let bundle_path = cache_dir.join("test-model@1.0.xyb");
fs::write(&bundle_path, b"bundle").unwrap();
let mut manager = CacheManager::with_dir(cache_dir).unwrap();
assert!(manager.is_cached("test-model"));
assert_eq!(manager.status().unwrap().total_models, 1);
assert_eq!(
manager.get_cached_path("test-model"),
Some(bundle_path.clone())
);
let removed = manager.clear_model_roots("test-model").unwrap();
assert_eq!(removed, 1);
assert!(!bundle_path.exists());
assert!(!manager.is_cached("test-model"));
assert_eq!(manager.status().unwrap().total_models, 0);
assert_eq!(manager.get_cached_path("test-model"), None);
}
fn write_ready_extracted_model(model_dir: &Path, model_id: &str) {
fs::create_dir_all(model_dir).unwrap();
let metadata = format!(
r#"{{
"model_id": "{}",
"version": "1.0",
"execution_template": {{ "type": "Onnx", "model_file": "model.onnx" }},
"preprocessing": [],
"postprocessing": [],
"files": ["model.onnx"],
"metadata": {{}}
}}"#,
model_id
);
fs::write(model_dir.join("model_metadata.json"), metadata).unwrap();
fs::write(model_dir.join("model.onnx"), b"model").unwrap();
}
fn create_test_bundle(temp_dir: &TempDir, model_id: &str) -> PathBuf {
let model_dir = temp_dir.path().join("model_files");
fs::create_dir_all(&model_dir).unwrap();
let metadata = format!(
r#"{{
"model_id": "{}",
"version": "1.0",
"execution_template": {{ "type": "Onnx", "model_file": "model.onnx" }},
"preprocessing": [],
"postprocessing": [],
"files": ["model.onnx"],
"metadata": {{}}
}}"#,
model_id
);
fs::write(model_dir.join("model_metadata.json"), &metadata).unwrap();
fs::write(model_dir.join("model.onnx"), b"fake model data").unwrap();
let mut bundle = XyBundle::new(model_id, "1.0", "universal");
bundle
.add_file(model_dir.join("model_metadata.json"))
.unwrap();
bundle.add_file(model_dir.join("model.onnx")).unwrap();
let bundle_path = temp_dir.path().join(format!("{}.xyb", model_id));
bundle.write(&bundle_path).unwrap();
bundle_path
}
fn create_vlm_test_bundle(temp_dir: &TempDir, model_id: &str) -> PathBuf {
let model_dir = temp_dir.path().join("vlm_model_files");
fs::create_dir_all(&model_dir).unwrap();
let metadata = format!(
r#"{{
"model_id": "{}",
"version": "1.0",
"execution_template": {{
"type": "VisionLanguage",
"model_file": "model.gguf"
}},
"vision_encoder": {{
"file": "mmproj-model.gguf",
"preprocessing_preset": "gemma3_vision",
"image_size": 896
}},
"preprocessing": [],
"postprocessing": [],
"files": ["model.gguf", "mmproj-model.gguf"],
"metadata": {{ "task": "vlm" }}
}}"#,
model_id
);
fs::write(model_dir.join("model_metadata.json"), &metadata).unwrap();
fs::write(model_dir.join("model.gguf"), b"fake language model").unwrap();
fs::write(
model_dir.join("mmproj-model.gguf"),
b"fake vision projector",
)
.unwrap();
let mut bundle = XyBundle::new(model_id, "1.0", "universal");
bundle
.add_file(model_dir.join("model_metadata.json"))
.unwrap();
bundle.add_file(model_dir.join("model.gguf")).unwrap();
bundle
.add_file(model_dir.join("mmproj-model.gguf"))
.unwrap();
let bundle_path = temp_dir.path().join(format!("{}.xyb", model_id));
bundle.write(&bundle_path).unwrap();
bundle_path
}
#[test]
fn test_extraction_dir_path() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let extract_dir = manager.extraction_dir("test-model");
assert!(extract_dir.to_string_lossy().contains("extracted"));
assert!(extract_dir.to_string_lossy().contains("test-model"));
}
#[test]
fn test_is_extracted_false_when_not_extracted() {
let temp_dir = TempDir::new().unwrap();
let manager = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
assert!(!manager.is_extracted("nonexistent-model"));
}
#[test]
fn test_ensure_extracted_creates_directory() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let bundle_path = create_test_bundle(&temp_dir, "test-extraction-model");
let extract_dir = manager.ensure_extracted(&bundle_path).unwrap();
assert!(extract_dir.exists());
assert!(extract_dir.join("model_metadata.json").exists());
assert!(extract_dir.join("model.onnx").exists());
}
#[test]
fn clear_leaves_cache_loadable_for_the_next_extraction() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let mut manager = CacheManager::with_dir(cache_dir.clone()).unwrap();
let bundle_path = create_test_bundle(&temp_dir, "reload-model");
manager.ensure_extracted(&bundle_path).unwrap();
assert!(manager.is_extracted("reload-model"));
let removed = manager.clear().unwrap();
assert_eq!(
removed, 2,
"clear() should count the registry + extracted roots"
);
assert!(
cache_dir.exists(),
"registry root should be recreated after clear()"
);
assert!(!manager.is_extracted("reload-model"));
assert!(manager.list_extracted_model_ids().is_empty());
let extract_dir = manager.ensure_extracted(&bundle_path).unwrap();
assert!(extract_dir.join("model_metadata.json").exists());
assert!(extract_dir.join("model.onnx").exists());
assert!(manager.is_extracted("reload-model"));
}
#[test]
fn test_ensure_extracted_repairs_partial_vlm_extraction() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let bundle_path = create_vlm_test_bundle(&temp_dir, "vlm-bundle-model");
let partial_dir = manager.extraction_dir("vlm-bundle-model");
fs::create_dir_all(&partial_dir).unwrap();
let bundle = XyBundle::load(&bundle_path).unwrap();
let metadata_json = bundle.get_metadata_json().unwrap().unwrap();
fs::write(partial_dir.join("model_metadata.json"), metadata_json).unwrap();
assert!(!partial_dir.join("model.gguf").exists());
assert!(!partial_dir.join("mmproj-model.gguf").exists());
let extract_dir = manager.ensure_extracted(&bundle_path).unwrap();
assert_eq!(extract_dir, partial_dir);
assert!(extract_dir.join("model_metadata.json").exists());
assert!(extract_dir.join("model.gguf").exists());
assert!(extract_dir.join("mmproj-model.gguf").exists());
}
#[test]
fn test_ensure_extracted_is_idempotent() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let bundle_path = create_test_bundle(&temp_dir, "idempotent-model");
let dir1 = manager.ensure_extracted(&bundle_path).unwrap();
let dir2 = manager.ensure_extracted(&bundle_path).unwrap();
assert_eq!(dir1, dir2);
assert!(dir1.join("model_metadata.json").exists());
}
#[test]
fn test_ensure_extracted_with_id_skips_when_exists() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let bundle_path = create_test_bundle(&temp_dir, "known-id-model");
let dir1 = manager
.ensure_extracted_with_id(&bundle_path, "known-id-model")
.unwrap();
assert!(dir1.join("model_metadata.json").exists());
let fake_path = temp_dir.path().join("nonexistent.xyb");
let dir2 = manager
.ensure_extracted_with_id(&fake_path, "known-id-model")
.unwrap();
assert_eq!(dir1, dir2);
}
#[test]
fn test_ensure_extracted_error_on_missing_bundle() {
let temp_dir = TempDir::new().unwrap();
let manager = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
let result = manager.ensure_extracted(Path::new("/nonexistent/bundle.xyb"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_is_extracted_true_after_extraction() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("cache").join("models");
fs::create_dir_all(&cache_dir).unwrap();
let manager = CacheManager::with_dir(cache_dir).unwrap();
let bundle_path = create_test_bundle(&temp_dir, "check-extracted-model");
assert!(!manager.is_extracted("check-extracted-model"));
manager.ensure_extracted(&bundle_path).unwrap();
assert!(manager.is_extracted("check-extracted-model"));
}
}