skillnet 0.6.0

Manage canonical AI skill stores, derived views, and calibration data for multi-phase-plan.
Documentation
use anyhow::Result;
use serde::Serialize;

use super::Context;
use crate::{
    cli::args::StatusFormat,
    commands::status::promotion_status_counts,
    model::Target,
    view::{
        materialize_view_with_options, view_diff, view_status, DriftEntry, DriftKind,
        FileDeltaKind, ViewSyncOptions, ViewSyncSummary,
    },
};

pub fn sync(ctx: &Context, allow_delete: bool, force: bool) -> Result<()> {
    let target = ctx.config.global_target(&ctx.mirror_root)?;
    if ctx.dry_run {
        print_dry_run(&target, allow_delete, force);
        return Ok(());
    }

    for view in &target.views {
        let summary = materialize_view_with_options(
            &target.canonical_path,
            view,
            ViewSyncOptions {
                allow_delete,
                force,
                ..ViewSyncOptions::default()
            },
        )?;
        println!(
            "{}  {}",
            view.label,
            format_view_summary(&view.path, &summary)
        );
    }
    Ok(())
}

pub fn status(ctx: &Context, format: StatusFormat) -> Result<()> {
    let target = ctx.config.global_target(&ctx.mirror_root)?;
    let mut rows = Vec::new();
    for view in &target.views {
        let drift = view_status(&target.canonical_path, view)?;
        let promotion_status = promotion_status_counts(&drift);
        rows.push(ViewStatusRow {
            label: view.label.clone(),
            path: view.path.to_string(),
            would_promote: promotion_status.would_promote,
            needs_tie_break: promotion_status.needs_tie_break,
            drift,
        });
    }

    match format {
        StatusFormat::Text => {
            for row in &rows {
                if row.drift.is_empty() {
                    println!("{}  clean", row.label);
                } else {
                    println!("{}  drift ({} entries)", row.label, row.drift.len());
                    for entry in &row.drift {
                        println!("{} {}", drift_marker(entry.kind), entry.skill);
                    }
                }
            }
        }
        StatusFormat::Json => {
            serde_json::to_writer_pretty(std::io::stdout(), &rows)?;
            println!();
        }
    }
    Ok(())
}

pub fn diff(ctx: &Context) -> Result<()> {
    let target = ctx.config.global_target(&ctx.mirror_root)?;
    for view in &target.views {
        println!("# {}", view.label);
        let deltas = view_diff(&target.canonical_path, view)?;
        if deltas.is_empty() {
            println!("clean");
            continue;
        }
        for delta in deltas {
            let marker = match delta.kind {
                FileDeltaKind::Missing => '-',
                FileDeltaKind::Extra => '+',
                FileDeltaKind::Modified => '~',
            };
            println!("{marker} {}", delta.skill);
        }
    }
    Ok(())
}

pub(crate) fn format_view_summary(path: &camino::Utf8Path, summary: &ViewSyncSummary) -> String {
    format!(
        "{} (+{} ~{} ={} -{})",
        path, summary.created, summary.updated, summary.unchanged, summary.removed
    )
}

fn print_dry_run(target: &Target, allow_delete: bool, force: bool) {
    println!("# view sync global");
    println!("from: {}", target.canonical_path);
    println!("allow_delete: {allow_delete}");
    println!("force: {force}");
    for view in &target.views {
        println!("to: {}\t{}", view.label, view.path);
    }
}

fn drift_marker(kind: DriftKind) -> char {
    match kind {
        DriftKind::Missing => '-',
        DriftKind::WrongTarget | DriftKind::NonSymlink => '~',
        DriftKind::Stale => '+',
    }
}

#[derive(Serialize)]
struct ViewStatusRow {
    label: String,
    path: String,
    would_promote: usize,
    needs_tie_break: usize,
    drift: Vec<DriftEntry>,
}