modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! The shared content-addressed blob store (`<home>/blobs/sha256-<hex>`).
//!
//! Blobs enter the store either by *adoption* (hardlinking an existing local
//! copy — zero extra disk) or by *promotion* of a completed download. The
//! store is the canonical copy that dedup links everything else to.

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

use crate::paths::ShelfPaths;
use crate::{Error, Result};

/// Bring an existing local file into the store without copying if possible.
///
/// Hardlink first (same filesystem: instant, no extra space); fall back to a
/// copy via a temp file when linking fails (different filesystem, or a
/// filesystem without hardlinks). The source file is never modified.
pub fn adopt(paths: &ShelfPaths, src: &Path, sha256_hex: &str) -> Result<PathBuf> {
    let blob = paths.blob_file(sha256_hex);
    if blob.is_file() {
        return Ok(blob);
    }
    let blobs_dir = paths.blobs_dir();
    std::fs::create_dir_all(&blobs_dir).map_err(|e| Error::io(&blobs_dir, e))?;

    if std::fs::hard_link(src, &blob).is_ok() {
        tracing::debug!(src = %src.display(), blob = %blob.display(), "adopted via hardlink");
        return Ok(blob);
    }

    // Cross-device or no-hardlink filesystem: copy, then rename into place
    // so a crash never leaves a partial blob under its final name.
    let tmp = blobs_dir.join(format!("sha256-{sha256_hex}.tmp-{}", std::process::id()));
    let result = (|| -> Result<()> {
        std::fs::copy(src, &tmp).map_err(|e| Error::io(&tmp, e))?;
        std::fs::rename(&tmp, &blob).map_err(|e| Error::io(&blob, e))?;
        Ok(())
    })();
    if result.is_err() {
        let _ = std::fs::remove_file(&tmp);
    }
    result?;
    tracing::debug!(src = %src.display(), blob = %blob.display(), "adopted via copy");
    Ok(blob)
}

/// Move a completed, verified download into the store. Same filesystem by
/// construction (`downloads/` and `blobs/` share the shelf home), so this is
/// an atomic rename.
pub fn promote_download(paths: &ShelfPaths, part_file: &Path, sha256_hex: &str) -> Result<PathBuf> {
    let blob = paths.blob_file(sha256_hex);
    if blob.is_file() {
        // Someone else finished first; the download is redundant.
        let _ = std::fs::remove_file(part_file);
        return Ok(blob);
    }
    let blobs_dir = paths.blobs_dir();
    std::fs::create_dir_all(&blobs_dir).map_err(|e| Error::io(&blobs_dir, e))?;
    std::fs::rename(part_file, &blob).map_err(|e| Error::io(&blob, e))?;
    Ok(blob)
}

/// Every blob currently in the store, as `(sha256_hex, path)`.
pub fn list_blobs(paths: &ShelfPaths) -> Result<Vec<(String, PathBuf)>> {
    let dir = paths.blobs_dir();
    let mut out = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(e) => return Err(Error::io(&dir, e)),
    };
    for entry in entries.filter_map(|e| e.ok()) {
        let name = entry.file_name().to_string_lossy().into_owned();
        if let Some(hex) = name.strip_prefix("sha256-") {
            if crate::scan::is_sha256_hex(hex) {
                out.push((hex.to_owned(), entry.path()));
            }
        }
    }
    Ok(out)
}

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

    #[test]
    fn adopt_hardlinks_on_same_filesystem() {
        let tmp = tempfile::tempdir().unwrap();
        let paths = ShelfPaths::at(tmp.path().join("shelf"));
        paths.ensure_layout().unwrap();
        let src = tmp.path().join("model.gguf");
        std::fs::write(&src, b"CONTENT").unwrap();

        let blob = adopt(&paths, &src, "ab".repeat(32).as_str()).unwrap();
        assert!(blob.is_file());
        #[cfg(unix)]
        assert!(crate::identity::same_inode(&src, &blob));
        // Idempotent.
        assert_eq!(adopt(&paths, &src, "ab".repeat(32).as_str()).unwrap(), blob);
    }

    #[test]
    fn promote_moves_part_into_store() {
        let tmp = tempfile::tempdir().unwrap();
        let paths = ShelfPaths::at(tmp.path().join("shelf"));
        paths.ensure_layout().unwrap();
        let part = paths.downloads_dir().join("x.part");
        std::fs::write(&part, b"DL").unwrap();
        let blob = promote_download(&paths, &part, "cd".repeat(32).as_str()).unwrap();
        assert!(blob.is_file());
        assert!(!part.exists());
        assert_eq!(std::fs::read(&blob).unwrap(), b"DL");
    }
}