bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Managed lifecycle boundary.
//!
//! Removal is restricted to entries in the registry and to paths below known managed roots.

use std::path::{Path, PathBuf};

use crate::activation::ActivationProvider;
use crate::error::ForgeError;
use crate::fsutil::{remove_dir_all, remove_file};
use crate::model::{Agent, BackendKind, InstallKind, RegistryEntry};
use crate::paths::{app_home, managed_bin_dir};
use crate::skills::agent_dir;
use crate::state::cache::acquire_usage_lease;
use crate::state::update_registry;
use crate::util::exe_name;

pub(crate) const RUSTUP_REMOVAL_UNSUPPORTED: &str =
    "removal is not supported for rustup-managed tool:";

/// Remove a registered installation and restore any previous activation it replaced.
///
/// `expected_revision` provides optimistic concurrency protection: a changed registry causes the
/// operation to fail so callers can preview the current state again.
///
/// # Errors
///
/// Returns [`ForgeError`] for an unknown entry, a stale registry revision, invalid metadata, or
/// an unmanaged target path.
pub(crate) fn remove_installed(
    name: &str,
    kind: Option<InstallKind>,
    expected_revision: u64,
) -> Result<Vec<RegistryEntry>, ForgeError> {
    let _cache_lease = acquire_usage_lease()?;
    update_registry(Some(expected_revision), |entries| {
        let (matched, remaining): (Vec<_>, Vec<_>) = entries.drain(..).partition(|entry| {
            entry.name == name && kind.is_none_or(|expected| entry.kind == expected)
        });

        if matched.is_empty() {
            return Err(ForgeError::Config(format!(
                "installation record not found: {name}"
            )));
        }

        // Reject before removing any targets belonging to other matching records.
        if let Some(entry) = matched.iter().find(|entry| {
            entry.kind == InstallKind::Tool && entry.backend == Some(BackendKind::Rustup)
        }) {
            return Err(ForgeError::Config(format!(
                "{RUSTUP_REMOVAL_UNSUPPORTED} {}",
                entry.name
            )));
        }
        for entry in &matched {
            if entry.kind == InstallKind::Tool
                && !matches!(
                    entry.backend,
                    Some(BackendKind::Cargo | BackendKind::Git | BackendKind::Archive)
                )
            {
                return Err(ForgeError::Config(format!(
                    "tool {} must use the {:?} typed backend for removal; the registry does not store replayable shell commands",
                    entry.name, entry.backend
                )));
            }
            for target in &entry.targets {
                remove_target(&target.path)?;
            }
        }
        restore_previous_activations(&matched)?;
        *entries = remaining;
        Ok(matched)
    })
}

/// Restore executable activations referenced by removed managed-tool entries.
///
/// Entries without a previous artifact or binary target require no restoration. Restoration is
/// limited to typed backends whose artifact layout is managed by bot-forge.
fn restore_previous_activations(entries: &[RegistryEntry]) -> Result<(), ForgeError> {
    let provider = ActivationProvider::new(managed_bin_dir())?;
    for entry in entries
        .iter()
        .filter(|entry| matches!(entry.backend, Some(BackendKind::Cargo | BackendKind::Git)))
    {
        let Some(previous_id) = entry.previous_artifact_id.as_ref() else {
            continue;
        };
        for target in &entry.targets {
            let Some(binary) = &target.binary else {
                continue;
            };
            let source = app_home()
                .join("artifacts")
                .join(previous_id)
                .join(exe_name(binary));
            if source.is_file() {
                provider.prepare(binary, &source)?.switch()?;
            }
        }
    }
    Ok(())
}

fn remove_target(target: &Path) -> Result<(), ForgeError> {
    let metadata = match std::fs::symlink_metadata(target) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(source) => {
            return Err(ForgeError::Io {
                path: target.to_path_buf(),
                source,
            });
        }
    };
    let Some(parent) = target.parent() else {
        return Err(ForgeError::Config(format!(
            "refusing to remove a path without a parent directory: {}",
            target.display()
        )));
    };
    let parent = parent.canonicalize().map_err(|source| ForgeError::Io {
        path: parent.to_path_buf(),
        source,
    })?;
    let target_path = parent.join(target.file_name().ok_or_else(|| {
        ForgeError::Config(format!(
            "refusing to remove a path without a file name: {}",
            target.display()
        ))
    })?);
    let allowed = allowed_removal_roots().iter().any(|root| {
        root.canonicalize()
            .ok()
            .is_some_and(|root| target_path.starts_with(&root) && target_path != root)
    });
    if !allowed {
        return Err(ForgeError::Config(format!(
            "refusing to remove an unmanaged path: {}",
            target.display()
        )));
    }
    if metadata.file_type().is_symlink() {
        return remove_file(target);
    }
    if !target.exists() {
        return Ok(());
    }
    let target = target.canonicalize().map_err(|source| ForgeError::Io {
        path: target.to_path_buf(),
        source,
    })?;
    if target.is_dir() {
        remove_dir_all(&target)
    } else if target.is_file() {
        remove_file(&target)
    } else {
        Ok(())
    }
}

fn allowed_removal_roots() -> Vec<PathBuf> {
    let mut roots = vec![managed_bin_dir(), app_home()];
    for agent in [Agent::Claude, Agent::OpenCode] {
        if let Some(path) = agent_dir(agent) {
            roots.push(path);
        }
    }
    roots
}