tuffcli 0.9.0

Capability lifecycle manager for coding agents.
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::path::Path;

use serde::Serialize;

use crate::error::Result;
use crate::lockfile::{self};
use crate::manifest::{CapabilityType, load_manifest};

use super::{home_dir_opt, render_table, short_sha};
use super::{style_capability_type, style_drift_status};

/// One `tuff list` row: a capability at one target, with the files it
/// emitted there folded into a single status. Serialized as-is for
/// `--json`, with the same `type`/`target` keys as `tuff check --json`.
#[derive(Serialize)]
struct ListRow {
    id: String,
    #[serde(rename = "type")]
    capability_type: CapabilityType,
    version: String,
    /// `semver`, `declared`, or `sha`: what `version` is (RFC-101).
    version_scheme: lockfile::VersionScheme,
    #[serde(skip)]
    source_type: String,
    scope: String,
    target: String,
    status: String,
    path: String,
}

#[derive(Clone)]
pub(crate) struct InventoryRow {
    pub id: String,
    pub capability_type: CapabilityType,
    pub version: String,
    pub version_scheme: lockfile::VersionScheme,
    pub scope: String,
    pub target: String,
    pub status: String,
    pub path: String,
    pub description: String,
    pub source_type: String,
}

pub fn cmd_list(
    repo_root: &Path,
    scope_filter: &str,
    kind_filter: Option<&str>,
    json: bool,
) -> Result<()> {
    let kind_type = kind_filter.and_then(CapabilityType::parse);

    let show_project = scope_filter == "all" || scope_filter == "project";
    let show_global = scope_filter == "all" || scope_filter == "global";

    let mut inventory: Vec<InventoryRow> = Vec::new();

    if show_project
        && let Some(lf) = lockfile::read_optional_lockfile(&lockfile::project_lockfile(repo_root))?
    {
        inventory.extend(collect_lockfile_inventory(
            repo_root, &lf, "project", None, kind_type,
        ));
    }

    if show_global && let Some(home) = home_dir_opt() {
        let lock_path = crate::paths::global_lockfile(&home);
        if let Some(lf) = lockfile::read_optional_lockfile(&lock_path)? {
            inventory.extend(collect_lockfile_inventory(
                &home,
                &lf,
                "global",
                Some("~/"),
                kind_type,
            ));
        }
    }

    if inventory.is_empty() {
        if json {
            println!("[]");
        } else {
            println!("no capabilities installed");
        }
        return Ok(());
    }

    let mut rows = collapse_inventory_for_list(inventory);
    rows.sort_by(|a, b| a.scope.cmp(&b.scope).then_with(|| a.id.cmp(&b.id)));

    if json {
        println!("{}", serde_json::to_string_pretty(&rows)?);
        return Ok(());
    }

    let table_rows: Vec<Vec<String>> = rows
        .into_iter()
        .map(|row| {
            // A version the source merely declared is weaker than a
            // release, and for a git install the table says so.
            let version = if row.source_type == "git"
                && row.version_scheme == lockfile::VersionScheme::Declared
            {
                format!("{} (declared)", row.version)
            } else {
                row.version
            };
            vec![
                row.id,
                style_capability_type(row.capability_type),
                version,
                row.scope,
                row.target,
                style_drift_status(&row.status),
                row.path,
            ]
        })
        .collect();
    println!(
        "{}",
        render_table(
            &["ID", "TYPE", "VERSION", "SCOPE", "AGENT", "STATUS", "PATH"],
            &table_rows
        )
    );
    Ok(())
}

