use std::io::Read;
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::paths::ShelfPaths;
use crate::{Error, Result};
pub const CATALOG_URL_ENV: &str = "MODELSHELF_CATALOG_URL";
pub const DEFAULT_CATALOG_URL: &str =
"https://raw.githubusercontent.com/KOIYAL/modelshelf/main/crates/modelshelf/catalog/catalog.json";
pub const REFRESH_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_CATALOG_BYTES: u64 = 2 * 1024 * 1024;
const EMBEDDED: &str = include_str!("../catalog/catalog.json");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtraFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
pub filename: String,
pub file_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CatalogEntry {
pub name: String,
#[serde(default = "default_task")]
pub task: String,
pub repo: String,
pub filename: String,
#[serde(default = "default_format")]
pub format: String,
pub quant: String,
pub file_bytes: u64,
pub params_b: f64,
#[serde(default)]
pub japanese: bool,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub deprecated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_files: Vec<ExtraFile>,
}
fn default_task() -> String {
"chat".to_owned()
}
fn default_format() -> String {
"gguf".to_owned()
}
impl CatalogEntry {
pub fn total_bytes(&self) -> u64 {
self.file_bytes + self.extra_files.iter().map(|f| f.file_bytes).sum::<u64>()
}
}
const ALLOWED_EXTENSIONS: [&str; 3] = [".gguf", ".bin", ".onnx"];
fn allowed_extension(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
ALLOWED_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Catalog {
pub catalog_version: u64,
pub updated: String,
pub entries: Vec<CatalogEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CatalogOrigin {
Embedded,
Cached,
}
impl Catalog {
pub fn embedded() -> Catalog {
serde_json::from_str(EMBEDDED).expect("embedded catalog.json must parse")
}
pub fn entry(&self, name: &str) -> Option<&CatalogEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn validate(&self) -> Result<()> {
let fail = |msg: String| Err(Error::InvalidSpec(format!("catalog: {msg}")));
if self.catalog_version == 0 {
return fail("catalog_version must be >= 1".into());
}
if self.entries.is_empty() {
return fail("no entries".into());
}
let mut names = std::collections::HashSet::new();
for e in &self.entries {
if !names.insert(e.name.as_str()) {
return fail(format!("duplicate entry name {:?}", e.name));
}
if e.name.is_empty() {
return fail("empty entry name".into());
}
if e.task.is_empty() {
return fail(format!("{}: empty task", e.name));
}
if e.repo.split('/').filter(|p| !p.is_empty()).count() != 2 {
return fail(format!(
"{}: repo must be org/name, got {:?}",
e.name, e.repo
));
}
if !allowed_extension(&e.filename) {
return fail(format!(
"{}: filename must end in one of {ALLOWED_EXTENSIONS:?}",
e.name
));
}
if e.file_bytes == 0 {
return fail(format!("{}: file_bytes must be > 0", e.name));
}
for extra in &e.extra_files {
if !allowed_extension(&extra.filename) {
return fail(format!(
"{}: extra file must end in one of {ALLOWED_EXTENSIONS:?}",
e.name
));
}
if extra.file_bytes == 0 {
return fail(format!("{}: extra file_bytes must be > 0", e.name));
}
if let Some(repo) = &extra.repo {
if repo.split('/').filter(|p| !p.is_empty()).count() != 2 {
return fail(format!("{}: extra repo must be org/name", e.name));
}
}
}
}
Ok(())
}
}
pub fn load(paths: &ShelfPaths) -> (Catalog, CatalogOrigin) {
let embedded = Catalog::embedded();
let cached = std::fs::read_to_string(paths.catalog_json())
.ok()
.and_then(|s| serde_json::from_str::<Catalog>(&s).ok())
.filter(|c| c.validate().is_ok());
match cached {
Some(c) if c.catalog_version > embedded.catalog_version => (c, CatalogOrigin::Cached),
_ => (embedded, CatalogOrigin::Embedded),
}
}
pub fn fetch_and_cache(paths: &ShelfPaths, url: Option<&str>) -> Result<Catalog> {
let url = match url {
Some(u) => u.to_owned(),
None => std::env::var(CATALOG_URL_ENV)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_owned()),
};
let agent = ureq::AgentBuilder::new()
.user_agent(concat!("modelshelf/", env!("CARGO_PKG_VERSION")))
.timeout(FETCH_TIMEOUT)
.build();
let response = agent
.get(&url)
.call()
.map_err(|e| Error::Network(format!("catalog fetch failed: {e}")))?;
let mut body = String::new();
response
.into_reader()
.take(MAX_CATALOG_BYTES)
.read_to_string(&mut body)
.map_err(|e| Error::Network(format!("catalog fetch failed while reading: {e}")))?;
let catalog: Catalog = serde_json::from_str(&body)
.map_err(|e| Error::Network(format!("fetched catalog is not valid JSON: {e}")))?;
catalog.validate()?;
crate::registry::lock::write_atomic(&paths.catalog_json(), body.as_bytes())?;
Ok(catalog)
}
pub fn should_refresh(cache_mtime: Option<SystemTime>, now: SystemTime) -> bool {
match cache_mtime {
None => true,
Some(t) => now
.duration_since(t)
.map(|age| age > REFRESH_TTL)
.unwrap_or(false), }
}
pub fn refresh_if_stale(paths: &ShelfPaths, url: Option<&str>) {
let cache = paths.catalog_json();
let mtime = std::fs::metadata(&cache)
.ok()
.and_then(|m| m.modified().ok());
if !should_refresh(mtime, SystemTime::now()) {
return;
}
if let Err(e) = fetch_and_cache(paths, url) {
tracing::debug!("catalog refresh skipped: {e}");
let stamped = std::fs::File::options()
.append(true)
.open(&cache)
.and_then(|f| f.set_modified(SystemTime::now()));
if stamped.is_err() {
let _ = crate::registry::lock::write_atomic(&cache, EMBEDDED.as_bytes());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shelf_paths(tmp: &tempfile::TempDir) -> ShelfPaths {
let p = ShelfPaths::at(tmp.path().join("shelf"));
p.ensure_layout().unwrap();
p
}
fn catalog_json(version: u64, name: &str) -> String {
format!(
r#"{{"catalog_version": {version}, "updated": "2026-07-10T00:00:00Z",
"entries": [{{"name": "{name}", "repo": "org/repo",
"filename": "m.gguf", "quant": "Q4_K_M",
"file_bytes": 1000, "params_b": 1.0}}]}}"#
)
}
#[test]
fn embedded_catalog_parses_and_validates() {
let c = Catalog::embedded();
c.validate().unwrap();
assert!(c.catalog_version >= 1);
assert!(c.entries.len() >= 8, "curated ladder shrank unexpectedly");
assert!(c
.entries
.iter()
.any(|e| e.file_bytes < 2 * 1024 * 1024 * 1024));
}
#[test]
fn validate_rejects_structural_problems() {
let mut dup = Catalog::embedded();
let clone = dup.entries[0].clone();
dup.entries.push(clone);
assert!(dup.validate().is_err(), "duplicate names must fail");
let mut bad_repo = Catalog::embedded();
bad_repo.entries[0].repo = "no-slash".into();
assert!(bad_repo.validate().is_err());
let mut bad_ext = Catalog::embedded();
bad_ext.entries[0].filename = "weights.safetensors".into();
assert!(bad_ext.validate().is_err());
let mut zero = Catalog::embedded();
zero.entries[0].file_bytes = 0;
assert!(zero.validate().is_err());
let mut v0 = Catalog::embedded();
v0.catalog_version = 0;
assert!(v0.validate().is_err());
}
#[test]
fn load_prefers_strictly_newer_valid_cache() {
let tmp = tempfile::tempdir().unwrap();
let paths = shelf_paths(&tmp);
let embedded_version = Catalog::embedded().catalog_version;
assert_eq!(load(&paths).1, CatalogOrigin::Embedded);
std::fs::write(
paths.catalog_json(),
catalog_json(embedded_version + 5, "newer"),
)
.unwrap();
let (c, origin) = load(&paths);
assert_eq!(origin, CatalogOrigin::Cached);
assert_eq!(c.catalog_version, embedded_version + 5);
std::fs::write(paths.catalog_json(), catalog_json(embedded_version, "same")).unwrap();
assert_eq!(load(&paths).1, CatalogOrigin::Embedded);
std::fs::write(paths.catalog_json(), "{ not json").unwrap();
assert_eq!(load(&paths).1, CatalogOrigin::Embedded);
}
#[test]
fn should_refresh_honors_ttl() {
let now = SystemTime::now();
assert!(should_refresh(None, now));
assert!(!should_refresh(Some(now), now));
assert!(!should_refresh(Some(now - Duration::from_secs(3600)), now));
assert!(should_refresh(
Some(now - (REFRESH_TTL + Duration::from_secs(1))),
now
));
assert!(!should_refresh(Some(now + Duration::from_secs(3600)), now));
}
#[test]
fn fetch_and_cache_stores_valid_and_rejects_invalid() {
let tmp = tempfile::tempdir().unwrap();
let paths = shelf_paths(&tmp);
let server = httpmock::MockServer::start();
let good = server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/good.json");
then.status(200).body(catalog_json(42, "fresh"));
});
let fetched =
fetch_and_cache(&paths, Some(&format!("{}/good.json", server.base_url()))).unwrap();
good.assert_hits(1);
assert_eq!(fetched.catalog_version, 42);
let (loaded, origin) = load(&paths);
assert_eq!(origin, CatalogOrigin::Cached);
assert_eq!(loaded.catalog_version, 42);
server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/bad.json");
then.status(200).body("<html>not a catalog</html>");
});
let err =
fetch_and_cache(&paths, Some(&format!("{}/bad.json", server.base_url()))).unwrap_err();
assert_eq!(err.kind(), crate::ErrorKind::Network);
assert_eq!(
load(&paths).0.catalog_version,
42,
"cache must be untouched"
);
server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/empty.json");
then.status(200).body(
r#"{"catalog_version": 99, "updated": "2026-07-10T00:00:00Z", "entries": []}"#,
);
});
assert!(
fetch_and_cache(&paths, Some(&format!("{}/empty.json", server.base_url()))).is_err()
);
assert_eq!(load(&paths).0.catalog_version, 42);
server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/missing.json");
then.status(404);
});
let err = fetch_and_cache(&paths, Some(&format!("{}/missing.json", server.base_url())))
.unwrap_err();
assert_eq!(err.kind(), crate::ErrorKind::Network);
}
#[test]
fn refresh_if_stale_is_silent_and_rate_limited_on_failure() {
let tmp = tempfile::tempdir().unwrap();
let paths = shelf_paths(&tmp);
let server = httpmock::MockServer::start();
let failing = server.mock(|when, then| {
when.method(httpmock::Method::GET).path("/down.json");
then.status(500);
});
let url = format!("{}/down.json", server.base_url());
refresh_if_stale(&paths, Some(&url));
failing.assert_hits(1);
assert!(
paths.catalog_json().is_file(),
"failure must seed the cache"
);
let (c, origin) = load(&paths);
assert_eq!(origin, CatalogOrigin::Embedded);
assert_eq!(c.catalog_version, Catalog::embedded().catalog_version);
refresh_if_stale(&paths, Some(&url));
failing.assert_hits(1);
}
}