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::{bail, Context as AnyhowContext, Result};
use camino::{Utf8Path, Utf8PathBuf};
use walkdir::WalkDir;

use super::Context;
use crate::cli::{Scope, SkillPath};
use crate::{catalog, fs_ops};

pub fn show(ctx: &Context, skill_path: &SkillPath) -> Result<()> {
    let target = ctx.target(&skill_path.scope)?;
    let path = target.mirror_path.join(&skill_path.skill);
    ensure_skill_exists(skill_path, &path)?;

    let skill_file = path.join("SKILL.md");
    let body = fs::read_to_string(&skill_file)
        .with_context(|| format!("failed to read skill file {skill_file}"))?;
    let frontmatter = catalog::parse_frontmatter(&body);
    let (file_count, dir_count) = tree_counts(&path)?;
    let entries = top_level_entries(&path)?;
    let catalog_entry = catalog::entry_for(ctx, skill_path)?;

    println!("skill: {}/{}", skill_path.scope, skill_path.skill);
    println!("path:  {}", display_path(&path));
    println!("files: {file_count} files, {dir_count} dirs");
    for entry in entries {
        println!("  {entry}");
    }
    println!();
    println!("frontmatter:");
    println!(
        "  name:        {}",
        frontmatter.name.as_deref().unwrap_or("(none)")
    );
    println!(
        "  description: {}",
        frontmatter.description.as_deref().unwrap_or("(none)")
    );
    println!();

    if let Some(entry) = catalog_entry {
        println!("catalog entry:");
        println!("  description:    {}", empty_as_none(&entry.description));
        println!(
            "  category:       {}",
            entry.category.as_deref().unwrap_or("uncategorized")
        );
        println!("  status:         {}", entry.status);
        println!("  tags:           {}", comma_list(&entry.tags));
        println!("  related_skills: {}", comma_list(&entry.related_skills));
        println!("  scope:          {}", entry.scope);
        if let Some(project) = &entry.project {
            println!("  project:        {project}");
        }
        println!("  catalog_path:   {}", entry.path);
        println!("  lines:          {}", entry.line_count);
        if let Some(note) = &entry.collision_note {
            println!("  collision_note: {note}");
        }
    } else {
        println!("catalog entry: (none - run 'skillnet catalog generate')");
    }

    Ok(())
}

pub fn delete(ctx: &Context, skill_path: &SkillPath) -> Result<()> {
    let target = ctx.target(&skill_path.scope)?;
    let path = target.mirror_path.join(&skill_path.skill);
    ensure_skill_exists(skill_path, &path)?;
    if ctx.dry_run {
        println!("delete {path}");
    } else {
        fs::remove_dir_all(&path)?;
    }
    Ok(())
}

pub fn rename(ctx: &Context, skill_path: &SkillPath, new: &str, force: bool) -> Result<()> {
    let target = ctx.target(&skill_path.scope)?;
    let src = target.mirror_path.join(&skill_path.skill);
    let dest = target.mirror_path.join(new);
    ensure_skill_exists(skill_path, &src)?;
    prepare_dest(&src, &dest, force)?;
    if ctx.dry_run {
        println!("rename {src} {dest}");
    } else {
        if dest.exists() {
            fs::remove_dir_all(&dest)?;
        }
        fs::rename(&src, &dest)?;
    }
    Ok(())
}

#[allow(clippy::too_many_arguments)]
pub fn move_skill(
    ctx: &Context,
    from_path: &SkillPath,
    to_scope: &Scope,
    as_name: Option<&str>,
    copy: bool,
    force: bool,
) -> Result<()> {
    let from = ctx.target(&from_path.scope)?;
    let to = ctx.target(to_scope)?;
    let src = from.mirror_path.join(&from_path.skill);
    let dest = to.mirror_path.join(as_name.unwrap_or(&from_path.skill));
    ensure_skill_exists(from_path, &src)?;
    prepare_dest(&src, &dest, force)?;

    if ctx.dry_run {
        let action = if copy { "copy" } else { "move" };
        println!("{action} {src} {dest}");
    } else {
        if dest.exists() {
            fs::remove_dir_all(&dest)?;
        }
        if copy {
            fs_ops::copy_dir(&src, &dest)?;
        } else {
            fs::create_dir_all(dest.parent().context("destination has no parent")?)?;
            fs::rename(&src, &dest)?;
        }
    }
    Ok(())
}

fn prepare_dest(src: &Utf8Path, dest: &Utf8Path, force: bool) -> Result<()> {
    if !dest.exists() {
        return Ok(());
    }
    fs_ops::ensure_skill_dir(dest)?;
    if force {
        return Ok(());
    }

    let src_mtime = fs_ops::newest_mtime_nanos(src)?;
    let dest_mtime = fs_ops::newest_mtime_nanos(dest)?;
    if src_mtime > dest_mtime {
        return Ok(());
    }
    if src_mtime == dest_mtime
        && fs_ops::content_signature(src)? == fs_ops::content_signature(dest)?
    {
        return Ok(());
    }
    bail!("destination `{dest}` exists and is not older; pass --force to overwrite");
}

fn ensure_skill_exists(skill_path: &SkillPath, path: &Utf8Path) -> Result<()> {
    if !path.exists() {
        bail!(
            "skill `{}` not found in scope `{}`",
            skill_path.skill,
            skill_path.scope
        );
    }
    fs_ops::ensure_skill_dir(path)
}

fn tree_counts(path: &Utf8Path) -> Result<(usize, usize)> {
    let mut files = 0;
    let mut dirs = 0;
    for entry in WalkDir::new(path).follow_links(false).min_depth(1) {
        let entry = entry?;
        if entry.file_type().is_dir() {
            dirs += 1;
        } else if entry.file_type().is_file() || entry.file_type().is_symlink() {
            files += 1;
        }
    }
    Ok((files, dirs))
}

fn top_level_entries(path: &Utf8Path) -> Result<Vec<String>> {
    let mut entries = Vec::new();
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let entry_path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in skill tree: {}", p.display()))?;
        let name = entry_path.file_name().unwrap_or_default();
        let metadata = fs::symlink_metadata(&entry_path)?;
        let rendered = if metadata.is_dir() {
            format!("{name}/")
        } else {
            format!("{name} ({} bytes)", metadata.len())
        };
        entries.push(rendered);
    }
    entries.sort();
    Ok(entries)
}

fn display_path(path: &Utf8Path) -> String {
    fs::canonicalize(path)
        .ok()
        .and_then(|path| Utf8PathBuf::from_path_buf(path).ok())
        .unwrap_or_else(|| path.to_path_buf())
        .to_string()
}

fn comma_list(items: &[String]) -> String {
    if items.is_empty() {
        "(none)".to_string()
    } else {
        items.join(", ")
    }
}

fn empty_as_none(value: &str) -> &str {
    if value.is_empty() {
        "(none)"
    } else {
        value
    }
}