skillnet 0.4.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
use anyhow::{bail, Context};
use serde::Serialize;

use super::{
    catalog::{HeuristicCategory, ThresholdSource, ThresholdStore, HEURISTICS},
    Db,
};

#[derive(Clone, Copy, Debug)]
pub enum OutputFormat {
    Json,
    Table,
}

#[derive(Clone, Copy, Debug)]
pub enum CategoryFilter {
    Coordination,
    Risk,
    PlanShape,
    QualityLint,
}

impl CategoryFilter {
    pub fn category(self) -> HeuristicCategory {
        match self {
            Self::Coordination => HeuristicCategory::Coordination,
            Self::Risk => HeuristicCategory::Risk,
            Self::PlanShape => HeuristicCategory::PlanShape,
            Self::QualityLint => HeuristicCategory::QualityLint,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct HeuristicRow {
    pub name: String,
    pub category: String,
    pub default_threshold: f64,
    pub current_threshold: f64,
    pub threshold_source: ThresholdSource,
    pub description: String,
    pub section_added_template: Option<String>,
}

pub fn list(db: &Db, format: OutputFormat, category: Option<CategoryFilter>) -> anyhow::Result<()> {
    let store = ThresholdStore::load(db)?;
    let rows = rows(&store, category.map(CategoryFilter::category))?;
    match format {
        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rows)?),
        OutputFormat::Table => print_table(&rows),
    }
    Ok(())
}

pub fn show(db: &Db, name: &str) -> anyhow::Result<()> {
    let store = ThresholdStore::load(db)?;
    let row = rows(&store, None)?
        .into_iter()
        .find(|row| row.name == name)
        .with_context(|| format!("unknown heuristic {name}"))?;
    println!("{}", serde_json::to_string_pretty(&row)?);
    Ok(())
}

fn rows(
    store: &ThresholdStore<'_>,
    category: Option<HeuristicCategory>,
) -> anyhow::Result<Vec<HeuristicRow>> {
    HEURISTICS
        .iter()
        .copied()
        .filter(|heuristic| category.is_none_or(|category| heuristic.category() == category))
        .map(|heuristic| {
            let name = heuristic.name();
            let current_threshold = store
                .get_optional(name)
                .with_context(|| format!("missing threshold for heuristic {name}"))?;
            let threshold_source = store.source(name).unwrap_or(ThresholdSource::Default);
            Ok(HeuristicRow {
                name: name.to_string(),
                category: heuristic.category().as_str().to_string(),
                default_threshold: heuristic.default_threshold(),
                current_threshold,
                threshold_source,
                description: heuristic.description().to_string(),
                section_added_template: heuristic.section_added(true).map(str::to_string),
            })
        })
        .collect()
}

fn print_table(rows: &[HeuristicRow]) {
    if rows.is_empty() {
        println!("no heuristics");
        return;
    }
    println!(
        "{:<32} {:<14} {:>8} {:>8} {:<9} SECTION",
        "NAME", "CATEGORY", "DEFAULT", "CURRENT", "SOURCE"
    );
    for row in rows {
        println!(
            "{:<32} {:<14} {:>8.2} {:>8.2} {:<9} {}",
            row.name,
            row.category,
            row.default_threshold,
            row.current_threshold,
            source_label(&row.threshold_source),
            row.section_added_template.as_deref().unwrap_or("")
        );
    }
}

fn source_label(source: &ThresholdSource) -> &'static str {
    match source {
        ThresholdSource::Default => "default",
        ThresholdSource::Override { .. } => "override",
    }
}

pub fn category_from_str(raw: &str) -> anyhow::Result<CategoryFilter> {
    match raw {
        "coordination" => Ok(CategoryFilter::Coordination),
        "risk" => Ok(CategoryFilter::Risk),
        "plan-shape" => Ok(CategoryFilter::PlanShape),
        "quality-lint" => Ok(CategoryFilter::QualityLint),
        _ => bail!(
            "unknown heuristic category {raw}; expected coordination, risk, plan-shape, or quality-lint"
        ),
    }
}