modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! Serde types for `registry.json`.
//!
//! Forward compatibility rules (part of the on-disk convention):
//! - every object carries a flattened `extra` map, so fields written by newer
//!   versions survive a read-modify-write cycle by an older version;
//! - readers MUST refuse a `schema_version` greater than [`SCHEMA_VERSION`];
//! - writers never downgrade `schema_version`.

use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

/// The highest `registry.json` schema version this build reads and writes.
pub const SCHEMA_VERSION: u64 = 1;

/// Extra-fields map used for forward compatibility on every schema object.
pub type ExtraFields = BTreeMap<String, serde_json::Value>;

/// A content-addressed model identity: `"sha256:<64 hex chars>"`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ModelId(pub String);

impl ModelId {
    /// Build an id from a sha256 hex digest.
    pub fn from_sha256_hex(hex: &str) -> Self {
        ModelId(format!("sha256:{hex}"))
    }

    /// The bare hex digest, if this id uses the `sha256:` scheme.
    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)
    }
}

/// Which ecosystem a model file location belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Ecosystem {
    /// Ollama's content-addressed blob store (`~/.ollama/models`).
    Ollama,
    /// LM Studio's `publisher/repo/file.gguf` tree.
    Lmstudio,
    /// The Hugging Face hub cache (`~/.cache/huggingface/hub`).
    HfCache,
    /// Jan's data folder.
    Jan,
    /// GPT4All's models directory.
    Gpt4all,
    /// A user-configured custom directory.
    Custom,
    /// The modelshelf shared store itself.
    Shelf,
}

impl Ecosystem {
    /// Whether modelshelf is allowed to modify files inside this ecosystem.
    ///
    /// Ollama and the HF cache manage their own internal structure; we adopt
    /// files *from* them (hardlink into the store) but never rewrite anything
    /// inside them. This is a safety rule of the convention, not a heuristic.
    pub fn is_writable(&self) -> bool {
        match self {
            Ecosystem::Ollama | Ecosystem::HfCache => false,
            Ecosystem::Lmstudio
            | Ecosystem::Jan
            | Ecosystem::Gpt4all
            | Ecosystem::Custom
            | Ecosystem::Shelf => true,
        }
    }
}

/// How a location relates to the canonical store copy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LocationRole {
    /// An independent copy we do not own.
    External,
    /// A hardlink to the canonical store blob.
    Hardlink,
    /// A symlink to the canonical store blob.
    Symlink,
    /// The canonical copy inside the shelf's `blobs/` directory.
    Store,
}

/// One known on-disk location of a model file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Location {
    /// Absolute path of the file.
    pub path: PathBuf,
    /// Which ecosystem the path belongs to.
    pub ecosystem: Ecosystem,
    /// Relationship to the canonical store copy.
    pub role: LocationRole,
    /// RFC 3339 timestamp of when a scan last confirmed this location.
    pub last_seen: String,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

/// Metadata extracted from a GGUF file header.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GgufMeta {
    /// `general.name` from the GGUF metadata.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub general_name: Option<String>,
    /// `general.architecture` (e.g. `llama`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub architecture: Option<String>,
    /// Human-readable quantization label (e.g. `Q4_K_M`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub quantization: Option<String>,
    /// Total parameter count, if recorded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parameter_count: Option<u64>,
    /// Maximum context length, if recorded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_length: Option<u64>,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

/// Where a model came from, for update checks.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Source {
    /// Source kind; currently `"huggingface"`.
    pub kind: String,
    /// Repository id (e.g. `bartowski/Meta-Llama-3.1-8B-Instruct-GGUF`).
    pub repo: String,
    /// File name within the repository.
    pub filename: String,
    /// The revision (commit sha) the local copy corresponds to, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
    /// RFC 3339 timestamp of when this mapping was established.
    pub resolved_at: String,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

/// An application's declaration that it uses a model.
///
/// References keep models alive: `gc` never removes a store blob that has
/// refs, and dedup reporting uses them to explain which apps depend on a file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppRef {
    /// Reverse-DNS style application id (e.g. `com.example.notetaker`).
    pub app_id: String,
    /// App-local alias for this model (e.g. `summarizer`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
    /// RFC 3339 timestamp of when the ref was added.
    pub added_at: String,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

/// One model in the registry, identified by content hash.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelEntry {
    /// Content-addressed identity (`sha256:<hex>`).
    pub id: ModelId,
    /// File format; currently always `"gguf"`.
    pub format: String,
    /// File size in bytes.
    pub size_bytes: u64,
    /// Best available human-readable name.
    pub display_name: String,
    /// GGUF metadata, if the header was parsed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gguf: Option<GgufMeta>,
    /// Remote source mapping for update checks, if known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<Source>,
    /// Path of the canonical copy, relative to the shelf home
    /// (e.g. `blobs/sha256-<hex>`), if the model has been adopted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub store_path: Option<String>,
    /// Every known on-disk location.
    #[serde(default)]
    pub locations: Vec<Location>,
    /// Applications that declared they use this model.
    #[serde(default)]
    pub refs: Vec<AppRef>,
    /// RFC 3339 timestamp of first discovery.
    pub first_seen: String,
    /// RFC 3339 timestamp of the last time the content hash was verified.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_verified: Option<String>,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

/// The root object of `registry.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Registry {
    /// Schema version; see [`SCHEMA_VERSION`].
    pub schema_version: u64,
    /// `name/version` of the writer, informational only.
    pub generated_by: String,
    /// RFC 3339 timestamp of the last write.
    pub updated_at: String,
    /// All known models.
    #[serde(default)]
    pub models: Vec<ModelEntry>,
    /// Forward-compatibility escape hatch.
    #[serde(flatten)]
    pub extra: ExtraFields,
}

impl Registry {
    /// A new, empty registry stamped with the current writer version.
    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(),
        }
    }

    /// Find a model by exact id.
    pub fn find(&self, id: &ModelId) -> Option<&ModelEntry> {
        self.models.iter().find(|m| &m.id == id)
    }

    /// Find a model by exact id, mutably.
    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(&reg).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());
    }
}