car-inference 0.31.0

Local model inference for CAR — Candle backend with Qwen3 models
//! `car uninstall` — remove a CAR install's own state, safely.
//!
//! Removes the contents of `~/.car` (config, state, logs, the installed
//! binaries, and the *managed-model symlinks*) so a reinstall starts clean and
//! no debris from this version is left behind. Two hard boundaries make it safe
//! to run without fear:
//!
//!   * **Never touches the shared HuggingFace cache** (`HF_HOME` /
//!     `~/.cache/huggingface`). Other tools use it, and CAR's managed models are
//!     symlinks *into* it — so removing `~/.car/models` drops the links, not the
//!     multi-GB shared blobs. The plan surfaces the cache path so the CLI can
//!     tell the user how to clear it themselves if they really want to.
//!   * **Can preserve `~/.car/env`** (the dotenv secrets file) on request, so an
//!     uninstall/reinstall cycle doesn't force re-entering API keys.
//!
//! OS-level schedules (launchd/cron) live *outside* `~/.car`; the CLI reaps them
//! via `car-scheduler` before calling [`execute`]. This module only owns the
//! `~/.car` tree.

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

/// What an uninstall would remove and what it would leave alone — computed
/// before any deletion so the CLI can show it and ask for confirmation.
#[derive(Debug, Clone)]
pub struct UninstallPlan {
    /// The `~/.car` directory being removed.
    pub home: PathBuf,
    /// Top-level entries under `home` that will be deleted.
    pub remove: Vec<PathBuf>,
    /// Names deliberately kept (e.g. `env` under `--keep-secrets`).
    pub preserved: Vec<String>,
    /// The shared HuggingFace cache, left intact. `Some` only when it exists —
    /// surfaced purely so the CLI can print a "left alone; remove manually with…"
    /// note. Never deleted by [`execute`].
    pub shared_hf_cache: Option<PathBuf>,
}

impl UninstallPlan {
    /// True when there's nothing to remove (no install, or already clean).
    pub fn is_empty(&self) -> bool {
        self.remove.is_empty()
    }
}

/// Build the removal plan for `home`. Pure (reads the directory listing, deletes
/// nothing), so the CLI can render it for a dry-run or a confirmation prompt.
pub fn plan_uninstall(home: &Path, keep_secrets: bool) -> UninstallPlan {
    let mut remove = Vec::new();
    let mut preserved = Vec::new();

    if let Ok(entries) = std::fs::read_dir(home) {
        for entry in entries.filter_map(Result::ok) {
            let name = entry.file_name().to_string_lossy().to_string();
            // The dotenv secrets file is the one thing we optionally keep.
            if keep_secrets && name == "env" {
                preserved.push(name);
                continue;
            }
            remove.push(entry.path());
        }
    }
    remove.sort();
    preserved.sort();

    UninstallPlan {
        home: home.to_path_buf(),
        remove,
        preserved,
        shared_hf_cache: existing_hf_cache(),
    }
}

/// Execute a plan: remove each listed entry. Returns a per-entry result so the
/// CLI can report partial failures (e.g. a permission error) without aborting
/// the rest. Files and directories are both handled. Never touches anything not
/// in `plan.remove` — in particular never the shared HF cache.
pub fn execute(plan: &UninstallPlan) -> Vec<(PathBuf, Result<(), String>)> {
    plan.remove
        .iter()
        .map(|path| {
            let result = remove_path(path).map_err(|e| e.to_string());
            (path.clone(), result)
        })
        .collect()
}

fn remove_path(path: &Path) -> std::io::Result<()> {
    // `symlink_metadata` does NOT follow links: a managed-model symlink (or a
    // symlink to the HF cache) is removed as a *link*, never recursing into or
    // deleting its target. Real directories are removed recursively.
    let meta = std::fs::symlink_metadata(path)?;
    if meta.is_dir() {
        std::fs::remove_dir_all(path)
    } else {
        std::fs::remove_file(path)
    }
}

