use std::{collections::BTreeMap, fs};
use anyhow::{Context as AnyhowContext, Result};
use camino::Utf8Path;
use crate::commands::Context;
use super::config::{VALID_GLOBAL_CATEGORIES, VALID_PROJECT_CATEGORIES};
use super::entry::SkillEntry;
use super::validate::duplicates_by_name;
pub(super) fn render_catalog(entries: &[SkillEntry]) -> Result<String> {
let mut out = String::new();
out.push_str("# Skill Catalog\n\n");
out.push_str("Generated by `skillnet catalog generate`.\n\n");
out.push_str("## Global Skills\n\n");
for category in VALID_GLOBAL_CATEGORIES {
render_category_table(&mut out, entries, None, category);
}
out.push_str("## Project Skills\n\n");
let mut by_project = BTreeMap::<String, Vec<&SkillEntry>>::new();
for entry in entries.iter().filter(|entry| entry.project.is_some()) {
by_project
.entry(entry.project.clone().unwrap())
.or_default()
.push(entry);
}
for (project, project_entries) in by_project {
out.push_str(&format!("### {project}\n\n"));
for category in VALID_PROJECT_CATEGORIES {
render_category_table_for_entries(&mut out, &project_entries, category);
}
}
Ok(out)
}
pub(super) fn render_routing(entries: &[SkillEntry]) -> Result<String> {
let mut out = String::new();
out.push_str("# Skill Routing Guide\n\n");
out.push_str("Generated by `skillnet catalog generate`.\n\n");
out.push_str("## High-Value Families\n\n");
render_family(
&mut out,
"Fix loops",
entries,
|entry| entry.name.starts_with("fix-loop-"),
"Specialized test/fix loops. Prefer the narrowest transport, platform, or matrix skill that matches the failing surface.",
);
render_family(
&mut out,
"Yee-haw runtime",
entries,
|entry| entry.name.starts_with("yh-"),
"Internal runtime skills. Keep available for automation, but hide from normal browsing emphasis.",
);
render_family(
&mut out,
"Rust crate release pipeline",
entries,
|entry| entry.name.starts_with("rust-crate-"),
"Pipeline order: metadata -> legal/readme -> rustdoc -> nix tooling -> quality gates -> CI -> publish.",
);
render_family(
&mut out,
"Forgejo and Codeberg",
entries,
|entry| entry.name.contains("forgejo") || entry.name.contains("codeberg"),
"Use CI/release skills for automation and docs/site skills for Pages/mdBook/Zola work.",
);
render_family(
&mut out,
"ETL and figure layout",
entries,
|entry| entry.name.starts_with("etl") || entry.name.starts_with("figure-layout"),
"SynDB domain workflows. Prefer task-specific ETL, figure, manuscript, and deployment skills.",
);
render_family(
&mut out,
"Code review",
entries,
|entry| entry.name.starts_with("code-review"),
"Project-qualified review skills. Use the project-local variant for repository-specific norms.",
);
Ok(out)
}
pub(super) fn render_conflicts(entries: &[SkillEntry]) -> String {
let mut out = String::new();
out.push_str("# Skill Conflict And Overlap Report\n\n");
out.push_str("Generated by `skillnet catalog generate`.\n\n");
for (name, duplicates) in duplicates_by_name(entries) {
if duplicates.len() < 2 {
continue;
}
out.push_str(&format!("## `{name}`\n\n"));
out.push_str("| Skill | Category | Collision Note |\n");
out.push_str("|---|---|---|\n");
for entry in duplicates {
out.push_str(&format!(
"| `{}` | `{}` | {} |\n",
entry.qualified_name,
entry.category.as_deref().unwrap_or("uncategorized"),
table_text(entry.collision_note.as_deref().unwrap_or(""))
));
}
out.push('\n');
}
out
}
pub(super) fn write_project_indexes(ctx: &Context, entries: &[SkillEntry]) -> Result<()> {
let mut by_project = BTreeMap::<String, Vec<&SkillEntry>>::new();
for entry in entries.iter().filter(|entry| entry.project.is_some()) {
by_project
.entry(entry.project.clone().unwrap())
.or_default()
.push(entry);
}
for (project, mut project_entries) in by_project {
project_entries.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
let mut out = String::new();
out.push_str(&format!("# {project} Skill Index\n\n"));
out.push_str("Generated by `skillnet catalog generate`.\n\n");
out.push_str("| Skill | Category | Status | Description |\n");
out.push_str("|---|---|---|---|\n");
for entry in project_entries {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} |\n",
entry.name,
entry.category.as_deref().unwrap_or("uncategorized"),
entry.status,
table_text(&entry.description)
));
}
let path = ctx
.mirror_root
.join("projects")
.join(project)
.join("INDEX.md");
write_generated_doc(&path, &out)?;
}
Ok(())
}
pub(super) fn write_generated_doc(path: &Utf8Path, body: &str) -> Result<()> {
let mut body = body.trim_end().to_string();
body.push('\n');
fs::write(path, body).with_context(|| format!("failed to write {path}"))
}
fn render_category_table(
out: &mut String,
entries: &[SkillEntry],
project: Option<&str>,
category: &str,
) {
let filtered = entries
.iter()
.filter(|entry| entry.project.as_deref() == project)
.filter(|entry| entry.category.as_deref() == Some(category))
.collect::<Vec<_>>();
render_category_table_for_entries(out, &filtered, category);
}
fn render_category_table_for_entries(out: &mut String, entries: &[&SkillEntry], category: &str) {
let mut sorted = entries
.iter()
.copied()
.filter(|entry| entry.category.as_deref() == Some(category))
.collect::<Vec<_>>();
if sorted.is_empty() {
return;
}
out.push_str(&format!("#### {category}\n\n"));
out.push_str("| Skill | Status | Tags | Description |\n");
out.push_str("|---|---|---|---|\n");
sorted.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
for entry in sorted {
out.push_str(&format!(
"| `{}` | `{}` | {} | {} |\n",
entry.qualified_name,
entry.status,
comma_list(&entry.tags),
table_text(&entry.description)
));
}
out.push('\n');
}
fn render_family<F>(out: &mut String, title: &str, entries: &[SkillEntry], filter: F, note: &str)
where
F: Fn(&SkillEntry) -> bool,
{
let mut filtered = entries
.iter()
.filter(|entry| filter(entry))
.collect::<Vec<_>>();
if filtered.is_empty() {
return;
}
filtered.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
out.push_str(&format!("### {title}\n\n{note}\n\n"));
out.push_str("| Skill | Category | Status | Description |\n");
out.push_str("|---|---|---|---|\n");
for entry in filtered {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} |\n",
entry.qualified_name,
entry.category.as_deref().unwrap_or("uncategorized"),
entry.status,
table_text(&entry.description)
));
}
out.push('\n');
}
fn table_text(text: &str) -> String {
text.replace('|', "\\|").replace('\n', " ")
}
fn comma_list(items: &[String]) -> String {
if items.is_empty() {
String::new()
} else {
items.join(", ")
}
}