skillnet 0.3.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use super::config::{
    CatalogConfig, VALID_GLOBAL_CATEGORIES, VALID_PROJECT_CATEGORIES, VALID_SCOPES, VALID_STATUSES,
};
use super::entry::SkillEntry;

pub(super) fn validate_entries(entries: &[SkillEntry], config: &CatalogConfig) -> Vec<String> {
    let mut errors = Vec::new();
    let categories = VALID_GLOBAL_CATEGORIES
        .iter()
        .chain(VALID_PROJECT_CATEGORIES.iter())
        .copied()
        .collect::<BTreeSet<_>>();
    let qualified_names = entries
        .iter()
        .map(|entry| entry.qualified_name.as_str())
        .collect::<BTreeSet<_>>();
    let names = entries
        .iter()
        .map(|entry| entry.name.as_str())
        .collect::<BTreeSet<_>>();

    for entry in entries {
        if entry.description.is_empty() {
            errors.push(format!("{}: missing description", entry.qualified_name));
        }
        match &entry.category {
            Some(category) if categories.contains(category.as_str()) => {}
            Some(category) => errors.push(format!(
                "{}: unknown category `{category}`",
                entry.qualified_name
            )),
            None => errors.push(format!("{}: missing category", entry.qualified_name)),
        }
        if !VALID_SCOPES.contains(&entry.scope.as_str()) {
            errors.push(format!(
                "{}: unknown scope `{}`",
                entry.qualified_name, entry.scope
            ));
        }
        if !VALID_STATUSES.contains(&entry.status.as_str()) {
            errors.push(format!(
                "{}: unknown status `{}`",
                entry.qualified_name, entry.status
            ));
        }
        for related in &entry.related_skills {
            if !qualified_names.contains(related.as_str()) && !names.contains(related.as_str()) {
                errors.push(format!(
                    "{}: related skill `{related}` does not exist",
                    entry.qualified_name
                ));
            }
        }
        if entry.line_count > config.settings.large_skill_line_threshold
            && !entry.tags.iter().any(|tag| tag == "large-skill")
        {
            errors.push(format!(
                "{}: large skill has {} lines but is not tagged `large-skill`",
                entry.qualified_name, entry.line_count
            ));
        }
    }

    for (name, duplicates) in duplicates_by_name(entries) {
        if duplicates.len() > 1
            && duplicates
                .iter()
                .all(|entry| entry.collision_note.is_none())
        {
            errors.push(format!(
                "duplicate skill `{name}` lacks collision notes: {}",
                duplicates
                    .iter()
                    .map(|entry| entry.qualified_name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }
    }
    errors
}

pub(super) fn duplicates_by_name(entries: &[SkillEntry]) -> BTreeMap<&str, Vec<&SkillEntry>> {
    let mut by_name = BTreeMap::<&str, Vec<&SkillEntry>>::new();
    for entry in entries {
        by_name.entry(entry.name.as_str()).or_default().push(entry);
    }
    by_name
}