use std::collections::HashSet;
use anyhow::{Context, bail};
use astrid_core::PrincipalId;
use astrid_core::dirs::AstridHome;
use super::meta::CapsuleMeta;
pub(crate) fn remove_capsule(
name: &str,
workspace: bool,
force: bool,
purge: bool,
) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
let principal = crate::principal::current();
remove_capsule_from_home_for(&home, &principal, name, workspace, force, purge)
}
fn remove_capsule_from_home(
home: &AstridHome,
name: &str,
workspace: bool,
force: bool,
purge: bool,
) -> anyhow::Result<()> {
let principal = astrid_capsule_install::paths::install_principal();
remove_capsule_from_home_for(home, &principal, name, workspace, force, purge)
}
fn remove_capsule_from_home_for(
home: &AstridHome,
principal: &PrincipalId,
name: &str,
workspace: bool,
force: bool,
purge: bool,
) -> anyhow::Result<()> {
let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout(
home,
principal,
name,
workspace,
crate::workspace_layout::current(),
)?;
std::fs::remove_dir_all(&target_dir)
.with_context(|| format!("failed to remove {}", target_dir.display()))?;
if purge {
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(())
}
pub(crate) fn validate_capsule_removal(
name: &str,
workspace: bool,
force: bool,
) -> anyhow::Result<()> {
let home = AstridHome::resolve()?;
let principal = crate::principal::current();
validate_capsule_removal_from_home_for(&home, &principal, name, workspace, force)
}
fn validate_capsule_removal_from_home(
home: &AstridHome,
name: &str,
workspace: bool,
force: bool,
) -> anyhow::Result<()> {
let principal = astrid_capsule_install::paths::install_principal();
validate_capsule_removal_from_home_for(home, &principal, name, workspace, force)
}
fn validate_capsule_removal_from_home_for(
home: &AstridHome,
principal: &PrincipalId,
name: &str,
workspace: bool,
force: bool,
) -> anyhow::Result<()> {
let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout(
home,
principal,
name,
workspace,
crate::workspace_layout::current(),
)?;
if !target_dir.exists() {
bail!("Capsule '{name}' is not installed.");
}
let target_meta = super::meta::read_meta(&target_dir);
let all_capsules = super::meta::scan_installed_capsules_in_home_for_with_layout(
home,
principal,
crate::workspace_layout::current(),
)?;
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,
);
}
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::*;
use crate::commands::capsule::meta::write_meta;
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(),
..Default::default()
}
}
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 validate_nonexistent_capsule_fails() {
let home_dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(home_dir.path());
let target_dir =
astrid_capsule_install::resolve_target_dir(&home, "nonexistent", false).unwrap();
assert!(!target_dir.exists());
let err = validate_capsule_removal_from_home(&home, "nonexistent", 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 =
astrid_capsule_install::resolve_target_dir(&home, "remove-test", false).unwrap();
assert!(target.exists());
remove_capsule_from_home(&home, "remove-test", false, true, false).unwrap();
assert!(!target.exists());
}
#[test]
fn remove_capsule_from_home_for_targets_principal_install() {
let home_dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(home_dir.path());
let default = astrid_capsule_install::paths::install_principal();
let user = PrincipalId::new("regular-user").unwrap();
let default_target =
astrid_capsule_install::resolve_target_dir_for(&home, &default, "shared", false)
.unwrap();
let user_target =
astrid_capsule_install::resolve_target_dir_for(&home, &user, "shared", false).unwrap();
std::fs::create_dir_all(&default_target).unwrap();
std::fs::create_dir_all(&user_target).unwrap();
remove_capsule_from_home_for(&home, &user, "shared", false, true, false).unwrap();
assert!(
default_target.exists(),
"default install must not be removed by a principal-scoped remove"
);
assert!(!user_target.exists(), "principal install should be removed");
}
#[test]
fn remove_capsule_does_not_repeat_dependency_validation() {
let home_dir = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(home_dir.path());
let capsules_dir = home
.principal_home(&astrid_capsule_install::paths::install_principal())
.capsules_dir();
let target = capsules_dir.join("target");
let dependent = capsules_dir.join("dependent");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&dependent).unwrap();
write_meta(&target, &meta_ie(&[("astrid", "llm", "1.0.0")], &[], None)).unwrap();
write_meta(
&dependent,
&meta_ie(&[], &[("astrid", "llm", "^1.0")], None),
)
.unwrap();
let validation_err = validate_capsule_removal_from_home(&home, "target", false, false)
.expect_err("fixture should make preflight validation fail");
assert!(
validation_err
.to_string()
.contains("it is the sole provider"),
"got: {validation_err}"
);
remove_capsule_from_home(&home, "target", false, false, false)
.expect("delete path must trust the caller's single preflight validation");
assert!(!target.exists());
}
#[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());
}
}