pub(crate) fn collect_lockfile_inventory(
    root: &Path,
    lf: &lockfile::Lockfile,
    scope: &str,
    path_prefix: Option<&str>,
    kind_filter: Option<CapabilityType>,
) -> Vec<InventoryRow> {
    let mut rows = Vec::new();

    for (id, entry) in &lf.capabilities {
        if let Some(kind) = kind_filter
            && entry.capability_type != kind
        {
            continue;
        }

        let description = capability_description(root, entry);
        let source_type = capability_source_type(entry).to_string();

        for (target_id, target_entry) in &entry.targets {
            let managed_status = target_entry
                .managed_hooks
                .iter()
                .map(|hook| lockfile::managed_hook_status(root, hook))
                .chain(target_entry.managed_mcp_entry.iter().map(|managed_entry| {
                    lockfile::managed_mcp_entry_status(root, id, managed_entry)
                }))
                .find(|status| *status != "clean")
                .unwrap_or("clean");
            let installed_path = target_entry.installed_path.clone();
            let status = if installed_path.is_empty() {
                "error"
            } else {
                match crate::cache::hash_tree(&root.join(&installed_path)) {
                    Ok(hash) if hash == target_entry.sha256 => "clean",
                    Ok(_) | Err(_) => "modified",
                }
            };
            rows.push(InventoryRow {
                id: id.clone(),
                capability_type: entry.capability_type,
                version: short_sha(&entry.version).to_string(),
                version_scheme: entry.version_scheme,
                scope: scope.to_string(),
                target: target_id.clone(),
                status: if managed_status == "clean" {
                    status.to_string()
                } else {
                    managed_status.to_string()
                },
                path: match path_prefix {
                    Some(prefix) => format!("{prefix}{installed_path}"),
                    None => installed_path,
                },
                description: description.clone(),
                source_type: source_type.clone(),
            });
        }
    }

    rows
}

fn capability_description(root: &Path, entry: &lockfile::CapabilityLockEntry) -> String {
    if !entry.description.trim().is_empty() {
        return entry.description.clone();
    }

    let Some(source_path) = entry
        .source
        .local_path()
        .filter(|path| !path.trim().is_empty())
    else {
        return String::new();
    };

    let source_path = Path::new(source_path);
    let capability_dir = if source_path.is_absolute() {
        source_path.to_path_buf()
    } else {
        root.join(source_path)
    };

    load_manifest(&capability_dir)
        .map(|manifest| manifest.description)
        .unwrap_or_default()
}

fn capability_source_type(entry: &lockfile::CapabilityLockEntry) -> String {
    entry.source.kind().to_string()
}

pub(crate) fn project_inventory(
    repo_root: &Path,
    kind_filter: Option<CapabilityType>,
) -> Result<Vec<InventoryRow>> {
    let lf = lockfile::require_lockfile(repo_root)?;
    Ok(collect_lockfile_inventory(
        repo_root,
        &lf,
        "project",
        None,
        kind_filter,
    ))
}

fn collapse_inventory_for_list(inventory: Vec<InventoryRow>) -> Vec<ListRow> {
    let mut grouped: BTreeMap<(String, String, String), Vec<InventoryRow>> = BTreeMap::new();
    for row in inventory {
        grouped
            .entry((row.scope.clone(), row.id.clone(), row.target.clone()))
            .or_default()
            .push(row);
    }

    grouped
        .into_values()
        .map(|mut rows| {
            rows.sort_by(|a, b| a.path.cmp(&b.path));
            let first = rows[0].clone();
            let status = aggregate_status(&rows);
            let path = display_path_for_rows(&rows);
            ListRow {
                id: first.id,
                capability_type: first.capability_type,
                version: first.version,
                version_scheme: first.version_scheme,
                source_type: first.source_type,
                scope: first.scope,
                target: first.target,
                status: status.to_string(),
                path,
            }
        })
        .collect()
}

fn aggregate_status(rows: &[InventoryRow]) -> &'static str {
    if rows.iter().any(|row| row.status == "missing") {
        "missing"
    } else if rows.iter().any(|row| row.status == "modified") {
        "modified"
    } else {
        "clean"
    }
}

fn display_path_for_rows(rows: &[InventoryRow]) -> String {
    let primary = rows
        .iter()
        .find(|row| Path::new(&row.path).file_name() == Some(OsStr::new("SKILL.md")))
        .unwrap_or(&rows[0]);

    if rows.len() > 1 {
        format!("{} (+{} files)", primary.path, rows.len() - 1)
    } else {
        primary.path.clone()
    }
}