tuffcli 0.6.0

Capability lifecycle manager for coding agents.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

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

use super::list::{InventoryRow, project_inventory};
use super::{markdown_escape, resolve_agent_selection};

pub fn cmd_generate_index(
    repo_root: &Path,
    agent: Option<&str>,
    output: Option<&Path>,
) -> Result<()> {
    let requested = agent
        .map(|value| vec![value.to_string()])
        .unwrap_or_default();
    let selected = resolve_agent_selection(repo_root, &requested, false)?;
    let adapter = AdapterKind::from_id(&selected[0]).ok_or_else(|| {
        TuffError::usage(format!("unknown agent '{}'", selected[0],))
            .with_hint("run 'tuff agent list' to see available agents")
    })?;
    let canonical_agent = adapter.id();

    let mut rows: Vec<InventoryRow> = project_inventory(repo_root, None)?
        .into_iter()
        .filter(|row| row.target == canonical_agent)
        .collect();
    rows.sort_by(|a, b| {
        a.capability_type
            .cmp(&b.capability_type)
            .then_with(|| a.id.cmp(&b.id))
            .then_with(|| a.path.cmp(&b.path))
    });

    let output_path = output
        .map(Path::to_path_buf)
        .unwrap_or_else(|| default_index_path(repo_root, adapter));
    write_generated_file(&output_path, &render_index_markdown(adapter, &rows))?;
    println!(
        "generated index for {} -> {}",
        canonical_agent,
        lockfile::relative_or_absolute_fs(&output_path, repo_root)
    );
    Ok(())
}

pub fn cmd_generate_report(repo_root: &Path, output: Option<&Path>) -> Result<()> {
    let mut rows = project_inventory(repo_root, None)?;
    rows.sort_by(|a, b| {
        a.target
            .cmp(&b.target)
            .then_with(|| a.capability_type.cmp(&b.capability_type))
            .then_with(|| a.id.cmp(&b.id))
            .then_with(|| a.path.cmp(&b.path))
    });

    let output_path = output
        .map(Path::to_path_buf)
        .unwrap_or_else(|| repo_root.join("tuff-report.md"));
    write_generated_file(&output_path, &render_report_markdown(&rows))?;
    println!(
        "generated report -> {}",
        lockfile::relative_or_absolute_fs(&output_path, repo_root)
    );
    Ok(())
}

fn default_index_path(repo_root: &Path, adapter: AdapterKind) -> PathBuf {
    repo_root.join(adapter.dir_prefix()).join("CAPABILITIES.md")
}

fn write_generated_file(path: &Path, content: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)?;
    Ok(())
}

fn render_index_markdown(adapter: AdapterKind, rows: &[InventoryRow]) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "# Capability Index: {}\n\n",
        adapter.display_name()
    ));
    out.push_str("Generated by Tuff from tracked project capabilities.\n\n");

    if rows.is_empty() {
        out.push_str("No capabilities are currently tracked for this agent.\n");
        return out;
    }

    for capability_type in ordered_capability_types(rows) {
        let type_rows: Vec<&InventoryRow> = rows
            .iter()
            .filter(|row| row.capability_type == capability_type)
            .collect();
        out.push_str(&format!(
            "## {}\n\n",
            capability_type_heading(capability_type)
        ));
        for group in group_capability_rows(type_rows) {
            write_capability_markdown(&mut out, &group);
        }
    }

    out
}

fn render_report_markdown(rows: &[InventoryRow]) -> String {
    let mut out = String::new();
    out.push_str("# Tuff Report\n\n");
    out.push_str("Generated by Tuff from project tracking state.\n\n");

    let capability_count = rows
        .iter()
        .map(|row| (&row.id, &row.target))
        .collect::<BTreeSet<_>>()
        .len();
    let clean = rows.iter().filter(|row| row.status == "clean").count();
    let modified = rows.iter().filter(|row| row.status == "modified").count();
    let missing = rows.iter().filter(|row| row.status == "missing").count();

    out.push_str("## Summary\n\n");
    out.push_str(&format!("- Capabilities: {capability_count}\n"));
    out.push_str(&format!("- Tracked files: {}\n", rows.len()));
    out.push_str(&format!("- Clean files: {clean}\n"));
    out.push_str(&format!("- Modified files: {modified}\n"));
    out.push_str(&format!("- Missing files: {missing}\n\n"));

    if rows.is_empty() {
        out.push_str("No project capabilities are currently tracked.\n");
        return out;
    }

    out.push_str("## Agents\n\n");
    for agent in ordered_agents(rows) {
        let agent_rows: Vec<&InventoryRow> =
            rows.iter().filter(|row| row.target == agent).collect();
        let agent_clean = agent_rows
            .iter()
            .filter(|row| row.status == "clean")
            .count();
        let agent_modified = agent_rows
            .iter()
            .filter(|row| row.status == "modified")
            .count();
        let agent_missing = agent_rows
            .iter()
            .filter(|row| row.status == "missing")
            .count();
        out.push_str(&format!(
            "- `{}`: {} files, {} clean, {} modified, {} missing\n",
            markdown_escape(&agent),
            agent_rows.len(),
            agent_clean,
            agent_modified,
            agent_missing
        ));
    }
    out.push('\n');

    out.push_str("## Capabilities\n\n");
    for group in group_capability_rows(rows.iter().collect()) {
        write_capability_markdown(&mut out, &group);
    }

    out
}

