tuffcli 0.1.7

Capability lifecycle manager for coding agents.
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::{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::new(format!("invalid scope '{}'", scope_str)))?;
    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::new(format!(
            "'{}' is not tracked for agent '{}'",
            id, target
        )));
    }
    Ok(())
}

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_lockfile(&scope_root)?;
    let mut entry = lf.capabilities.get(id).cloned().ok_or_else(|| {
        TuffError::new(format!(
            "'{}' is not installed in {} scope",
            id,
            scope.as_str()
        ))
    })?;

    for target in &target_ids {
        let target_entry = entry.targets.get(target).ok_or_else(|| {
            TuffError::new(format!("'{}' is not tracked for agent '{}'", id, target))
        })?;

        if target_entry.ownership == lockfile::TargetOwnership::Imported {
            return Err(TuffError::new(format!(
                "'{}' is tracked in place for agent '{}'; use 'tuff untrack {} -a {}' instead",
                id, target, id, target
            )));
        }

        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 modified = modified_tree
            || target_entry
                .emitted_files
                .iter()
                .any(|emitted| lockfile::drift_status(&scope_root, emitted) == "modified");
        let modified_hook = target_entry
            .managed_hooks
            .iter()
            .any(|hook| lockfile::managed_hook_status(&scope_root, hook) == "modified");
        if (modified || modified_hook) && !force {
            return Err(TuffError::new(format!(
                "'{}' has local modifications for agent '{}'; use --force to delete",
                id, target
            )));
        }
        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_lockfile(&scope_root, &lf)?;
    lockfile::prune_unreferenced_baseline_objects(&scope_root, &lf)?;
    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_lockfile(&scope_root)?;
    let mut entry = lf.capabilities.get(id).cloned().ok_or_else(|| {
        TuffError::new(format!(
            "'{}' is not installed in {} scope",
            id,
            scope.as_str()
        ))
    })?;

    for target in &target_ids {
        if !entry.targets.contains_key(target) {
            return Err(TuffError::new(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_lockfile(&scope_root, &lf)?;
    lockfile::prune_unreferenced_baseline_objects(&scope_root, &lf)?;
    println!("untracked '{}' from {} scope", id, scope.as_str());
    Ok(())
}