modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! Deduplication: replace redundant model copies with hardlinks to the
//! canonical store blob.
//!
//! Safety design (part of the on-disk convention, see `docs/SPEC.md`):
//!
//! 1. **Two phases.** [`plan`](crate::Shelf::plan_dedup) is pure — it only
//!    reads. [`apply`](crate::Shelf::apply_dedup) re-verifies every action
//!    against the stat snapshot taken at plan time and aborts individual
//!    actions on any mismatch.
//! 2. **Write targets are restricted.** Only ecosystems that treat GGUFs as
//!    plain files (LM Studio, Jan, GPT4All, custom dirs) are ever modified.
//!    Ollama internals and the HF cache are read-only sources, always.
//! 3. **Atomic swaps.** The replacement link is created next to the target
//!    and moved into place with `rename`. Content is identical before and
//!    after, so no crash window exists in which the file is broken.
//! 4. **Journaled and undoable.** Every operation writes a journal entry
//!    before touching anything; `undo` re-materializes independent copies.

pub(crate) mod execute;
pub(crate) mod plan;
pub(crate) mod undo;

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::registry::{Ecosystem, ModelId};

/// How duplicate copies are replaced.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkStrategy {
    /// Hardlink (default): indistinguishable from a regular file to apps,
    /// works without privileges, requires the same filesystem.
    #[default]
    Hardlink,
    /// Symlink: visible to apps and requires Developer Mode on Windows.
    /// Explicit opt-in only.
    Symlink,
}

/// Options for [`crate::Shelf::plan_dedup`].
#[derive(Debug, Clone)]
pub struct DedupOptions {
    /// Replacement strategy.
    pub strategy: LinkStrategy,
    /// Files smaller than this are never touched (default 100 MB — tiny
    /// files are not worth any risk).
    pub min_size_bytes: u64,
    /// Files modified within this many seconds are never touched (default
    /// 600 — they may be mid-download by another tool).
    pub recent_grace_secs: u64,
}

impl Default for DedupOptions {
    fn default() -> Self {
        DedupOptions {
            strategy: LinkStrategy::default(),
            min_size_bytes: 100 * 1024 * 1024,
            recent_grace_secs: 600,
        }
    }
}

/// Stat snapshot taken at plan time and re-verified at apply time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileSnapshot {
    /// Size in bytes.
    pub size_bytes: u64,
    /// Modification time in Unix milliseconds.
    pub mtime_unix_ms: i64,
    /// Inode number (Unix only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inode: Option<u64>,
    /// Device id (Unix only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub device: Option<u64>,
}

impl FileSnapshot {
    pub(crate) fn of(path: &std::path::Path) -> crate::Result<Self> {
        let meta = std::fs::metadata(path).map_err(|e| crate::Error::io(path, e))?;
        Ok(Self::from_metadata(&meta))
    }

    pub(crate) fn from_metadata(meta: &std::fs::Metadata) -> Self {
        #[cfg(unix)]
        let (inode, device) = {
            use std::os::unix::fs::MetadataExt;
            (Some(meta.ino()), Some(meta.dev()))
        };
        #[cfg(not(unix))]
        let (inode, device) = (None, None);
        FileSnapshot {
            size_bytes: meta.len(),
            mtime_unix_ms: meta
                .modified()
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_millis() as i64)
                .unwrap_or(0),
            inode,
            device,
        }
    }
}

/// One planned replacement: `target` becomes a link to the store blob.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DedupAction {
    /// Model this action belongs to.
    pub id: ModelId,
    /// The duplicate file to replace.
    pub target: PathBuf,
    /// Which ecosystem the target lives in (always a writable one).
    pub ecosystem: Ecosystem,
    /// Stat snapshot of the target at plan time.
    pub snapshot: FileSnapshot,
    /// The canonical blob the target will point to.
    pub blob: PathBuf,
}

/// A store adoption that must happen before the actions (the blob does not
/// exist yet; one existing copy is hardlinked in — zero extra disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdoptAction {
    /// Model to adopt.
    pub id: ModelId,
    /// The existing copy to link into the store.
    pub source: PathBuf,
    /// sha256 hex (names the blob).
    pub sha256_hex: String,
}

/// Why a duplicate copy was left alone.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkippedCopy {
    /// The file that was not touched.
    pub path: PathBuf,
    /// Human-readable reason.
    pub reason: String,
}

/// The output of the planning phase. Pure data; nothing has happened yet.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DedupPlan {
    /// Strategy the plan was computed for.
    pub strategy: LinkStrategy,
    /// Store adoptions to perform first.
    pub adoptions: Vec<AdoptAction>,
    /// Replacements to perform.
    pub actions: Vec<DedupAction>,
    /// Copies that were considered and skipped, with reasons.
    pub skipped: Vec<SkippedCopy>,
    /// Bytes expected to be reclaimed if every action applies.
    pub reclaimable_bytes: u64,
}

impl DedupPlan {
    /// One-line human summary.
    pub fn summary(&self) -> String {
        format!(
            "{} replacement(s), {} adoption(s), {} skipped, ~{} reclaimable",
            self.actions.len(),
            self.adoptions.len(),
            self.skipped.len(),
            human_bytes(self.reclaimable_bytes)
        )
    }
}

/// What happened to one action during apply.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionOutcome {
    /// The swap completed.
    Applied,
    /// The target changed since planning, or a precondition failed.
    Skipped,
    /// The action was undone later.
    Undone,
}

/// Journal record of one action (written before the swap).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalAction {
    /// The action as planned.
    #[serde(flatten)]
    pub action: DedupAction,
    /// Current status.
    pub outcome: ActionOutcome,
    /// Reason, when skipped.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// Journal record of a whole dedup operation, stored as
/// `journal/<op_id>.json` and used by `undo`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalOp {
    /// Operation id (`<utc-timestamp>-<pid>`).
    pub op_id: String,
    /// RFC 3339 creation time.
    pub created_at: String,
    /// Strategy used.
    pub strategy: LinkStrategy,
    /// Per-action records.
    pub actions: Vec<JournalAction>,
}

/// Result of [`crate::Shelf::apply_dedup`].
#[derive(Debug, Clone, Default)]
pub struct DedupReport {
    /// Journal id for [`crate::Shelf::undo`].
    pub op_id: String,
    /// Number of applied replacements.
    pub applied: usize,
    /// Actions skipped at apply time, with reasons.
    pub skipped: Vec<SkippedCopy>,
    /// Bytes reclaimed (freed once no other process holds the old inodes).
    pub reclaimed_bytes: u64,
}

/// Result of [`crate::Shelf::undo`].
#[derive(Debug, Clone, Default)]
pub struct UndoReport {
    /// Copies restored to independent files.
    pub restored: usize,
    /// Targets that could not be restored, with reasons.
    pub skipped: Vec<SkippedCopy>,
}

pub(crate) fn human_bytes(n: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut value = n as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{n} B")
    } else {
        format!("{value:.1} {}", UNITS[unit])
    }
}

/// Path components that indicate a cloud-synced folder, where hardlinks are
/// silently broken by sync clients rehydrating files.
pub(crate) fn in_cloud_synced_dir(path: &std::path::Path) -> bool {
    path.components().any(|c| {
        let s = c.as_os_str().to_string_lossy();
        matches!(
            s.as_ref(),
            "OneDrive" | "Dropbox" | "iCloud Drive" | "Google Drive" | "GoogleDrive"
        ) || s.starts_with("OneDrive -")
    })
}