tuffcli 0.1.7

Capability lifecycle manager for coding agents.
use std::fs;
use std::path::Path;

use crate::adapter::{AdapterKind, AgentAdapter, resolve_capability};
use crate::error::{Result, TuffError};
use crate::git;
use crate::lockfile;
use crate::manifest::{self, load_manifest};
use crate::resolver::{self, Scope};

use super::add::{SourceMetaInput, install_capability};
use super::{home_dir, resolve_agent_selection};

fn update_local_baseline(
    scope_root: &Path,
    id: &str,
    target_ids: &[String],
    check: bool,
    force: bool,
) -> Result<()> {
    if force {
        return Err(TuffError::new(
            "--force is only valid for git-sourced capabilities; local updates accept the current files as the new baseline",
        ));
    }

    let lf = lockfile::require_lockfile(scope_root)?;
    let entry = lf
        .capabilities
        .get(id)
        .ok_or_else(|| TuffError::new(format!("'{}' is not installed", id)))?;

    let mut updates = Vec::new();
    let mut changed_files = 0usize;

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

        if !target_entry.installed_path.is_empty() {
            let path = scope_root.join(&target_entry.installed_path);
            let current_hash = crate::cache::hash_tree(&path)?;
            if current_hash != target_entry.sha256 {
                changed_files += 1;
            }
            updates.push((target_id.clone(), current_hash));
            continue;
        }
        return Err(TuffError::new(format!(
            "lock entry for '{id}' and target '{target_id}' has no installed path"
        )));
    }

    if check {
        if changed_files == 0 {
            println!("'{}' is already up to date", id);
        } else {
            println!(
                "'{}' has {} local file(s) — update would record them as the new baseline",
                id, changed_files
            );
        }
        return Ok(());
    }

    let mut lf = lf;
    let installed = lf
        .capabilities
        .get_mut(id)
        .ok_or_else(|| TuffError::new(format!("'{}' is not installed", id)))?;
    for (target_id, update) in updates {
        let target_entry = installed.targets.get_mut(&target_id).ok_or_else(|| {
            TuffError::new(format!(
                "'{}' is not installed for agent '{}'",
                id, target_id
            ))
        })?;
        if !target_entry.installed_path.is_empty() {
            target_entry.sha256 = update.clone();
            crate::cache::populate(
                &super::home_dir()?,
                &update,
                &scope_root.join(&target_entry.installed_path),
            )?;
        }
    }
    lockfile::write_lockfile(scope_root, &lf)?;
    lockfile::prune_unreferenced_baseline_objects(scope_root, &lf)?;

    if changed_files == 0 {
        println!("'{}' is already up to date", id);
    } else {
        println!(
            "updated local baseline for '{}' ({} file(s))",
            id, changed_files
        );
    }
    Ok(())
}

fn is_in_place_source_path(source_path: &str) -> bool {
    source_path.is_empty()
        || source_path.starts_with(".agents/")
        || source_path == ".agents"
        || source_path.starts_with(".claude/")
        || source_path == ".claude"
}

fn update_local_from_source(
    scope_root: &Path,
    scope: Scope,
    id: &str,
    entry: &lockfile::CapabilityLockEntry,
    target_ids: &[String],
    check: bool,
    force: bool,
) -> Result<()> {
    let source_dir = lockfile::absolutize(scope_root, Path::new(&entry.source_path));
    let manifest = load_manifest(&source_dir)?;
    let capability = resolve_capability(&manifest)?;
    if capability.id != id {
        return Err(TuffError::new(format!(
            "sourcePath for '{}' points at capability '{}'",
            id, capability.id
        )));
    }

    let mut adapters = Vec::new();
    for tid in target_ids {
        let adapter = AdapterKind::from_id(tid).ok_or_else(|| {
            TuffError::new(format!(
                "unknown agent '{}'; run 'tuff agent list' to see available agents",
                tid
            ))
        })?;
        adapters.push(adapter);
    }

    let mut changed_files = 0usize;
    let mut has_local_drift = false;
    for adapter in &adapters {
        let planned_files = adapter.plan(&capability, scope_root)?;
        let target_entry = entry.targets.get(adapter.id()).ok_or_else(|| {
            TuffError::new(format!(
                "'{}' is not installed for agent '{}'",
                id,
                adapter.id()
            ))
        })?;
        for planned in &planned_files {
            let current_path = scope_root.join(&planned.path);
            let current = if current_path.exists() {
                fs::read(&current_path)?
            } else {
                Vec::new()
            };
            if current != planned.content {
                changed_files += 1;
            }
        }
        has_local_drift |= target_entry
            .emitted_files
            .iter()
            .any(|emitted| lockfile::drift_status(scope_root, emitted) != "clean");
    }

    if check {
        if has_local_drift && !force {
            println!(
                "'{}' has local changes — update would require --force to reload from local source",
                id
            );
        } else if changed_files == 0 {
            println!("'{}' is already up to date", id);
        } else {
            println!(
                "'{}' has {} source file(s) to apply from {}",
                id, changed_files, entry.source_path
            );
        }
        return Ok(());
    }

    if has_local_drift && !force {
        return Err(TuffError::new(format!(
            "'{}' has local changes; run 'tuff diff {}' first or use --force to reload from source",
            id, id
        )));
    }

    install_capability(
        scope_root,
        scope,
        &capability,
        &manifest,
        target_ids,
        None,
        true,
    )?;
    Ok(())
}

