use std::collections::HashSet;
use anyhow::{Context, bail};
use astrid_core::dirs::AstridHome;
use super::meta::{CapsuleMeta, scan_installed_capsules};
pub(crate) fn remove_capsule(
name: &str,
workspace: bool,
force: bool,
purge: bool,
) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
let target_dir = super::install::resolve_target_dir(&home, name, workspace)?;
if !target_dir.exists() {
bail!("Capsule '{name}' is not installed.");
}
let target_meta = super::meta::read_meta(&target_dir);
let all_capsules = scan_installed_capsules()?;
if !force && let Some(block) = check_removal_safety(name, target_meta.as_ref(), &all_capsules) {
bail!(
"Cannot remove '{name}': it is the sole provider of '{}' \
which is required by '{}'. Use --force to override.",
block.capability,
block.dependent,
);
}
std::fs::remove_dir_all(&target_dir)
.with_context(|| format!("failed to remove {}", target_dir.display()))?;
if purge {
let principal = astrid_core::PrincipalId::default();
let env_path = home
.principal_home(&principal)
.env_dir()
.join(format!("{name}.env.json"));
if env_path.exists() {
std::fs::remove_file(&env_path).with_context(|| {
format!("failed to purge configuration at {}", env_path.display())
})?;
eprintln!("Purged configuration for '{name}'.");
}
}
if force {
eprintln!("Removed '{name}' (forced).");
} else {
eprintln!("Removed '{name}'.");
}
Ok(())
}
struct RemovalBlocked {
capability: String,
dependent: String,
}
fn check_removal_safety(
target_name: &str,
target_meta: Option<&CapsuleMeta>,
all_capsules: &[super::meta::InstalledCapsule],
) -> Option<RemovalBlocked> {
let target_exports: HashSet<(&str, &str)> = target_meta
.map(|m| {
m.exports
.iter()
.flat_map(|(ns, ifaces)| {
ifaces.keys().map(move |name| (ns.as_str(), name.as_str()))
})
.collect()
})
.unwrap_or_default();
if target_exports.is_empty() {
return None;
}
let mut other_exported: HashSet<(&str, &str)> = HashSet::new();
for capsule in all_capsules {
if capsule.name == target_name {
continue;
}
if let Some(ref meta) = capsule.meta {
for (ns, ifaces) in &meta.exports {
for name in ifaces.keys() {
other_exported.insert((ns.as_str(), name.as_str()));
}
}
}
}
for capsule in all_capsules {
if capsule.name == target_name {
continue;
}
if let Some(ref meta) = capsule.meta {
for (ns, ifaces) in &meta.imports {
for name in ifaces.keys() {
let key = (ns.as_str(), name.as_str());
if target_exports.contains(&key) && !other_exported.contains(&key) {
return Some(RemovalBlocked {
capability: format!("{ns}/{name}"),
dependent: capsule.name.clone(),
});
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::super::meta::{CapsuleLocation, CapsuleMeta, InstalledCapsule};
use super::*;
fn meta_ie(
exports: &[(&str, &str, &str)],
imports: &[(&str, &str, &str)],
hash: Option<&str>,
) -> CapsuleMeta {
let mut export_map = std::collections::HashMap::new();
for (ns, iface, ver) in exports {
export_map
.entry(ns.to_string())
.or_insert_with(std::collections::HashMap::new)
.insert(iface.to_string(), ver.to_string());
}
let mut import_map = std::collections::HashMap::new();
for (ns, iface, ver) in imports {
import_map
.entry(ns.to_string())
.or_insert_with(std::collections::HashMap::new)
.insert(iface.to_string(), ver.to_string());
}
CapsuleMeta {
version: "1.0.0".into(),
installed_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
source: None,
exports: export_map,
imports: import_map,
wasm_hash: hash.map(String::from),
wit_files: std::collections::HashMap::new(),
}
}
fn capsule(
name: &str,
exports: &[(&str, &str, &str)],
imports: &[(&str, &str, &str)],
) -> InstalledCapsule {
InstalledCapsule {
name: name.to_string(),
meta: Some(meta_ie(exports, imports, None)),
location: CapsuleLocation::User,
}
}
#[test]
fn removal_safe_when_no_dependents() {
let target_meta = Some(meta_ie(&[("astrid", "llm", "1.0.0")], &[], None));
let all = vec![
capsule("target", &[("astrid", "llm", "1.0.0")], &[]),
capsule("other", &[("astrid", "tool", "1.0.0")], &[]),
];
assert!(check_removal_safety("target", target_meta.as_ref(), &all).is_none());
}
#[test]
fn removal_blocked_when_sole_provider() {
let target_meta = Some(meta_ie(&[("astrid", "llm", "1.0.0")], &[], None));
let all = vec![
capsule("target", &[("astrid", "llm", "1.0.0")], &[]),
capsule("react", &[], &[("astrid", "llm", "^1.0")]),
];
let block =
check_removal_safety("target", target_meta.as_ref(), &all).expect("should be blocked");
assert_eq!(block.capability, "astrid/llm");
assert_eq!(block.dependent, "react");
}
#[test]
fn removal_safe_when_another_provider_exists() {
let target_meta = Some(meta_ie(&[("astrid", "llm", "1.0.0")], &[], None));
let all = vec![
capsule("openai", &[("astrid", "llm", "1.0.0")], &[]),
capsule("ollama", &[("astrid", "llm", "1.0.0")], &[]),
capsule("react", &[], &[("astrid", "llm", "^1.0")]),
];
assert!(check_removal_safety("openai", target_meta.as_ref(), &all).is_none());
}
#[test]
fn removal_safe_when_no_exports() {
let target_meta = Some(meta_ie(&[], &[], None));
let all = vec![
capsule("target", &[], &[]),
capsule(
"other",
&[("astrid", "tool", "1.0.0")],
&[("astrid", "llm", "^1.0")],
),
];
assert!(check_removal_safety("target", target_meta.as_ref(), &all).is_none());
}
#[test]
fn removal_safe_when_no_meta() {
let all = vec![
InstalledCapsule {
name: "target".into(),
meta: None,
location: CapsuleLocation::User,
},
capsule(
"other",
&[("astrid", "tool", "1.0.0")],
&[("astrid", "llm", "^1.0")],
),
];
assert!(check_removal_safety("target", None, &all).is_none());
}
#[test]
fn removal_blocked_on_first_conflict_only() {
let target_meta = Some(meta_ie(
&[("astrid", "llm", "1.0.0"), ("astrid", "tool", "1.0.0")],
&[],
None,
));
let all = vec![
capsule(
"target",
&[("astrid", "llm", "1.0.0"), ("astrid", "tool", "1.0.0")],
&[],
),
capsule("react", &[], &[("astrid", "llm", "^1.0")]),
capsule("cli", &[], &[("astrid", "tool", "^1.0")]),
];
let block = check_removal_safety("target", target_meta.as_ref(), &all);
assert!(block.is_some());
}
#[test]
fn remove_nonexistent_capsule_fails() {
let home_dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(home_dir.path());
let target_dir =
super::super::install::resolve_target_dir(&home, "nonexistent", false).unwrap();
assert!(!target_dir.exists());
let err = remove_capsule("nonexistent", false, false, false);
assert!(err.is_err());
let msg = format!("{}", err.unwrap_err());
assert!(msg.contains("not installed"), "got: {msg}");
}
#[test]
fn remove_capsule_cleans_directory() {
let home_dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(home_dir.path());
let capsule_dir = tempfile::tempdir().unwrap();
std::fs::write(
capsule_dir.path().join("Capsule.toml"),
"[package]\nname = \"remove-test\"\nversion = \"1.0.0\"\n",
)
.unwrap();
super::super::install::install_from_local_path(capsule_dir.path(), false, &home, None)
.expect("install should succeed");
let target =
super::super::install::resolve_target_dir(&home, "remove-test", false).unwrap();
assert!(target.exists());
remove_capsule("remove-test", false, true, false).unwrap_or_else(|_| {
std::fs::remove_dir_all(&target).unwrap();
});
}
#[test]
fn remove_without_purge_preserves_env() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join("env");
std::fs::create_dir_all(&env_dir).unwrap();
let env_path = env_dir.join("test-capsule.env.json");
std::fs::write(&env_path, r#"{"api_key":"secret"}"#).unwrap();
let purge = false;
if purge {
let _ = std::fs::remove_file(&env_path);
}
assert!(
env_path.exists(),
"env.json should be preserved when purge=false"
);
}
#[test]
fn remove_with_purge_deletes_env() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join("env");
std::fs::create_dir_all(&env_dir).unwrap();
let env_path = env_dir.join("test-capsule.env.json");
std::fs::write(&env_path, r#"{"api_key":"secret"}"#).unwrap();
let purge = true;
if purge && env_path.exists() {
std::fs::remove_file(&env_path).unwrap();
}
assert!(
!env_path.exists(),
"env.json should be deleted when purge=true"
);
}
#[test]
fn purge_with_no_env_file_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let env_path = tmp.path().join("nonexistent.env.json");
let purge = true;
if purge && env_path.exists() {
std::fs::remove_file(&env_path).unwrap();
}
assert!(!env_path.exists());
}
}