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(target_os = "macos")]
pub const HOST_BUNDLE_ID: &str = "ai.parslee.car";
#[cfg(target_os = "macos")]
const SCHEDULE_LABEL_PREFIX: &str = "ai.parslee.car.task.";
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MacHostPlan {
pub prefs_domain: Option<String>,
pub bootout: Vec<String>,
pub remove: Vec<PathBuf>,
pub tcc_reset: Option<Vec<String>>,
}
#[cfg(target_os = "macos")]
impl MacHostPlan {
pub fn is_empty(&self) -> bool {
self.remove.is_empty()
&& self.bootout.is_empty()
&& self.prefs_domain.is_none()
&& self.tcc_reset.is_none()
}
fn empty() -> Self {
Self {
prefs_domain: None,
bootout: Vec::new(),
remove: Vec::new(),
tcc_reset: None,
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TccResetError {
BundleMissing,
Failed(String),
}
#[cfg(target_os = "macos")]
#[derive(Debug)]
pub struct MacHostOutcome {
pub bootout: Vec<(String, Result<(), String>)>,
pub steps: Vec<(String, Result<(), String>)>,
pub tcc: Option<Result<(), TccResetError>>,
}
#[cfg(target_os = "macos")]
pub fn plan_macos_host(
home_dir: &Path,
app_bundle: &Path,
keep_permissions: bool,
state_root_relocated: bool,
) -> MacHostPlan {
if state_root_relocated {
return MacHostPlan::empty();
}
let lib = home_dir.join("Library");
let prefs_plist = lib
.join("Preferences")
.join(format!("{HOST_BUNDLE_ID}.plist"));
let app_evidence = [
prefs_plist.clone(),
lib.join("Caches").join(HOST_BUNDLE_ID),
lib.join("Saved Application State")
.join(format!("{HOST_BUNDLE_ID}.savedState")),
];
let host_present = exists(app_bundle) || app_evidence.iter().any(|p| exists(p));
let mut remove: Vec<PathBuf> = app_evidence
.into_iter()
.chain([lib.join("Application Support").join(HOST_BUNDLE_ID)])
.filter(|p| exists(p))
.collect();
remove.extend(entries_with_prefix(&lib.join("HTTPStorages"), |_| true));
let launch_agents = entries_with_prefix(&lib.join("LaunchAgents"), |name| {
!name.starts_with(SCHEDULE_LABEL_PREFIX)
});
let mut bootout: Vec<String> = launch_agents
.iter()
.filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.collect();
bootout.sort();
bootout.dedup();
remove.extend(launch_agents);
remove.sort();
remove.dedup();
let prefs_domain = exists(&prefs_plist).then(|| HOST_BUNDLE_ID.to_string());
let tcc_reset = (!keep_permissions && host_present).then(|| {
vec![
"/usr/bin/tccutil".to_string(),
"reset".to_string(),
"All".to_string(),
HOST_BUNDLE_ID.to_string(),
]
});
MacHostPlan {
prefs_domain,
bootout,
remove,
tcc_reset,
}
}
#[cfg(target_os = "macos")]
pub fn stop_legacy_agents(plan: &MacHostPlan) -> Vec<(String, Result<(), String>)> {
plan.bootout
.iter()
.map(|label| (label.clone(), launchctl_bootout(label)))
.collect()
}
#[cfg(target_os = "macos")]
fn launchctl_bootout(label: &str) -> Result<(), String> {
let uid = unsafe { libc::getuid() };
let out = std::process::Command::new("/bin/launchctl")
.args(["bootout", &format!("gui/{uid}/{label}")])
.output()
.map_err(|e| format!("could not run launchctl: {e}"))?;
if out.status.success() || out.status.code() == Some(3) {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("No such process") || stderr.contains("Could not find domain") {
return Ok(());
}
let detail = stderr.trim();
Err(if detail.is_empty() {
format!("launchctl bootout exited with {}", out.status)
} else {
detail.to_string()
})
}
#[cfg(target_os = "macos")]
pub fn execute_macos_host(plan: &MacHostPlan) -> MacHostOutcome {
let mut steps = Vec::new();
let bootout = stop_legacy_agents(plan);
if let Some(domain) = &plan.prefs_domain {
steps.push((
format!("defaults delete {domain}"),
delete_prefs_domain(domain),
));
}
for path in &plan.remove {
steps.push((
path.display().to_string(),
remove_path(path).map_err(|e| e.to_string()),
));
}
let tcc = plan.tcc_reset.as_deref().map(run_tccutil);
MacHostOutcome {
bootout,
steps,
tcc,
}
}
#[cfg(target_os = "macos")]
fn exists(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
#[cfg(target_os = "macos")]
fn entries_with_prefix(dir: &Path, keep: impl Fn(&str) -> bool) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
(name.starts_with(HOST_BUNDLE_ID) && keep(&name)).then(|| entry.path())
})
.collect()
}
#[cfg(target_os = "macos")]
fn delete_prefs_domain(domain: &str) -> Result<(), String> {
let out = std::process::Command::new("/usr/bin/defaults")
.args(["delete", domain])
.output()
.map_err(|e| format!("could not run defaults: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("not found") || stderr.contains("does not exist") {
return Ok(());
}
Err(stderr.trim().to_string())
}
#[cfg(target_os = "macos")]
fn run_tccutil(argv: &[String]) -> Result<(), TccResetError> {
let Some((bin, args)) = argv.split_first() else {
return Err(TccResetError::Failed("empty tccutil argv".to_string()));
};
let out = std::process::Command::new(bin)
.args(args)
.output()
.map_err(|e| TccResetError::Failed(format!("could not run {bin}: {e}")))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.code() == Some(64) || stderr.contains("-10814") {
return Err(TccResetError::BundleMissing);
}
let detail = stderr.trim();
Err(TccResetError::Failed(if detail.is_empty() {
format!("tccutil exited with {}", out.status)
} else {
detail.to_string()
}))
}
#[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());
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_host_tests {
use super::*;
use tempfile::TempDir;
fn home_with(names: &[&str]) -> TempDir {
let tmp = TempDir::new().unwrap();
for name in names {
let path = tmp.path().join("Library").join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
if name.ends_with('/') {
std::fs::create_dir_all(&path).unwrap();
} else {
std::fs::write(&path, b"x").unwrap();
}
}
tmp
}
fn missing_bundle() -> PathBuf {
PathBuf::from("/Applications/definitely-not-installed-CarHost.app")
}
#[test]
fn plan_lists_only_host_paths_that_exist() {
let home = home_with(&[
"Preferences/ai.parslee.car.plist",
"Application Support/ai.parslee.car/auth-token",
"Caches/ai.parslee.car/blob",
"HTTPStorages/ai.parslee.car",
"HTTPStorages/ai.parslee.car.binarycookies",
"Saved Application State/ai.parslee.car.savedState/window.plist",
"LaunchAgents/ai.parslee.car-server.plist",
"Preferences/com.apple.finder.plist",
"Caches/ai.parslee.other/blob",
]);
let lib = home.path().join("Library");
let plan = plan_macos_host(home.path(), &missing_bundle(), false, false);
let names: Vec<String> = plan
.remove
.iter()
.map(|p| p.strip_prefix(&lib).unwrap().display().to_string())
.collect();
assert_eq!(
names,
vec![
"Application Support/ai.parslee.car".to_string(),
"Caches/ai.parslee.car".to_string(),
"HTTPStorages/ai.parslee.car".to_string(),
"HTTPStorages/ai.parslee.car.binarycookies".to_string(),
"LaunchAgents/ai.parslee.car-server.plist".to_string(),
"Preferences/ai.parslee.car.plist".to_string(),
"Saved Application State/ai.parslee.car.savedState".to_string(),
],
"{plan:#?}"
);
assert_eq!(plan.prefs_domain.as_deref(), Some("ai.parslee.car"));
assert!(!plan.is_empty());
}
#[test]
fn scheduled_task_agents_are_left_to_the_scheduler() {
let home = home_with(&[
"LaunchAgents/ai.parslee.car.task.abc123.plist",
"LaunchAgents/ai.parslee.car-host.plist",
]);
let plan = plan_macos_host(home.path(), &missing_bundle(), false, false);
let names: Vec<String> = plan
.remove
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["ai.parslee.car-host.plist".to_string()]);
}
#[test]
fn tcc_reset_renders_the_exact_argv() {
let home = home_with(&["Preferences/ai.parslee.car.plist"]);
let plan = plan_macos_host(home.path(), &missing_bundle(), false, false);
assert_eq!(
plan.tcc_reset.as_deref(),
Some(
[
"/usr/bin/tccutil".to_string(),
"reset".to_string(),
"All".to_string(),
"ai.parslee.car".to_string(),
]
.as_slice()
)
);
}
#[test]
fn keep_permissions_omits_the_tccutil_argv() {
let home = home_with(&["Preferences/ai.parslee.car.plist"]);
let plan = plan_macos_host(home.path(), &missing_bundle(), true, false);
assert!(plan.tcc_reset.is_none());
assert_eq!(plan.remove.len(), 1);
assert!(!plan.is_empty());
}
#[test]
fn no_host_install_plans_nothing_at_all() {
let home = TempDir::new().unwrap();
let plan = plan_macos_host(home.path(), &missing_bundle(), false, false);
assert!(plan.is_empty(), "{plan:#?}");
assert!(plan.tcc_reset.is_none());
assert!(plan.prefs_domain.is_none());
}
#[test]
fn a_cli_only_mac_earns_no_tcc_reset() {
let home = home_with(&["Application Support/ai.parslee.car/auth-token"]);
let plan = plan_macos_host(home.path(), &missing_bundle(), false, false);
assert!(plan.tcc_reset.is_none(), "{plan:#?}");
assert_eq!(plan.remove.len(), 1);
assert!(plan.remove[0].ends_with("Application Support/ai.parslee.car"));
assert!(plan.prefs_domain.is_none());
assert!(!plan.is_empty(), "the token dir is still purged");
}
#[test]
fn a_relocated_state_root_never_touches_the_default_install() {
let home = home_with(&[
"Preferences/ai.parslee.car.plist",
"Application Support/ai.parslee.car/auth-token",
"Caches/ai.parslee.car/blob",
"LaunchAgents/ai.parslee.car-server.plist",
]);
let bundle = TempDir::new().unwrap();
let plan = plan_macos_host(home.path(), bundle.path(), false, true);
assert!(plan.is_empty(), "{plan:#?}");
assert!(plan.remove.is_empty(), "no $HOME/Library entries");
assert!(plan.bootout.is_empty());
assert!(plan.tcc_reset.is_none(), "no tccutil reset");
assert!(plan.prefs_domain.is_none());
}
#[test]
fn legacy_agents_are_booted_out_before_their_plists_are_unlinked() {
let home = home_with(&[
"LaunchAgents/ai.parslee.car-server.plist",
"LaunchAgents/ai.parslee.car-host.plist",
"LaunchAgents/ai.parslee.car.task.abc123.plist",
]);
let plan = plan_macos_host(home.path(), &missing_bundle(), true, false);
assert_eq!(
plan.bootout,
vec![
"ai.parslee.car-host".to_string(),
"ai.parslee.car-server".to_string()
],
"{plan:#?}"
);
for label in &plan.bootout {
assert!(
plan.remove
.iter()
.any(|p| p.file_stem().map(|s| s == label.as_str()) == Some(true)),
"{label} booted out but its plist is not in remove"
);
}
}
#[test]
fn booting_out_an_unloaded_agent_is_a_no_op_success() {
assert_eq!(
launchctl_bootout("ai.parslee.car.uninstall-test-not-a-real-agent"),
Ok(())
);
}
#[test]
fn an_installed_app_alone_still_earns_a_reset() {
let home = TempDir::new().unwrap();
let bundle = TempDir::new().unwrap();
let plan = plan_macos_host(home.path(), bundle.path(), false, false);
assert!(plan.remove.is_empty());
assert!(plan.tcc_reset.is_some());
assert!(!plan.is_empty());
}
#[test]
fn execute_removes_host_state_and_reports_each_step() {
let home = home_with(&[
"Application Support/ai.parslee.car/auth-token",
"Caches/ai.parslee.car/blob",
]);
let plan = MacHostPlan {
prefs_domain: None,
tcc_reset: None,
..plan_macos_host(home.path(), &missing_bundle(), true, false)
};
let outcome = execute_macos_host(&plan);
assert_eq!(outcome.steps.len(), 2);
assert!(outcome.steps.iter().all(|(_, r)| r.is_ok()), "{outcome:?}");
assert!(outcome.tcc.is_none());
assert!(!home
.path()
.join("Library/Application Support/ai.parslee.car")
.exists());
assert!(!home.path().join("Library/Caches/ai.parslee.car").exists());
}
#[test]
fn an_unresolvable_bundle_is_reported_as_bundle_missing() {
let argv = [
"/usr/bin/tccutil".to_string(),
"reset".to_string(),
"All".to_string(),
"ai.parslee.car.uninstall-test-not-a-real-bundle".to_string(),
];
assert_eq!(run_tccutil(&argv), Err(TccResetError::BundleMissing));
}
#[test]
fn a_missing_tccutil_binary_is_a_plain_failure() {
let argv = [
"/usr/bin/tccutil-does-not-exist".to_string(),
"reset".to_string(),
];
assert!(matches!(
run_tccutil(&argv),
Err(TccResetError::Failed(msg)) if msg.contains("could not run")
));
}
#[test]
fn deleting_an_absent_prefs_domain_is_a_no_op_success() {
assert_eq!(
delete_prefs_domain("ai.parslee.car.uninstall-test-not-a-real-domain"),
Ok(())
);
}
}