skillnet 0.4.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
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(", ")
    }
}