skillnet 0.4.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
use std::fs;

use anyhow::{bail, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};

use crate::{
    fs_ops,
    model::{Candidate, Choice, Source, Target},
};

pub fn discover_candidates(sources: &[Source]) -> Result<Vec<Candidate>> {
    let mut candidates = Vec::new();
    for source in sources {
        if !source.path.exists() {
            continue;
        }
        discover_regular(source, &mut candidates)?;
        discover_system(source, &mut candidates)?;
    }
    Ok(candidates)
}

pub fn choose_latest(candidates: &[Candidate]) -> Result<Vec<Choice>> {
    let mut sorted = candidates.to_vec();
    sorted.sort_by(|a, b| a.skill.cmp(&b.skill));

    let mut choices = Vec::new();
    let mut idx = 0;
    while idx < sorted.len() {
        let skill = sorted[idx].skill.clone();
        let start = idx;
        while idx < sorted.len() && sorted[idx].skill == skill {
            idx += 1;
        }
        let group = &sorted[start..idx];
        let mut ranked = group.to_vec();
        ranked.sort_by(|a, b| {
            b.newest_mtime_nanos
                .cmp(&a.newest_mtime_nanos)
                .then_with(|| b.priority.cmp(&a.priority))
        });
        let winner = &ranked[0];
        let tied: Vec<_> = ranked
            .iter()
            .filter(|c| c.newest_mtime_nanos == winner.newest_mtime_nanos)
            .collect();
        if tied.len() > 1 {
            let first_sig = &tied[0].content_signature;
            if tied.iter().any(|c| &c.content_signature != first_sig) {
                let details = tied
                    .iter()
                    .map(|c| format!("  - {}: {} ({})", c.skill, c.source, c.path))
                    .collect::<Vec<_>>()
                    .join("\n");
                bail!("ambiguous newest source for skill `{skill}`:\n{details}");
            }
        }
        choices.push(Choice {
            skill,
            source: winner.source.clone(),
            path: winner.path.clone(),
            newest_mtime_nanos: winner.newest_mtime_nanos,
            candidate_count: group.len(),
        });
    }

    choices.sort_by(|a, b| a.skill.cmp(&b.skill));
    Ok(choices)
}

pub fn reconcile_target(target: &Target, sync: bool, dry_run: bool) -> Result<Vec<Choice>> {
    let candidates = discover_candidates(&target.sources)?;
    let choices = choose_latest(&candidates)?;

    if dry_run {
        print_choices(target, &choices, sync);
        return Ok(choices);
    }

    write_mirror(target, &choices)?;
    if sync {
        sync_target(target)?;
    }
    println!(
        "reconciled {} skills into {}",
        choices.len(),
        target.mirror_path
    );
    Ok(choices)
}

pub fn sync_target(target: &Target) -> Result<()> {
    for sync_path in &target.sync_paths {
        write_flat_from_mirror(&target.mirror_path, sync_path)?;
    }
    for path in &target.stale_codex_skill_paths {
        fs_ops::remove_codex_skills(path)?;
    }
    Ok(())
}

pub fn write_flat_from_mirror(mirror: &Utf8Path, dest: &Utf8Path) -> Result<()> {
    let staging = Utf8PathBuf::from(format!("{dest}.skillnet-tmp"));
    if staging.exists() {
        fs::remove_dir_all(&staging)?;
    }
    fs::create_dir_all(&staging)?;
    for skill in mirror_skill_dirs(mirror)? {
        fs_ops::copy_dir(&skill, &staging.join(skill.file_name().unwrap_or_default()))?;
    }
    fs_ops::replace_dir(&staging, dest)
}

pub fn mirror_skill_dirs(mirror: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
    if !mirror.exists() {
        return Ok(Vec::new());
    }
    let mut dirs = Vec::new();
    for entry in fs::read_dir(mirror)? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in mirror: {}", p.display()))?;
        if path.is_dir() && path.join("SKILL.md").is_file() {
            dirs.push(path);
        }
    }
    dirs.sort();
    Ok(dirs)
}

fn write_mirror(target: &Target, choices: &[Choice]) -> Result<()> {
    let staging = Utf8PathBuf::from(format!("{}.skillnet-tmp", target.mirror_path));
    if staging.exists() {
        fs::remove_dir_all(&staging)?;
    }
    fs::create_dir_all(&staging)?;
    for choice in choices {
        fs_ops::copy_dir(&choice.path, &staging.join(&choice.skill))?;
    }
    write_manifest(target, choices, &staging)?;
    fs_ops::replace_dir(&staging, &target.mirror_path)
}

