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::new(format!(
"unknown agent '{}'; use 'tuff agent list' to see available agents",
selected[0]
))
})?;
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,
] {
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(),
}
}