use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{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
}
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.cache_dir
.parent()
.unwrap_or(&self.cache_dir)
.join("extracted")
.join(model_id)
}
pub fn is_extracted(&self, model_id: &str) -> bool {
let extract_dir = self.extraction_dir(model_id);
extract_dir.join("model_metadata.json").exists()
}
pub fn list_extracted_model_ids(&self) -> Vec<String> {
let extracted_root = self
.cache_dir
.parent()
.unwrap_or(&self.cache_dir)
.join("extracted");
let Ok(entries) = std::fs::read_dir(&extracted_root) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().join("model_metadata.json").exists())
.filter_map(|entry| {
entry
.file_name()
.into_string()
.ok()
.filter(|name| !name.starts_with('.'))
})
.collect();
ids.sort();
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))?;
let extract_dir = self.extraction_dir(&metadata.model_id);
if extract_dir.join("model_metadata.json").exists() {
log::debug!(
"Bundle already extracted for '{}' at {}",
metadata.model_id,
extract_dir.display()
);
return Ok(extract_dir);
}
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> {
let extract_dir = self.extraction_dir(model_id);
if extract_dir.join("model_metadata.json").exists() {
log::debug!(
"Bundle already extracted for '{}' at {}",
model_id,
extract_dir.display()
);
return Ok(extract_dir);
}
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 fn clear(&mut self) -> Result<u32, SdkError> {
let count = self.entries.len() as u32;
for entry in self.entries.values() {
if entry.path.exists() {
let _ = std::fs::remove_file(&entry.path);
}
}
self.entries.clear();
Ok(count)
}
}
#[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());
}
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
}
#[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 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"));
}
}