modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! The planning phase: pure, side-effect free.

use super::{
    in_cloud_synced_dir, AdoptAction, DedupAction, DedupOptions, DedupPlan, FileSnapshot,
    SkippedCopy,
};
use crate::paths::ShelfPaths;
use crate::registry::{LocationRole, Registry};
use crate::Result;

/// Build a dedup plan from current registry state. Reads stat information
/// but writes nothing anywhere.
pub(crate) fn build(
    paths: &ShelfPaths,
    reg: &Registry,
    options: &DedupOptions,
) -> Result<DedupPlan> {
    let mut plan = DedupPlan {
        strategy: options.strategy,
        ..Default::default()
    };
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0);
    let blobs_dev = device_of(&paths.blobs_dir());

    for model in &reg.models {
        let Some(hex) = model.id.sha256_hex() else {
            continue;
        };
        let blob = paths.blob_file(hex);
        let blob_snapshot = FileSnapshot::of(&blob).ok();

        // Candidate targets: writable-ecosystem locations that are not
        // already the store copy or a link to it.
        let mut candidates = Vec::new();
        for loc in &model.locations {
            if loc.role == LocationRole::Store || loc.path == blob {
                continue;
            }
            if !loc.ecosystem.is_writable() {
                // Read-only ecosystems are sources, never targets. Not even
                // listed as "skipped" — they are out of scope by design.
                continue;
            }
            let Ok(snapshot) = FileSnapshot::of(&loc.path) else {
                plan.skipped.push(SkippedCopy {
                    path: loc.path.clone(),
                    reason: "cannot stat file".into(),
                });
                continue;
            };
            if let (Some(bs), Some(inode), Some(binode)) = (
                blob_snapshot.as_ref(),
                snapshot.inode,
                blob_snapshot.as_ref().and_then(|b| b.inode),
            ) {
                if snapshot.device == bs.device && inode == binode {
                    continue; // already a hardlink to the blob
                }
            }
            if snapshot.size_bytes < options.min_size_bytes {
                plan.skipped.push(SkippedCopy {
                    path: loc.path.clone(),
                    reason: format!(
                        "below size threshold ({} < {})",
                        snapshot.size_bytes, options.min_size_bytes
                    ),
                });
                continue;
            }
            if now_ms - snapshot.mtime_unix_ms < (options.recent_grace_secs as i64) * 1000 {
                plan.skipped.push(SkippedCopy {
                    path: loc.path.clone(),
                    reason: "modified too recently (may be in use)".into(),
                });
                continue;
            }
            if in_cloud_synced_dir(&loc.path) {
                plan.skipped.push(SkippedCopy {
                    path: loc.path.clone(),
                    reason: "inside a cloud-synced folder (links break on sync)".into(),
                });
                continue;
            }
            // Open-for-read probe: a file another process holds exclusively
            // (Windows) or that is unreadable is left alone.
            if std::fs::File::open(&loc.path).is_err() {
                plan.skipped.push(SkippedCopy {
                    path: loc.path.clone(),
                    reason: "cannot open for reading (in use?)".into(),
                });
                continue;
            }
            candidates.push((loc, snapshot));
        }

        if candidates.is_empty() {
            continue;
        }

        // Ensure a canonical blob will exist: adopt one copy if needed.
        // Prefer a same-device copy so adoption is a free hardlink; any
        // location (including read-only ecosystems) may be a *source*.
        if !blob.is_file() {
            let adopt_src = model
                .locations
                .iter()
                .map(|l| &l.path)
                .filter(|p| p.is_file())
                .max_by_key(|p| (device_of(p) == blobs_dev) as u8);
            let Some(src) = adopt_src else {
                for (loc, _) in candidates {
                    plan.skipped.push(SkippedCopy {
                        path: loc.path.clone(),
                        reason: "no readable copy left to adopt into the store".into(),
                    });
                }
                continue;
            };
            plan.adoptions.push(AdoptAction {
                id: model.id.clone(),
                source: src.clone(),
                sha256_hex: hex.to_owned(),
            });
        }

        for (loc, snapshot) in candidates {
            // Hardlinks cannot cross filesystems. When device ids are known
            // (Unix), refuse up front; otherwise the apply-time link attempt
            // is the authoritative check.
            if options.strategy == super::LinkStrategy::Hardlink {
                if let (Some(target_dev), Some(blob_dev)) = (snapshot.device, blobs_dev) {
                    if target_dev != blob_dev {
                        plan.skipped.push(SkippedCopy {
                            path: loc.path.clone(),
                            reason: "different filesystem (hardlink impossible)".into(),
                        });
                        continue;
                    }
                }
            }
            plan.reclaimable_bytes += snapshot.size_bytes;
            plan.actions.push(DedupAction {
                id: model.id.clone(),
                target: loc.path.clone(),
                ecosystem: loc.ecosystem,
                snapshot,
                blob: blob.clone(),
            });
        }
    }
    Ok(plan)
}

fn device_of(path: &std::path::Path) -> Option<u64> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        std::fs::metadata(path)
            .or_else(|_| {
                path.parent()
                    .map(std::fs::metadata)
                    .unwrap_or_else(|| std::fs::metadata(path))
            })
            .ok()
            .map(|m| m.dev())
    }
    #[cfg(not(unix))]
    {
        let _ = path;
        None
    }
}