skillnet 0.3.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
use std::fs;

use anyhow::{Context as AnyhowContext, Result};
use camino::{Utf8Path, Utf8PathBuf};

use crate::commands::Context;

use super::config::CatalogConfig;
use super::entry::SkillEntry;
use super::frontmatter::parse_frontmatter;

pub(super) fn load_entries(ctx: &Context, config: &CatalogConfig) -> Result<Vec<SkillEntry>> {
    let mut entries = Vec::new();
    let global_root = ctx.mirror_root.join("global");
    if global_root.exists() {
        for skill in skill_dirs(&global_root)? {
            entries.push(entry_from_skill(ctx, config, &skill, None)?);
        }
    }

    let projects_root = ctx.mirror_root.join("projects");
    if projects_root.exists() {
        for project in sorted_child_dirs(&projects_root)? {
            let project_name = project
                .file_name()
                .context("project path has no final component")?
                .to_string();
            for skill in skill_dirs(&project)? {
                entries.push(entry_from_skill(ctx, config, &skill, Some(&project_name))?);
            }
        }
    }

    entries.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
    Ok(entries)
}

fn entry_from_skill(
    ctx: &Context,
    config: &CatalogConfig,
    skill_dir: &Utf8Path,
    project: Option<&str>,
) -> Result<SkillEntry> {
    let skill_file = skill_dir.join("SKILL.md");
    let body = fs::read_to_string(&skill_file)
        .with_context(|| format!("failed to read skill file {skill_file}"))?;
    let frontmatter = parse_frontmatter(&body);
    let name = skill_dir
        .file_name()
        .unwrap_or("unknown")
        .trim()
        .to_string();
    let rel = relative_to(&ctx.mirror_root, skill_dir);
    let mut entry = SkillEntry {
        qualified_name: match project {
            Some(project) => format!("{project}/{name}"),
            None => format!("global/{name}"),
        },
        name,
        scope: if project.is_some() {
            "project".to_string()
        } else {
            "global".to_string()
        },
        project: project.map(ToOwned::to_owned),
        category: None,
        status: "active".to_string(),
        tags: Vec::new(),
        related_skills: Vec::new(),
        collision_note: None,
        description: frontmatter.description.unwrap_or_default(),
        path: skill_dir.to_path_buf(),
        line_count: body.lines().count(),
    };

    for rule in &config.rules {
        if rule.matches(&entry, &rel) {
            rule.apply(&mut entry);
        }
    }
    entry.tags.sort();
    entry.tags.dedup();
    entry.related_skills.sort();
    entry.related_skills.dedup();
    Ok(entry)
}

fn skill_dirs(root: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
    let mut dirs = Vec::new();
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in mirror: {}", p.display()))?;
        if path.is_dir() && path.join("SKILL.md").is_file() {
            dirs.push(path);
        }
    }
    dirs.sort();
    Ok(dirs)
}

fn sorted_child_dirs(root: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
    let mut dirs = Vec::new();
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in mirror: {}", p.display()))?;
        if path.is_dir() {
            dirs.push(path);
        }
    }
    dirs.sort();
    Ok(dirs)
}

fn relative_to(root: &Utf8Path, path: &Utf8Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .as_str()
        .trim_start_matches('/')
        .to_string()
}