fn write_manifest(target: &Target, choices: &[Choice], output: &Utf8Path) -> Result<()> {
    let mut body = String::new();
    body.push_str("# Skill Reconciliation\n\n");
    body.push_str("Generated by `skillnet reconcile`.\n\n");
    body.push_str("## Rule\n\n");
    body.push_str("Generated outputs are flat sets of skill directories. Candidates are read from live source roots. Claude/Codex `.system/*` children are flattened into normal global skills.\n\n");
    body.push_str("When the same skill exists in more than one source, the source tree with the newest file mtime wins. Exact newest-time ties are accepted only when the tied directory contents are identical.\n\n");
    body.push_str("## Sources\n\n");
    for source in &target.sources {
        if source.path.exists() {
            body.push_str(&format!("- {}: `{}`\n", source.label, source.path));
        }
    }
    body.push_str("\n## Choices\n\n");
    body.push_str("| Skill | Selected Source | Selected Path | Newest Mtime | Candidates |\n");
    body.push_str("|---|---|---|---:|---:|\n");
    for choice in choices {
        body.push_str(&format!(
            "| `{}` | `{}` | `{}` | `{}` | {} |\n",
            choice.skill,
            choice.source,
            choice.path,
            choice.newest_mtime_nanos,
            choice.candidate_count
        ));
    }
    fs::write(output.join("RECONCILIATION.md"), body).context("failed to write manifest")
}

fn discover_regular(source: &Source, candidates: &mut Vec<Candidate>) -> Result<()> {
    for entry in fs::read_dir(&source.path)? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in source: {}", p.display()))?;
        if path.file_name() == Some(".system") {
            continue;
        }
        if path.is_dir() && path.join("SKILL.md").is_file() {
            candidates.push(candidate_from_path(source, &path, source.label.clone())?);
        }
    }
    Ok(())
}

fn discover_system(source: &Source, candidates: &mut Vec<Candidate>) -> Result<()> {
    let system_root = source.path.join(".system");
    if !system_root.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(system_root)? {
        let entry = entry?;
        let path = Utf8PathBuf::from_path_buf(entry.path())
            .map_err(|p| anyhow::anyhow!("non-UTF-8 path in system source: {}", p.display()))?;
        if path.is_dir() && path.join("SKILL.md").is_file() {
            candidates.push(candidate_from_path(
                source,
                &path,
                format!("{}-system", source.label),
            )?);
        }
    }
    Ok(())
}

pub fn candidate_from_path(source: &Source, path: &Utf8Path, label: String) -> Result<Candidate> {
    Ok(Candidate {
        skill: path
            .file_name()
            .context("skill path has no final component")?
            .to_string(),
        source: label,
        priority: source.priority,
        path: path.to_path_buf(),
        newest_mtime_nanos: fs_ops::newest_mtime_nanos(path)?,
        content_signature: fs_ops::content_signature(path)?,
    })
}

fn print_choices(target: &Target, choices: &[Choice], sync: bool) {
    println!("# {} -> {}", target.name, target.mirror_path);
    if choices.is_empty() {
        println!("no skills found");
    }
    for choice in choices {
        println!(
            "{}\t{}\t{}\t{}",
            choice.skill, choice.source, choice.path, choice.candidate_count
        );
    }
    if sync {
        println!(
            "sync-back: {}",
            target
                .sync_paths
                .iter()
                .map(|p| p.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        );
        println!(
            "remove-stale-codex-skills: {}",
            target
                .stale_codex_skill_paths
                .iter()
                .map(|p| p.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
}

#[cfg(test)]
mod tests {
    use std::{fs, thread, time::Duration};

    use tempfile::tempdir;

    use super::*;

    fn skill(root: &Utf8Path, name: &str, body: &str) -> Utf8PathBuf {
        let dir = root.join(name);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("SKILL.md"), body).unwrap();
        dir
    }

    #[test]
    fn newest_candidate_wins() {
        let tmp = tempdir().unwrap();
        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
        let older = root.join("older");
        let newer = root.join("newer");
        fs::create_dir_all(&older).unwrap();
        fs::create_dir_all(&newer).unwrap();
        skill(&older, "x", "old");
        thread::sleep(Duration::from_millis(5));
        skill(&newer, "x", "new");
        let candidates = discover_candidates(&[
            Source {
                label: "older".into(),
                path: older,
                priority: 2,
            },
            Source {
                label: "newer".into(),
                path: newer,
                priority: 1,
            },
        ])
        .unwrap();
        let choices = choose_latest(&candidates).unwrap();
        assert_eq!(choices[0].source, "newer");
    }

    #[test]
    fn identical_ties_are_allowed() {
        let tmp = tempdir().unwrap();
        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
        let a = skill(&root, "a", "same");
        let b = skill(&root, "b", "same");
        let source = Source {
            label: "s".into(),
            path: root,
            priority: 1,
        };
        let mut c1 = candidate_from_path(&source, &a, "a".into()).unwrap();
        let mut c2 = candidate_from_path(&source, &b, "b".into()).unwrap();
        c1.skill = "x".into();
        c2.skill = "x".into();
        c2.newest_mtime_nanos = c1.newest_mtime_nanos;
        choose_latest(&[c1, c2]).unwrap();
    }

    #[test]
    fn conflicting_ties_fail() {
        let tmp = tempdir().unwrap();
        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
        let a = skill(&root, "a", "one");
        let b = skill(&root, "b", "two");
        let source = Source {
            label: "s".into(),
            path: root,
            priority: 1,
        };
        let mut c1 = candidate_from_path(&source, &a, "a".into()).unwrap();
        let mut c2 = candidate_from_path(&source, &b, "b".into()).unwrap();
        c1.skill = "x".into();
        c2.skill = "x".into();
        c2.newest_mtime_nanos = c1.newest_mtime_nanos;
        assert!(choose_latest(&[c1, c2]).is_err());
    }
}