skillnet 0.6.0

Manage canonical AI skill stores, derived views, and calibration data for multi-phase-plan.
Documentation
use std::{fs, process::Command as StdCommand};

use anyhow::{bail, Context as AnyhowContext, Result};
use camino::Utf8Path;
use serde::Serialize;
use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table};

use super::{status::promotion_status_counts, view::format_view_summary, Context};
use crate::cli::args::StatusFormat;
use crate::config::{config_is_hm_managed, expand_path, hm_managed_error_message};
use crate::view::{
    materialize_project_with_options, project_diff, project_status, AggregatorStatus, DriftEntry,
    DriftKind, FileDeltaKind, ProjectSyncOptions,
};

pub fn project_list(ctx: &Context) {
    for project in &ctx.config.projects {
        println!("{}\t{}", project.name, project.path);
    }
}

pub fn project_add(ctx: &Context, name: &str, path: &Utf8Path, allow_missing: bool) -> Result<()> {
    if config_is_hm_managed(&ctx.config_path) {
        bail!(hm_managed_error_message(&ctx.config_path, "skillnet.toml"));
    }

    if name.is_empty() || name.contains('/') || name.contains('\\') {
        bail!("project name must be a non-empty scope name, not a path");
    }
    if name == "global" || name == "all" || name == "project" {
        bail!("`{name}` is reserved and cannot be used as a project name");
    }
    if ctx.config.projects.iter().any(|p| p.name == name) {
        bail!("project `{name}` is already configured");
    }

    let expanded_path = expand_path(path.as_str())?;
    if !allow_missing && !expanded_path.is_dir() {
        bail!("project path `{expanded_path}` does not exist or is not a directory");
    }

    if ctx.dry_run {
        println!("add project {name}\t{expanded_path}");
        return Ok(());
    }

    let mut doc = load_config_doc(&ctx.config_path)?;
    projects_array_mut(&mut doc)?.push(project_table(name, &expanded_path));
    write_config_doc(&ctx.config_path, &doc)?;
    println!("added project {name}");
    Ok(())
}

pub fn project_remove(ctx: &Context, name: &str, prune_mirror: bool) -> Result<()> {
    if config_is_hm_managed(&ctx.config_path) {
        bail!(hm_managed_error_message(&ctx.config_path, "skillnet.toml"));
    }

    if prune_mirror {
        ctx.ensure_destination_clean()?;
    }
    let project = ctx
        .project(name)
        .with_context(|| format!("unknown project `{name}`"))?;
    let mirror_path = ctx.mirror_root.join("projects").join(&project.name);

    if ctx.dry_run {
        println!("remove project {name}");
        if prune_mirror {
            println!("delete mirror {mirror_path}");
        }
        return Ok(());
    }

    let mut doc = load_config_doc(&ctx.config_path)?;
    remove_project_from_doc(&mut doc, name)?;
    write_config_doc(&ctx.config_path, &doc)?;

    if prune_mirror && mirror_path.exists() {
        fs::remove_dir_all(&mirror_path)?;
    }

    println!("removed project {name}");
    Ok(())
}

pub fn project_sync(
    ctx: &Context,
    names: &[String],
    all: bool,
    allow_delete: bool,
    force: bool,
) -> Result<()> {
    for target in project_targets(ctx, names, all)? {
        if let Some(project_root) = &target.project_root {
            if !project_root.is_dir() {
                eprintln!(
                    "warn: [{}] project repository path {} does not exist; skipping",
                    target.name, project_root
                );
                continue;
            }
        }
        if ctx.dry_run {
            println!("# project sync {}", target.name);
            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);
            }
            if let Some(aggregator) = &target.aggregator_path {
                println!("aggregator: {aggregator}");
            }
            continue;
        }

        let summary = materialize_project_with_options(
            &target,
            ProjectSyncOptions {
                allow_delete,
                force,
            },
        )?;
        println!("# {}", target.name);
        for view in &summary.views {
            println!(
                "{}  {}",
                view.label,
                format_view_summary(&view.path, &view.summary)
            );
        }
        if let Some(status) = summary.aggregator {
            println!("aggregator  {}", format_aggregator_status(status));
        }
    }
    Ok(())
}

