use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use reqwest::blocking::Client;
use super::cache_key::{
LEAP_BUNDLES_API_URL, LeapBundleEntry, cache_relative_segments, parse_leap_bundles,
};
use super::download;
use crate::session::CeraError;
pub trait DownloadProgress: Send + Sync + std::fmt::Debug {
fn on_progress(&self, url: &str, bytes_downloaded: u64, total_bytes: Option<u64>);
}
#[derive(Clone, Debug)]
pub struct BundleRepo {
store_dir: PathBuf,
http_client: Client,
head_client: Client,
progress: Option<Arc<dyn DownloadProgress>>,
}
impl BundleRepo {
pub fn new(store_dir: impl Into<PathBuf>) -> Self {
let head_client = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| Client::new());
Self {
store_dir: store_dir.into(),
http_client: Client::new(),
head_client,
progress: None,
}
}
pub fn with_progress(
store_dir: impl Into<PathBuf>,
progress: Arc<dyn DownloadProgress>,
) -> Self {
let mut repo = Self::new(store_dir);
repo.progress = Some(progress);
repo
}
pub fn store_dir(&self) -> &Path {
&self.store_dir
}
pub fn progress(&self) -> Option<Arc<dyn DownloadProgress>> {
self.progress.clone()
}
pub fn cache_size(&self) -> Result<u64, CeraError> {
let mut total = 0u64;
Self::walk_dir_size(&self.store_dir, &mut total)?;
Ok(total)
}
fn walk_dir_size(dir: &Path, total: &mut u64) -> Result<(), CeraError> {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
Self::walk_dir_size(&entry.path(), total)?;
} else if file_type.is_file()
&& let Ok(meta) = entry.metadata()
{
*total = total.saturating_add(meta.len());
}
}
Ok(())
}
pub fn clear_cache(&self) -> Result<(), CeraError> {
match fs::remove_dir_all(&self.store_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
}
fs::create_dir_all(&self.store_dir)?;
Ok(())
}
pub fn resolve_url(
&self,
url: &str,
expected_sha256: Option<&str>,
) -> Result<PathBuf, CeraError> {
let dest = self.path_for_url(url)?;
let head = if expected_sha256.is_some() {
download::HeadInfo {
content_length: None,
linked_sha256: None,
}
} else {
download::head_info(&self.head_client, url)
};
if dest.exists() && self.cache_hit_valid(&dest, url, expected_sha256, &head) {
return Ok(dest);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let download_hash = expected_sha256
.map(|s| s.to_ascii_lowercase())
.or_else(|| head.linked_sha256.clone());
tracing::info!(
target: "cera::bundle",
url,
dest = %dest.display(),
hash_source = match (expected_sha256.is_some(), head.linked_sha256.is_some()) {
(true, _) => "caller",
(false, true) => "x-linked-etag",
(false, false) => "unverified",
},
"downloading bundle file"
);
download::download_to(
&self.http_client,
url,
&dest,
download_hash.as_deref(),
head.content_length,
self.progress.as_deref(),
)?;
Ok(dest)
}
fn cache_hit_valid(
&self,
dest: &Path,
url: &str,
expected_sha256: Option<&str>,
head: &download::HeadInfo,
) -> bool {
let expected_hash = expected_sha256
.map(|s| s.to_ascii_lowercase())
.or_else(|| head.linked_sha256.clone());
if let Some(exp_hash) = expected_hash {
return hash_matches(dest, url, &exp_hash);
}
if let Some(exp_len) = head.content_length {
let actual = fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
if actual == exp_len {
return true;
}
tracing::info!(
target: "cera::bundle",
url,
expected = exp_len,
actual,
"cached file size mismatch; re-downloading"
);
return false;
}
true
}
fn path_for_url(&self, url: &str) -> Result<PathBuf, CeraError> {
let mut out = self.store_dir.clone();
out.extend(cache_relative_segments(url)?);
Ok(out)
}
}
fn hash_matches(dest: &Path, url: &str, expected_hash: &str) -> bool {
let expected = expected_hash.to_ascii_lowercase();
if let Some(cached) = download::read_sidecar(dest) {
if cached == expected {
return true;
}
tracing::info!(
target: "cera::bundle",
url,
expected = %expected,
actual = %cached,
"cached file sidecar hash mismatch; re-downloading"
);
return false;
}
match download::sha256_file(dest) {
Ok(actual) if actual == expected => {
download::write_sidecar(dest, &actual);
true
}
Ok(actual) => {
tracing::info!(
target: "cera::bundle",
url,
expected = %expected,
actual = %actual,
"cached file hash mismatch; re-downloading"
);
false
}
Err(e) => {
tracing::warn!(
target: "cera::bundle",
url,
error = %e,
"failed to hash cached file; re-downloading"
);
false
}
}
}
const LIST_BUNDLES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub fn list_leap_bundles() -> Result<Vec<LeapBundleEntry>, CeraError> {
let client = Client::builder()
.timeout(LIST_BUNDLES_TIMEOUT)
.build()
.map_err(|e| CeraError::Backend(format!("list-bundles client build failed: {e}")))?;
let body = client
.get(LEAP_BUNDLES_API_URL)
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.text())
.map_err(|e| CeraError::Backend(format!("list-bundles HTTP failed: {e}")))?;
let mut entries = parse_leap_bundles(&body)?;
if !entries.iter().any(|e| e.name == "LFM2.5-VL-3B-GGUF") {
entries.push(LeapBundleEntry {
name: "LFM2.5-VL-3B-GGUF".to_string(),
quants: vec![
"BF16".to_string(),
"F16".to_string(),
"Q4_0".to_string(),
"Q4_K_M".to_string(),
"Q5_K_M".to_string(),
"Q6_K".to_string(),
"Q8_0".to_string(),
],
});
entries.sort_by(|a, b| a.name.cmp(&b.name));
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("cera-bundle-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn cache_size_is_zero_when_store_dir_missing() {
let dir = unique_test_dir("size-empty");
let repo = BundleRepo::new(&dir);
assert!(
!dir.exists(),
"BundleRepo::new must not eagerly create store_dir"
);
assert_eq!(repo.cache_size().unwrap(), 0);
}
#[test]
fn cache_size_sums_nested_files() {
let dir = unique_test_dir("size-sum");
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/A")).unwrap();
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/B")).unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/A/file1"), vec![0u8; 1024]).unwrap();
fs::write(
dir.join("huggingface.co/LiquidAI/A/file1.sha256"),
b"deadbeef",
)
.unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/B/file2"), vec![0u8; 4096]).unwrap();
let repo = BundleRepo::new(&dir);
assert_eq!(repo.cache_size().unwrap(), 1024 + 8 + 4096);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn clear_cache_is_idempotent_on_missing_store_dir() {
let dir = unique_test_dir("clear-empty");
let repo = BundleRepo::new(&dir);
assert!(!dir.exists());
repo.clear_cache().unwrap();
assert!(!dir.exists());
}
#[test]
fn clear_cache_wipes_files_but_keeps_store_dir() {
let dir = unique_test_dir("clear-wipe");
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/A")).unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/A/file"), vec![0u8; 100]).unwrap();
let repo = BundleRepo::new(&dir);
assert_eq!(repo.cache_size().unwrap(), 100);
repo.clear_cache().unwrap();
assert!(dir.exists(), "store_dir must survive clear_cache");
assert_eq!(repo.cache_size().unwrap(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn hash_matches_uses_sidecar_fast_path() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
std::fs::write(&dest, b"").unwrap();
let hex = "0123456789abcdef".repeat(4);
assert_eq!(hex.len(), 64);
std::fs::write(download::sidecar_path(&dest), &hex).unwrap();
assert!(hash_matches(&dest, "https://example.com/x", &hex));
assert!(hash_matches(
&dest,
"https://example.com/x",
&hex.to_uppercase()
));
let wrong = "f".repeat(64);
assert!(!hash_matches(&dest, "https://example.com/x", &wrong));
}
#[test]
fn hash_matches_full_rehash_when_no_sidecar() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.bin");
std::fs::write(&dest, b"hello").unwrap();
let correct = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
let wrong = "0".repeat(64);
assert!(hash_matches(&dest, "https://example.com/x", correct));
assert!(!hash_matches(&dest, "https://example.com/x", &wrong));
}
}