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:";
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}"
)));
}
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)
})
}
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
}