pub fn project_clone_all(ctx: &Context, all: bool, dry_run: bool, ssh_strict: bool) -> Result<()> {
    if !all {
        bail!("must pass --all");
    }

    let dry_run = dry_run || ctx.dry_run;
    let mut cloned_or_planned = false;
    for project in &ctx.config.projects {
        let path = expand_path(&project.path)
            .with_context(|| format!("failed to resolve project path for `{}`", project.name))?;
        if path.exists() {
            continue;
        }
        let Some(origin) = project
            .origin
            .as_deref()
            .filter(|origin| !origin.is_empty())
        else {
            eprintln!(
                "warn: [{}] no origin configured; skipping clone to {}",
                project.name, path
            );
            continue;
        };
        if ssh_strict && is_https_origin(origin) {
            bail!(
                "project `{}` origin `{origin}` uses HTTPS; pass --ssh-strict=false to allow it",
                project.name
            );
        }

        cloned_or_planned = true;
        if dry_run {
            println!("clone {}\t{}\t{}", project.name, origin, path);
            continue;
        }

        create_parent_dir(&path)?;
        let status = StdCommand::new("git")
            .args(["clone", origin, path.as_str()])
            .status()
            .with_context(|| format!("failed to run git clone for project `{}`", project.name))?;
        if !status.success() {
            bail!(
                "git clone failed for project `{}` with status {status}",
                project.name
            );
        }
    }

    if cloned_or_planned {
        if dry_run {
            println!("project sync --all");
        } else {
            project_sync(ctx, &[], true, false, false)?;
        }
    }

    Ok(())
}

pub fn project_status_command(
    ctx: &Context,
    names: &[String],
    all: bool,
    format: StatusFormat,
) -> Result<()> {
    let mut rows = Vec::new();
    for target in project_targets(ctx, names, all)? {
        let drift = project_status(&target)?;
        let promotion_status = promotion_status_counts(&drift);
        rows.push(ProjectStatusRow {
            name: target.name.clone(),
            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.name);
                } else {
                    println!("{}  drift ({} entries)", row.name, 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 project_diff_command(ctx: &Context, names: &[String], all: bool) -> Result<()> {
    for target in project_targets(ctx, names, all)? {
        println!("# {}", target.name);
        let deltas = project_diff(&target)?;
        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(())
}

fn project_targets(
    ctx: &Context,
    names: &[String],
    all: bool,
) -> Result<Vec<crate::model::Target>> {
    if all && !names.is_empty() {
        bail!("use either --all or --name, not both");
    }
    if !all && names.is_empty() {
        bail!("must pass --name or --all");
    }

    let projects = if all {
        ctx.config
            .projects
            .iter()
            .map(|project| project.name.clone())
            .collect::<Vec<_>>()
    } else {
        names.to_vec()
    };

    let mut targets = Vec::with_capacity(projects.len());
    for name in projects {
        let project = ctx
            .project(&name)
            .with_context(|| format!("unknown project `{name}`"))?;
        targets.push(ctx.config.project_target(&ctx.mirror_root, project)?);
    }
    Ok(targets)
}

fn format_aggregator_status(status: AggregatorStatus) -> &'static str {
    match status {
        AggregatorStatus::Created => "created",
        AggregatorStatus::Updated => "updated",
        AggregatorStatus::Unchanged => "unchanged",
    }
}

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

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

fn load_config_doc(path: &Utf8Path) -> Result<DocumentMut> {
    let text =
        fs::read_to_string(path).with_context(|| format!("failed to read config file {path}"))?;
    text.parse::<DocumentMut>()
        .with_context(|| format!("failed to parse config file {path}"))
}

fn write_config_doc(path: &Utf8Path, doc: &DocumentMut) -> Result<()> {
    fs::write(path, doc.to_string()).with_context(|| format!("failed to write config file {path}"))
}

fn projects_array_mut(doc: &mut DocumentMut) -> Result<&mut ArrayOfTables> {
    if doc.get("projects").is_none() {
        doc["projects"] = Item::ArrayOfTables(ArrayOfTables::new());
    }
    doc["projects"]
        .as_array_of_tables_mut()
        .context("config `projects` entry is not an array of tables")
}

fn project_table(name: &str, path: &Utf8Path) -> Table {
    let mut table = Table::new();
    table["name"] = value(name);
    table["path"] = value(path.as_str());
    table
}

fn create_parent_dir(path: &Utf8Path) -> Result<()> {
    let parent = path
        .parent()
        .with_context(|| format!("project path {path} has no parent directory"))?;
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create parent directory {parent}"))
}

fn is_https_origin(origin: &str) -> bool {
    origin.starts_with("https://") || origin.starts_with("http://")
}

fn remove_project_from_doc(doc: &mut DocumentMut, name: &str) -> Result<()> {
    let projects = projects_array_mut(doc)?;
    let idx = projects
        .iter()
        .position(|project| project.get("name").and_then(Item::as_str) == Some(name))
        .with_context(|| format!("unknown project `{name}`"))?;
    projects.remove(idx);
    Ok(())
}