use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct UninstallPlan {
pub home: PathBuf,
pub remove: Vec<PathBuf>,
pub preserved: Vec<String>,
pub shared_hf_cache: Option<PathBuf>,
}
impl UninstallPlan {
pub fn is_empty(&self) -> bool {
self.remove.is_empty()
}
}
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();
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(),
}
}
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<()> {
let meta = std::fs::symlink_metadata(path)?;
if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
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"));
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() {
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());
assert!(execute(&plan).is_empty());
}
}