/// The shared HuggingFace cache root, but only if it exists on disk. Same
/// resolution the rest of CAR uses (`HF_HOME` else `~/.cache/huggingface`),
/// joined with `hub`. Returned for the informational note only.
fn existing_hf_cache() -> Option<PathBuf> {
    let root = std::env::var("HF_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            let home = std::env::var_os("HOME")
                .or_else(|| std::env::var_os("USERPROFILE"))
                .map(PathBuf::from)
                .unwrap_or_else(|| PathBuf::from("."));
            home.join(".cache").join("huggingface")
        })
        .join("hub");
    root.exists().then_some(root)
}

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

    fn touch(p: &Path) {
        std::fs::write(p, b"x").unwrap();
    }

    #[test]
    fn plan_lists_all_top_level_entries() {
        let tmp = TempDir::new().unwrap();
        touch(&tmp.path().join("models.json"));
        std::fs::create_dir_all(tmp.path().join("models")).unwrap();
        std::fs::create_dir_all(tmp.path().join("logs")).unwrap();

        let plan = plan_uninstall(tmp.path(), false);
        assert_eq!(plan.remove.len(), 3);
        assert!(plan.preserved.is_empty());
        assert!(!plan.is_empty());
    }

    #[test]
    fn keep_secrets_preserves_env() {
        let tmp = TempDir::new().unwrap();
        touch(&tmp.path().join("env"));
        touch(&tmp.path().join("models.json"));

        let plan = plan_uninstall(tmp.path(), true);
        assert_eq!(plan.preserved, vec!["env".to_string()]);
        assert!(plan.remove.iter().all(|p| p.file_name().unwrap() != "env"));

        // Without --keep-secrets, env is removed like anything else.
        let plan = plan_uninstall(tmp.path(), false);
        assert!(plan.preserved.is_empty());
        assert!(plan.remove.iter().any(|p| p.file_name().unwrap() == "env"));
    }

    #[test]
    fn execute_removes_files_and_dirs_and_reports() {
        let tmp = TempDir::new().unwrap();
        touch(&tmp.path().join("models.json"));
        std::fs::create_dir_all(tmp.path().join("logs").join("sub")).unwrap();
        touch(&tmp.path().join("logs").join("sub").join("a.log"));

        let plan = plan_uninstall(tmp.path(), false);
        let results = execute(&plan);
        assert!(results.iter().all(|(_, r)| r.is_ok()), "{results:?}");
        assert!(!tmp.path().join("models.json").exists());
        assert!(!tmp.path().join("logs").exists());
    }

    #[cfg(unix)]
    #[test]
    fn removing_a_model_symlink_does_not_touch_its_target() {
        // A managed-model symlink into a "shared cache" must be removed as a
        // link; its target (the shared blob) must survive.
        let tmp = TempDir::new().unwrap();
        let home = tmp.path().join(".car");
        let cache = tmp.path().join("cache");
        std::fs::create_dir_all(home.join("models")).unwrap();
        std::fs::create_dir_all(&cache).unwrap();
        let blob = cache.join("blob");
        touch(&blob);
        std::os::unix::fs::symlink(&blob, home.join("models").join("weights.safetensors")).unwrap();

        let plan = plan_uninstall(&home, false);
        let results = execute(&plan);
        assert!(results.iter().all(|(_, r)| r.is_ok()));
        assert!(!home.join("models").exists(), "managed model dir removed");
        assert!(blob.exists(), "shared blob behind the symlink must survive");
    }

    #[test]
    fn missing_home_yields_empty_plan() {
        let tmp = TempDir::new().unwrap();
        let plan = plan_uninstall(&tmp.path().join("nonexistent"), false);
        assert!(plan.is_empty());
        // Executing an empty plan is a harmless no-op.
        assert!(execute(&plan).is_empty());
    }
}