tuffcli 0.1.7

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

use tuff_hooks_spec::{CompatibilityEntry, CoverageLevel};

use crate::adapter::{AdapterKind, AgentAdapter};
use crate::config;
use crate::error::{Result, TuffError};
use crate::lockfile;
use crate::manifest::CapabilityType;

use super::render_table;

pub fn cmd_hooks_matrix(repo_root: &Path) -> Result<()> {
    let adapters = registered_adapters(repo_root)?;
    let mut rows = Vec::new();
    let mut notes = Vec::new();

    for adapter in adapters {
        for entry in adapter.hook_compatibility().events {
            if let Some(caveat) = entry.caveat {
                notes.push(format!("{} / {}: {}", adapter.id(), entry.event, caveat));
            }
            rows.push(vec![
                adapter.id().to_string(),
                entry.event.to_string(),
                entry.native_event.unwrap_or("").to_string(),
                coverage_label(entry.coverage).to_string(),
                entry.scope.join(", "),
                version_label(entry),
            ]);
        }
    }

    println!(
        "{}",
        render_table(
            &[
                "ADAPTER", "EVENT", "NATIVE", "COVERAGE", "SCOPE", "VERSIONS"
            ],
            &rows
        )
    );
    if !notes.is_empty() {
        println!("\nNotes:");
        for note in notes {
            println!("- {note}");
        }
    }
    Ok(())
}

pub fn cmd_hooks_check_portability(repo_root: &Path, hook_id: &str, target: &str) -> Result<()> {
    let target = AdapterKind::from_id(target).ok_or_else(|| {
        TuffError::new(format!(
            "unknown agent '{}'; use 'tuff agent list' to see available agents",
            target
        ))
    })?;
    ensure_registered(repo_root, target)?;

    let lock = lockfile::require_lockfile(repo_root)?;
    let entry = lock.capabilities.get(hook_id).ok_or_else(|| {
        TuffError::new(format!(
            "hook capability '{}' is not tracked in tuff.lock",
            hook_id
        ))
    })?;
    if entry.capability_type != CapabilityType::Hook {
        return Err(TuffError::new(format!(
            "'{hook_id}' is not a hook capability"
        )));
    }

    if entry.description == "Added from native hook fragment." {
        println!(
            "note: '{}' was added from a native hook fragment; portability is inferred from tracked native events and is not guaranteed",
            hook_id
        );
    }

    let events = tracked_hook_events(entry);
    if events.is_empty() {
        println!(
            "hook '{}' has no tracked native hook registrations; portability cannot be checked",
            hook_id
        );
        return Ok(());
    }

    let mut rows = Vec::new();
    for tracked in events {
        let matrix = target.hook_compatibility();
        let lookup_event = tracked
            .canonical_event
            .as_deref()
            .unwrap_or(&tracked.native_event);
        let display_event = tracked
            .canonical_event
            .as_deref()
            .unwrap_or(&tracked.native_event)
            .to_string();
        let Some(compat) = matrix.find_event(lookup_event) else {
            rows.push(vec![
                display_event,
                tracked.native_event,
                String::new(),
                target.id().to_string(),
                "unsupported".to_string(),
                String::new(),
                "target adapter has no compatibility row for this event".to_string(),
            ]);
            continue;
        };
        let caveat = match tracked.canonical_event {
            Some(_) => compat.caveat.unwrap_or("").to_string(),
            None => {
                let native_note =
                    "legacy/native event; portability is inferred from its native name";
                match compat.caveat {
                    Some(caveat) => format!("{native_note}; {caveat}"),
                    None => native_note.to_string(),
                }
            }
        };
        rows.push(vec![
            display_event,
            tracked.native_event,
            compat.native_event.unwrap_or("").to_string(),
            target.id().to_string(),
            coverage_label(compat.coverage).to_string(),
            compat.scope.join(", "),
            caveat,
        ]);
    }

    println!(
        "{}",
        render_table(
            &[
                "EVENT",
                "SOURCE NATIVE",
                "TARGET NATIVE",
                "TARGET",
                "STATUS",
                "SCOPE",
                "CAVEAT",
            ],
            &rows,
        )
    );
    Ok(())
}

fn registered_adapters(repo_root: &Path) -> Result<Vec<AdapterKind>> {
    let config = config::read_config(repo_root)?;
    let mut adapters = Vec::new();
    for id in config.agents {
        let adapter = AdapterKind::from_id(&id).ok_or_else(|| {
            TuffError::new(format!(
                "unknown registered agent '{}'; use 'tuff agent list' to inspect config",
                id
            ))
        })?;
        if !adapters.contains(&adapter) {
            adapters.push(adapter);
        }
    }
    Ok(adapters)
}

fn ensure_registered(repo_root: &Path, adapter: AdapterKind) -> Result<()> {
    let registered = registered_adapters(repo_root)?;
    if registered.contains(&adapter) {
        return Ok(());
    }
    Err(TuffError::new(format!(
        "agent '{}' is not registered in this project; run 'tuff agent add {}' first",
        adapter.id(),
        adapter.id()
    )))
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct TrackedHookEvent {
    native_event: String,
    canonical_event: Option<String>,
}

fn tracked_hook_events(entry: &lockfile::CapabilityLockEntry) -> BTreeSet<TrackedHookEvent> {
    entry
        .targets
        .values()
        .flat_map(|target| {
            target.managed_hooks.iter().map(|hook| TrackedHookEvent {
                native_event: hook.event.clone(),
                canonical_event: hook.canonical_event.clone(),
            })
        })
        .collect()
}

fn coverage_label(coverage: CoverageLevel) -> &'static str {
    match coverage {
        CoverageLevel::Full => "full",
        CoverageLevel::Partial => "partial",
        CoverageLevel::Unsupported => "unsupported",
    }
}

fn version_label(entry: &CompatibilityEntry) -> String {
    match (entry.since_harness_version, entry.until_harness_version) {
        (Some(since), Some(until)) => format!("{since}..{until}"),
        (Some(since), None) => format!("since {since}"),
        (None, Some(until)) => format!("until {until}"),
        (None, None) => String::new(),
    }
}