struct CapabilityGroup<'a> {
    id: &'a str,
    capability_type: CapabilityType,
    version: &'a str,
    target: &'a str,
    scope: &'a str,
    description: &'a str,
    source_type: &'a str,
    status: &'a str,
    paths: Vec<&'a str>,
}

fn group_capability_rows(rows: Vec<&InventoryRow>) -> Vec<CapabilityGroup<'_>> {
    let mut grouped: BTreeMap<(&str, &str, CapabilityType), Vec<&InventoryRow>> = BTreeMap::new();
    for row in rows {
        grouped
            .entry((&row.id, &row.target, row.capability_type))
            .or_default()
            .push(row);
    }

    grouped
        .into_values()
        .map(|mut rows| {
            rows.sort_by(|a, b| a.path.cmp(&b.path));
            let first = rows[0];
            let status = if rows.iter().any(|row| row.status == "missing") {
                "missing"
            } else if rows.iter().any(|row| row.status == "modified") {
                "modified"
            } else {
                "clean"
            };
            CapabilityGroup {
                id: &first.id,
                capability_type: first.capability_type,
                version: &first.version,
                target: &first.target,
                scope: &first.scope,
                description: &first.description,
                source_type: &first.source_type,
                status,
                paths: rows.iter().map(|row| row.path.as_str()).collect(),
            }
        })
        .collect()
}

fn write_capability_markdown(out: &mut String, group: &CapabilityGroup<'_>) {
    out.push_str(&format!(
        "- `{}` ({}, version `{}`) - `{}`\n",
        markdown_escape(group.id),
        markdown_escape(group.capability_type.as_str()),
        markdown_escape(group.version),
        markdown_escape(group.status)
    ));
    if !group.description.trim().is_empty() {
        out.push_str(&format!(
            "  - Description: {}\n",
            markdown_escape(group.description)
        ));
    }
    out.push_str(&format!(
        "  - Agent: `{}`; scope: `{}`; source: `{}`\n",
        markdown_escape(group.target),
        markdown_escape(group.scope),
        markdown_escape(group.source_type)
    ));
    for path in &group.paths {
        out.push_str(&format!("  - Path: `{}`\n", markdown_escape(path)));
    }
    out.push('\n');
}

fn ordered_capability_types(rows: &[InventoryRow]) -> Vec<CapabilityType> {
    let mut remaining: BTreeSet<CapabilityType> =
        rows.iter().map(|row| row.capability_type).collect();
    let mut ordered = Vec::new();
    for capability_type in [
        CapabilityType::Skill,
        CapabilityType::Tool,
        CapabilityType::Hook,
        CapabilityType::Workflow,
        CapabilityType::McpServer,
    ] {
        if remaining.remove(&capability_type) {
            ordered.push(capability_type);
        }
    }
    ordered.extend(remaining);
    ordered
}

fn ordered_agents(rows: &[InventoryRow]) -> Vec<String> {
    rows.iter()
        .map(|row| row.target.clone())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn capability_type_heading(capability_type: CapabilityType) -> String {
    match capability_type {
        CapabilityType::Skill => "Skills".to_string(),
        CapabilityType::Tool => "Tools".to_string(),
        CapabilityType::Hook => "Hooks".to_string(),
        CapabilityType::Workflow => "Workflows".to_string(),
        CapabilityType::Policy => "Policies".to_string(),
        CapabilityType::McpServer => "MCP Servers".to_string(),
    }
}