use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u64 = 1;
pub type ExtraFields = BTreeMap<String, serde_json::Value>;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ModelId(pub String);
impl ModelId {
pub fn from_sha256_hex(hex: &str) -> Self {
ModelId(format!("sha256:{hex}"))
}
pub fn sha256_hex(&self) -> Option<&str> {
self.0.strip_prefix("sha256:")
}
}
impl fmt::Display for ModelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Ecosystem {
Ollama,
Lmstudio,
HfCache,
Jan,
Gpt4all,
Custom,
Shelf,
}
impl Ecosystem {
pub fn is_writable(&self) -> bool {
match self {
Ecosystem::Ollama | Ecosystem::HfCache => false,
Ecosystem::Lmstudio
| Ecosystem::Jan
| Ecosystem::Gpt4all
| Ecosystem::Custom
| Ecosystem::Shelf => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LocationRole {
External,
Hardlink,
Symlink,
Store,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Location {
pub path: PathBuf,
pub ecosystem: Ecosystem,
pub role: LocationRole,
pub last_seen: String,
#[serde(flatten)]
pub extra: ExtraFields,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GgufMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub general_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub architecture: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantization: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameter_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_length: Option<u64>,
#[serde(flatten)]
pub extra: ExtraFields,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Source {
pub kind: String,
pub repo: String,
pub filename: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
pub resolved_at: String,
#[serde(flatten)]
pub extra: ExtraFields,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppRef {
pub app_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
pub added_at: String,
#[serde(flatten)]
pub extra: ExtraFields,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelEntry {
pub id: ModelId,
pub format: String,
pub size_bytes: u64,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gguf: Option<GgufMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<Source>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_path: Option<String>,
#[serde(default)]
pub locations: Vec<Location>,
#[serde(default)]
pub refs: Vec<AppRef>,
pub first_seen: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_verified: Option<String>,
#[serde(flatten)]
pub extra: ExtraFields,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Registry {
pub schema_version: u64,
pub generated_by: String,
pub updated_at: String,
#[serde(default)]
pub models: Vec<ModelEntry>,
#[serde(flatten)]
pub extra: ExtraFields,
}
impl Registry {
pub fn empty() -> Self {
Registry {
schema_version: SCHEMA_VERSION,
generated_by: format!("modelshelf/{}", env!("CARGO_PKG_VERSION")),
updated_at: crate::time_util::now_rfc3339(),
models: Vec::new(),
extra: ExtraFields::new(),
}
}
pub fn find(&self, id: &ModelId) -> Option<&ModelEntry> {
self.models.iter().find(|m| &m.id == id)
}
pub fn find_mut(&mut self, id: &ModelId) -> Option<&mut ModelEntry> {
self.models.iter_mut().find(|m| &m.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_id_roundtrip() {
let id = ModelId::from_sha256_hex("ab12");
assert_eq!(id.to_string(), "sha256:ab12");
assert_eq!(id.sha256_hex(), Some("ab12"));
}
#[test]
fn unknown_fields_survive_roundtrip() {
let json = r#"{
"schema_version": 1,
"generated_by": "othertool/9.9",
"updated_at": "2026-07-10T00:00:00Z",
"models": [],
"future_field": {"nested": [1, 2, 3]}
}"#;
let reg: Registry = serde_json::from_str(json).unwrap();
assert!(reg.extra.contains_key("future_field"));
let out = serde_json::to_string(®).unwrap();
let reparsed: Registry = serde_json::from_str(&out).unwrap();
assert_eq!(reg, reparsed);
assert!(out.contains("future_field"));
}
#[test]
fn read_only_ecosystems() {
assert!(!Ecosystem::Ollama.is_writable());
assert!(!Ecosystem::HfCache.is_writable());
assert!(Ecosystem::Lmstudio.is_writable());
assert!(Ecosystem::Custom.is_writable());
}
}