use std::path::{Path, PathBuf};
use crate::adapter::{AdapterKind, AgentAdapter};
use crate::error::{Result, TuffError};
use crate::lockfile;
use crate::resolver::{self, Scope};
use super::{capability_index, home_dir, resolve_agent_selection};
fn resolve_cleanup_scope(repo_root: &Path, scope_str: &str) -> Result<(Scope, PathBuf)> {
let scope = resolver::Scope::parse(scope_str).ok_or_else(|| {
TuffError::usage(format!("invalid scope '{}'", scope_str))
.with_hint("scope is 'project' or 'global'")
})?;
let scope_root = match scope {
Scope::Project => repo_root.to_path_buf(),
Scope::Global => home_dir()?,
};
Ok((scope, scope_root))
}
fn remove_target_tracking(
_scope_root: &Path,
id: &str,
entry: &mut lockfile::CapabilityLockEntry,
target: &str,
) -> Result<()> {
if entry.targets.remove(target).is_none() {
return Err(TuffError::not_found(format!(
"'{}' is not tracked for agent '{}'",
id, target
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct LocalModifications {
pub files: bool,
pub managed: bool,
}
impl LocalModifications {
pub(crate) fn any(self) -> bool {
self.files || self.managed
}
}
pub(crate) fn local_modifications(
scope_root: &Path,
id: &str,
target_entry: &lockfile::TargetLockEntry,
) -> LocalModifications {
let modified_tree = !target_entry.installed_path.is_empty()
&& crate::cache::hash_tree(&scope_root.join(&target_entry.installed_path))
.map(|hash| hash != target_entry.sha256)
.unwrap_or(true);
let files = modified_tree;
let managed = target_entry
.managed_hooks
.iter()
.any(|hook| lockfile::managed_hook_status(scope_root, hook) == "modified")
|| target_entry
.managed_mcp_entry
.as_ref()
.is_some_and(|entry| {
lockfile::managed_mcp_entry_status(scope_root, id, entry) == "modified"
});
LocalModifications { files, managed }
}
pub fn cmd_delete(
repo_root: &Path,
id: &str,
scope_str: &str,
targets: &[String],
force: bool,
) -> Result<()> {
let (scope, scope_root) = resolve_cleanup_scope(repo_root, scope_str)?;
let target_ids = resolve_agent_selection(&scope_root, targets, scope == Scope::Global)?;
let mut lf = lockfile::require_scoped_lockfile(&scope_root, scope)?;
let mut entry = lf.capabilities.get(id).cloned().ok_or_else(|| {
TuffError::not_found(format!(
"'{}' is not installed in {} scope",
id,
scope.as_str()
))
.with_hint("run 'tuff list' to see what is installed")
})?;
for target in &target_ids {
let target_entry = entry.targets.get(target).ok_or_else(|| {
TuffError::not_found(format!("'{}' is not tracked for agent '{}'", id, target))
})?;
if target_entry.ownership == lockfile::TargetOwnership::Imported {
return Err(TuffError::refused(format!(
"'{}' is tracked in place for agent '{}'",
id, target
))
.with_hint(format!("use 'tuff untrack {id} -a {target}' instead")));
}
let LocalModifications {
files: modified,
managed: modified_hook,
} = local_modifications(&scope_root, id, target_entry);
if (modified || modified_hook) && !force {
return Err(TuffError::drift(format!(
"'{}' has local modifications for agent '{}'",
id, target
))
.with_hint("use --force to delete them"));
}
if modified {
eprintln!(
"warning: '{}' has local modifications for agent '{}' - deleting them",
id, target
);
}
}
for target in &target_ids {
if let Some(adapter) = AdapterKind::from_id(target) {
let managed_hooks = entry
.targets
.get(target)
.map(|target_entry| target_entry.managed_hooks.as_slice())
.unwrap_or(&[]);
adapter.remove(id, &scope_root, managed_hooks)?;
}
remove_target_tracking(&scope_root, id, &mut entry, target)?;
}
if entry.targets.is_empty() {
lf.capabilities.remove(id);
} else {
lf.capabilities.insert(id.to_string(), entry);
}
lockfile::write_scoped_lockfile(&scope_root, scope, &lf)?;
if id != capability_index::CAPABILITY_INDEX_ID {
capability_index::regenerate_capability_index(&scope_root, scope)?;
}
println!("deleted '{}' from {} scope", id, scope.as_str());
Ok(())
}
pub fn cmd_untrack(repo_root: &Path, id: &str, scope_str: &str, targets: &[String]) -> Result<()> {
let (scope, scope_root) = resolve_cleanup_scope(repo_root, scope_str)?;
let target_ids = resolve_agent_selection(&scope_root, targets, scope == Scope::Global)?;
let mut lf = lockfile::require_scoped_lockfile(&scope_root, scope)?;
let mut entry = lf.capabilities.get(id).cloned().ok_or_else(|| {
TuffError::not_found(format!(
"'{}' is not installed in {} scope",
id,
scope.as_str()
))
})?;
for target in &target_ids {
if !entry.targets.contains_key(target) {
return Err(TuffError::not_found(format!(
"'{}' is not tracked for agent '{}'",
id, target
)));
}
}
for target in &target_ids {
remove_target_tracking(&scope_root, id, &mut entry, target)?;
}
if entry.targets.is_empty() {
lf.capabilities.remove(id);
} else {
lf.capabilities.insert(id.to_string(), entry);
}
lockfile::write_scoped_lockfile(&scope_root, scope, &lf)?;
if id != capability_index::CAPABILITY_INDEX_ID {
capability_index::regenerate_capability_index(&scope_root, scope)?;
}
println!("untracked '{}' from {} scope", id, scope.as_str());
Ok(())
}