astrid 0.7.0

Command-line interface for Astrid secure agent runtime
//! Capsule removal with dependency safety checks.
//!
//! Before removing a capsule, checks whether it is the sole provider of any
//! capability required by another installed capsule. Blocks removal unless
//! `--force` is passed. Content-addressed WASM binaries in `bin/` are cleaned
//! up only if no other installed capsule references the same hash.

use std::collections::HashSet;

use anyhow::{Context, bail};
use astrid_core::dirs::AstridHome;

use super::meta::{CapsuleMeta, scan_installed_capsules};

/// Remove an installed capsule by name.
///
/// Checks the provides/requires dependency graph before removal. If the target
/// capsule is the sole provider of a capability required by another capsule,
/// removal is blocked unless `force` is `true`.
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);

    // Scan once, reuse for both dependency check and binary cleanup
    let all_capsules = scan_installed_capsules()?;

    // Dependency safety check (skip with --force)
    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,
        );
    }

    // Content-addressed artifacts in bin/ and wit/ are NEVER deleted.
    // They are the audit trail — the BLAKE3 hash in audit entries must always
    // resolve to a real binary. Append-only by default, explicit `astrid gc`
    // for operator-initiated cleanup (future).

    // Remove the capsule directory (metadata, Capsule.toml, config).
    std::fs::remove_dir_all(&target_dir)
        .with_context(|| format!("failed to remove {}", target_dir.display()))?;

    // Only delete user configuration (API keys, env vars) with --purge.
    // By default, env.json is preserved so reinstall skips prompting.
    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(())
}

/// A blocked removal: the target capsule is the sole provider of a capability
/// that another capsule requires.
struct RemovalBlocked {
    capability: String,
    dependent: String,
}

/// Check whether removing `target_name` would leave any required capability
/// without a provider.
///
/// Returns `Some(RemovalBlocked)` on the first blocking dependency found,
/// or `None` if removal is safe.
fn check_removal_safety(
    target_name: &str,
    target_meta: Option<&CapsuleMeta>,
    all_capsules: &[super::meta::InstalledCapsule],
) -> Option<RemovalBlocked> {
    // Collect all interfaces the target exports as (namespace, name) pairs.
    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;
    }

    // Collect all interfaces exported by capsules other than the target.
    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()));
                }
            }
        }
    }

    // Check if any other capsule imports something only the target exports.
    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,
            topics: vec![],
            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());
        // Direct test: the bail should fire
        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());

        // Install a minimal capsule
        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)
            .expect("install should succeed");

        let target =
            super::super::install::resolve_target_dir(&home, "remove-test", false).unwrap();
        assert!(target.exists());

        // Remove it (force to skip dep check which scans real fs)
        remove_capsule("remove-test", false, true, false).unwrap_or_else(|_| {
            // If home resolution differs, clean up manually
            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();

        // Simulate the purge=false path: env file should not be touched.
        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();

        // Simulate the purge=true path: env file should be deleted.
        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");

        // Simulate purge on a capsule that has no env config.
        let purge = true;
        if purge && env_path.exists() {
            std::fs::remove_file(&env_path).unwrap();
        }
        // Should not error — just a no-op.
        assert!(!env_path.exists());
    }
}