modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! The apply phase: journaled, re-verified, atomic swaps.

use std::path::Path;

use super::{
    ActionOutcome, DedupPlan, DedupReport, FileSnapshot, JournalAction, JournalOp, LinkStrategy,
    SkippedCopy,
};
use crate::paths::ShelfPaths;
use crate::registry::{LocationRole, RegistryStore};
use crate::{Error, Result};

/// Apply a plan. Each action is re-verified against its plan-time snapshot;
/// a mismatch skips that action only. The journal entry is persisted before
/// any file is touched.
pub(crate) fn apply(
    paths: &ShelfPaths,
    store: &RegistryStore,
    plan: &DedupPlan,
) -> Result<DedupReport> {
    let op_id = format!("{}-{}", crate::time_util::now_compact(), std::process::id());
    let mut journal = JournalOp {
        op_id: op_id.clone(),
        created_at: crate::time_util::now_rfc3339(),
        strategy: plan.strategy,
        actions: plan
            .actions
            .iter()
            .map(|a| JournalAction {
                action: a.clone(),
                outcome: ActionOutcome::Skipped, // pessimistic until applied
                reason: Some("not attempted".into()),
            })
            .collect(),
    };
    // The journal hits disk before anything is modified.
    write_journal(paths, &journal)?;

    let mut report = DedupReport {
        op_id: op_id.clone(),
        ..Default::default()
    };

    // Adoptions first: they create store blobs, never modify sources.
    for adopt in &plan.adoptions {
        if let Err(e) = crate::store::adopt(paths, &adopt.source, &adopt.sha256_hex) {
            tracing::warn!(id = %adopt.id, "adoption failed: {e}");
        }
    }

    for (i, action) in plan.actions.iter().enumerate() {
        let result = apply_one(action, plan.strategy);
        match result {
            Ok(()) => {
                journal.actions[i].outcome = ActionOutcome::Applied;
                journal.actions[i].reason = None;
                report.applied += 1;
                report.reclaimed_bytes += action.snapshot.size_bytes;
                // Record the new role in the registry; failure to update the
                // registry must not abort remaining actions (the journal is
                // the source of truth for undo).
                let target = action.target.clone();
                let id = action.id.clone();
                let role = match plan.strategy {
                    LinkStrategy::Hardlink => LocationRole::Hardlink,
                    LinkStrategy::Symlink => LocationRole::Symlink,
                };
                let _ = store.update(|reg| {
                    if let Some(entry) = reg.find_mut(&id) {
                        if let Some(loc) = entry.locations.iter_mut().find(|l| l.path == target) {
                            loc.role = role;
                        }
                    }
                    Ok(())
                });
            }
            Err(e) => {
                journal.actions[i].outcome = ActionOutcome::Skipped;
                journal.actions[i].reason = Some(e.to_string());
                report.skipped.push(SkippedCopy {
                    path: action.target.clone(),
                    reason: e.to_string(),
                });
            }
        }
        write_journal(paths, &journal)?;
    }
    Ok(report)
}

/// Verify preconditions and perform one atomic swap.
fn apply_one(action: &super::DedupAction, strategy: LinkStrategy) -> Result<()> {
    // The blob must exist (adoption may have failed).
    if !action.blob.is_file() {
        return Err(Error::Refused("store blob missing".into()));
    }
    // The target must be exactly what we planned against.
    let current = FileSnapshot::of(&action.target)?;
    if current != action.snapshot {
        return Err(Error::Refused(
            "file changed since planning (size/mtime/inode mismatch)".into(),
        ));
    }

    let tmp = swap_tmp_path(&action.target);
    let _ = std::fs::remove_file(&tmp); // leftover from an earlier crash
    let link_result = match strategy {
        LinkStrategy::Hardlink => std::fs::hard_link(&action.blob, &tmp),
        LinkStrategy::Symlink => symlink(&action.blob, &tmp),
    };
    link_result.map_err(|e| Error::io(&tmp, e))?;

    // Atomic replace: after this rename the path serves identical bytes.
    if let Err(e) = std::fs::rename(&tmp, &action.target) {
        let _ = std::fs::remove_file(&tmp);
        return Err(Error::io(&action.target, e));
    }
    Ok(())
}

pub(crate) fn swap_tmp_path(target: &Path) -> std::path::PathBuf {
    let mut name = target
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    name.push_str(".msh-tmp");
    target.with_file_name(name)
}

fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
    #[cfg(unix)]
    return std::os::unix::fs::symlink(original, link);
    #[cfg(windows)]
    return std::os::windows::fs::symlink_file(original, link);
    #[cfg(not(any(unix, windows)))]
    {
        let _ = (original, link);
        Err(std::io::Error::other(
            "symlinks unsupported on this platform",
        ))
    }
}

pub(crate) fn journal_path(paths: &ShelfPaths, op_id: &str) -> std::path::PathBuf {
    paths.journal_dir().join(format!("{op_id}.json"))
}

pub(crate) fn write_journal(paths: &ShelfPaths, journal: &JournalOp) -> Result<()> {
    let path = journal_path(paths, &journal.op_id);
    let bytes = serde_json::to_vec_pretty(journal)
        .map_err(|e| Error::io(&path, std::io::Error::other(e)))?;
    crate::registry::lock::write_atomic(&path, &bytes)
}

pub(crate) fn read_journal(paths: &ShelfPaths, op_id: &str) -> Result<JournalOp> {
    let path = journal_path(paths, op_id);
    let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
        std::io::ErrorKind::NotFound => Error::NotFound(format!("no journal for op {op_id}")),
        _ => Error::io(&path, e),
    })?;
    serde_json::from_slice(&bytes).map_err(|e| Error::io(&path, std::io::Error::other(e)))
}