modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! Shelf home resolution and the standard directory layout.

use std::path::{Path, PathBuf};

/// Environment variable that overrides the shelf home directory.
pub const HOME_ENV: &str = "MODELSHELF_HOME";

/// Resolved locations of everything inside a shelf home directory.
///
/// The layout is part of the on-disk convention (see `docs/SPEC.md`):
///
/// ```text
/// <home>/
/// ├── registry.json
/// ├── registry.lock
/// ├── blobs/
/// ├── downloads/
/// ├── journal/
/// └── config.json
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShelfPaths {
    home: PathBuf,
}

impl ShelfPaths {
    /// Resolve the shelf home: an explicit path wins, then `$MODELSHELF_HOME`,
    /// then the platform default `~/.modelshelf`.
    ///
    /// Returns `None` only if no explicit path was given, the environment
    /// variable is unset, and the user's home directory cannot be determined.
    pub fn resolve(explicit: Option<&Path>) -> Option<Self> {
        if let Some(p) = explicit {
            return Some(Self::at(p));
        }
        if let Some(env) = std::env::var_os(HOME_ENV) {
            if !env.is_empty() {
                return Some(Self::at(PathBuf::from(env)));
            }
        }
        dirs::home_dir().map(|h| Self::at(h.join(".modelshelf")))
    }

    /// A shelf rooted at exactly `home` (no environment lookup).
    pub fn at(home: impl Into<PathBuf>) -> Self {
        Self { home: home.into() }
    }

    /// The shelf home directory.
    pub fn home(&self) -> &Path {
        &self.home
    }

    /// Path of the registry manifest.
    pub fn registry_json(&self) -> PathBuf {
        self.home.join("registry.json")
    }

    /// Path of the zero-byte advisory lock file. Locks are always taken on
    /// this file, never on `registry.json` itself, so the atomic rename that
    /// replaces the manifest does not interact with lock state.
    pub fn lock_file(&self) -> PathBuf {
        self.home.join("registry.lock")
    }

    /// Directory of the canonical content-addressed model store.
    pub fn blobs_dir(&self) -> PathBuf {
        self.home.join("blobs")
    }

    /// Path of a blob file for the given sha256 hex digest.
    pub fn blob_file(&self, sha256_hex: &str) -> PathBuf {
        self.blobs_dir().join(format!("sha256-{sha256_hex}"))
    }

    /// Directory holding in-progress downloads and their resume state.
    pub fn downloads_dir(&self) -> PathBuf {
        self.home.join("downloads")
    }

    /// Directory holding dedup operation journals (for undo).
    pub fn journal_dir(&self) -> PathBuf {
        self.home.join("journal")
    }

    /// Path of the user configuration file.
    pub fn config_json(&self) -> PathBuf {
        self.home.join("config.json")
    }

    /// Path of the cached model catalog (see [`crate::catalog`]). A private
    /// client-side cache: other shelf tools may ignore it.
    pub fn catalog_json(&self) -> PathBuf {
        self.home.join("catalog.json")
    }

    /// Create the home directory and all standard subdirectories.
    pub fn ensure_layout(&self) -> crate::Result<()> {
        for dir in [
            self.home.clone(),
            self.blobs_dir(),
            self.downloads_dir(),
            self.journal_dir(),
        ] {
            std::fs::create_dir_all(&dir).map_err(|e| crate::Error::io(&dir, e))?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn explicit_path_wins() {
        let p = ShelfPaths::resolve(Some(Path::new("/x/y"))).unwrap();
        assert_eq!(p.home(), Path::new("/x/y"));
        assert_eq!(p.registry_json(), Path::new("/x/y/registry.json"));
        assert_eq!(p.blob_file("ab12"), Path::new("/x/y/blobs/sha256-ab12"));
    }

    #[test]
    fn ensure_layout_creates_subdirs() {
        let tmp = tempfile::tempdir().unwrap();
        let p = ShelfPaths::at(tmp.path().join("shelf"));
        p.ensure_layout().unwrap();
        assert!(p.blobs_dir().is_dir());
        assert!(p.downloads_dir().is_dir());
        assert!(p.journal_dir().is_dir());
    }
}