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;
use serde::Deserialize;

use super::entry::SkillEntry;

pub(super) const VALID_GLOBAL_CATEGORIES: &[&str] = &[
    "rust",
    "nix",
    "ci-release",
    "docs-sites",
    "planning",
    "routing",
    "agent-tools",
    "vendor-platforms",
    "creative-media",
];
pub(super) const VALID_PROJECT_CATEGORIES: &[&str] = &[
    "project-runtime",
    "test-fix-loop",
    "investigation",
    "deployment",
    "review",
    "internal-runtime",
    "domain-workflow",
];
pub(super) const VALID_SCOPES: &[&str] = &["global", "project", "plugin", "vendor"];
pub(super) const VALID_STATUSES: &[&str] =
    &["active", "reference", "internal", "experimental", "retired"];

#[derive(Debug, Default, Deserialize)]
pub(super) struct CatalogConfig {
    #[serde(default)]
    pub(super) settings: CatalogSettings,
    #[serde(default)]
    pub(super) rules: Vec<CatalogRule>,
}

#[derive(Debug, Deserialize)]
pub(super) struct CatalogSettings {
    #[serde(default = "default_large_skill_line_threshold")]
    pub(super) large_skill_line_threshold: usize,
}

impl Default for CatalogSettings {
    fn default() -> Self {
        Self {
            large_skill_line_threshold: default_large_skill_line_threshold(),
        }
    }
}

#[derive(Debug, Deserialize)]
pub(super) struct CatalogRule {
    path_prefix: Option<String>,
    name: Option<String>,
    name_prefix: Option<String>,
    name_suffix: Option<String>,
    project: Option<String>,
    category: Option<String>,
    scope: Option<String>,
    status: Option<String>,
    collision_note: Option<String>,
    #[serde(default)]
    tags: Vec<String>,
    #[serde(default)]
    related_skills: Vec<String>,
}

impl CatalogConfig {
    pub(super) fn load(path: &Utf8Path) -> Result<Self> {
        let text = fs::read_to_string(path)
            .with_context(|| format!("failed to read catalog config file {path}"))?;
        toml::from_str(&text).with_context(|| format!("failed to parse catalog config file {path}"))
    }
}

impl CatalogRule {
    pub(super) fn matches(&self, entry: &SkillEntry, rel: &str) -> bool {
        self.path_prefix
            .as_deref()
            .is_none_or(|prefix| rel.starts_with(prefix))
            && self.name.as_deref().is_none_or(|name| entry.name == name)
            && self
                .name_prefix
                .as_deref()
                .is_none_or(|prefix| entry.name.starts_with(prefix))
            && self
                .name_suffix
                .as_deref()
                .is_none_or(|suffix| entry.name.ends_with(suffix))
            && self
                .project
                .as_deref()
                .is_none_or(|project| entry.project.as_deref() == Some(project))
    }

    pub(super) fn apply(&self, entry: &mut SkillEntry) {
        if let Some(category) = &self.category {
            entry.category = Some(category.clone());
        }
        if let Some(scope) = &self.scope {
            entry.scope = scope.clone();
        }
        if let Some(status) = &self.status {
            entry.status = status.clone();
        }
        if let Some(collision_note) = &self.collision_note {
            entry.collision_note = Some(collision_note.clone());
        }
        entry.tags.extend(self.tags.clone());
        entry.related_skills.extend(self.related_skills.clone());
    }
}

fn default_large_skill_line_threshold() -> usize {
    220
}