pub fn cmd_update(
    repo_root: &Path,
    id: &str,
    scope_str: Option<&str>,
    requested_targets: &[String],
    check: bool,
    force: bool,
) -> Result<()> {
    let (scope, entry, scope_root) = if let Some(s) = scope_str {
        let scope = resolver::Scope::parse(s)
            .ok_or_else(|| TuffError::new(format!("invalid scope '{}'", s)))?;
        let root = match scope {
            Scope::Project => repo_root.to_path_buf(),
            Scope::Global => home_dir()?,
        };
        let lf = lockfile::require_lockfile(&root)?;
        let entry = lf.capabilities.get(id).ok_or_else(|| {
            TuffError::new(format!(
                "'{}' is not installed in {} scope",
                id,
                scope.as_str()
            ))
        })?;
        (scope, entry.clone(), root)
    } else {
        match resolver::resolve_entry(id, repo_root)? {
            Some((s, e)) => {
                let root = match s {
                    Scope::Project => repo_root.to_path_buf(),
                    Scope::Global => home_dir()?,
                };
                (s, e, root)
            }
            None => {
                return Err(TuffError::new(format!("'{}' is not installed", id)));
            }
        }
    };

    let target_ids =
        resolve_agent_selection(&scope_root, requested_targets, scope == Scope::Global)?;

    if entry.source.is_none() && is_in_place_source_path(&entry.source_path) {
        return update_local_baseline(&scope_root, id, &target_ids, check, force);
    }

    if entry.source.is_none() {
        return update_local_from_source(&scope_root, scope, id, &entry, &target_ids, check, force);
    }

    let source = entry.source.as_ref().expect("source checked above");

    let (_source_guard, cache_dir, _clean_url) = git::clone_to_temp(&source.url, None)?;
    let latest_sha = git::resolve_ref(&cache_dir)?;

    if latest_sha == entry.installed_version {
        println!("'{}' is already up to date", id);
        return Ok(());
    }

    let skill_dir = git::discover_capability(&cache_dir, &source.skill, entry.capability_type)?;

    if force {
        let manifest = manifest::synthetic_manifest(&skill_dir, id, &latest_sha)?;
        let capability = resolve_capability(&manifest)?;
        return install_capability(
            &scope_root,
            scope,
            &capability,
            &manifest,
            &target_ids,
            Some(&SourceMetaInput {
                source_type: "git".to_string(),
                url: source.url.clone(),
                source_ref: latest_sha,
                skill: source.skill.clone(),
            }),
            true,
        );
    }

    let mut all_clean = true;
    for target_id in &target_ids {
        let target_entry = entry.targets.get(target_id).ok_or_else(|| {
            TuffError::new(format!(
                "'{}' is not installed for agent '{}'",
                id, target_id
            ))
        })?;
        let current = scope_root.join(&target_entry.installed_path);
        if crate::cache::hash_tree(&current)? != target_entry.sha256 {
            all_clean = false;
        }
    }

    if check {
        if all_clean {
            println!("'{}' can be updated cleanly (no local changes)", id);
        } else if !all_clean {
            println!(
                "'{}' has local changes — update would replace the materialized tree",
                id
            );
        } else {
            println!("'{}' is up to date", id);
        }
        return Ok(());
    }

    if !all_clean {
        return Err(TuffError::new(format!(
            "'{id}' has local changes; run 'tuff diff {id}' first or use --force to reload from source"
        )));
    }

    let manifest = manifest::synthetic_manifest(&skill_dir, id, &latest_sha)?;
    let primitive = resolve_capability(&manifest)?;
    install_capability(
        &scope_root,
        scope,
        &primitive,
        &manifest,
        &target_ids,
        Some(&SourceMetaInput {
            source_type: "git".to_string(),
            url: source.url.clone(),
            source_ref: latest_sha,
            skill: source.skill.clone(),
        }),
        true,
    )
}