skillnet 0.6.0

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

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::{tempdir, TempDir};

struct Fixture {
    temp: TempDir,
}

impl Fixture {
    fn new() -> Self {
        Self {
            temp: tempdir().unwrap(),
        }
    }

    fn root(&self) -> &Path {
        self.temp.path()
    }

    fn cwd(&self) -> std::path::PathBuf {
        self.root().join("cwd")
    }

    fn xdg_home(&self) -> std::path::PathBuf {
        self.root().join("xdg")
    }

    fn home(&self) -> std::path::PathBuf {
        self.root().join("home")
    }

    fn legacy(&self, file_name: &str) -> std::path::PathBuf {
        self.cwd().join(file_name)
    }

    fn xdg(&self, file_name: &str) -> std::path::PathBuf {
        self.xdg_home().join("skillnet").join(file_name)
    }

    fn breadcrumb(&self, file_name: &str) -> std::path::PathBuf {
        self.cwd().join(format!(".{file_name}.moved-to-xdg"))
    }

    fn write_legacy(&self, file_name: &str, body: &str) {
        fs::create_dir_all(self.cwd()).unwrap();
        fs::write(self.legacy(file_name), body).unwrap();
    }

    fn write_xdg(&self, file_name: &str, body: &str) {
        let path = self.xdg(file_name);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, body).unwrap();
    }

    fn command(&self) -> Command {
        fs::create_dir_all(self.cwd()).unwrap();
        fs::create_dir_all(self.home()).unwrap();
        let mut command = Command::cargo_bin("skillnet").unwrap();
        command
            .current_dir(self.cwd())
            .env("XDG_CONFIG_HOME", self.xdg_home())
            .env("HOME", self.home())
            .env_remove("SKILLNET_CONFIG")
            .env_remove("SKILLNET_CATALOG_CONFIG")
            .env_remove("SKILLNET_MIRROR_ROOT");
        command
    }
}

fn read_optional(path: &Path) -> Option<Vec<u8>> {
    path.exists().then(|| fs::read(path).unwrap())
}

fn assert_breadcrumb_points_to(fixture: &Fixture, file_name: &str) {
    let body = fs::read_to_string(fixture.breadcrumb(file_name)).unwrap();
    assert_eq!(body, format!("{}\n", fixture.xdg(file_name).display()));
}

fn minimal_config(root: &Path) -> String {
    format!(
        r#"
mirror_root = "{}"

[global]
views = []
"#,
        root.display()
    )
}

#[test]
fn migrate_noop_when_neither_present() {
    let fixture = Fixture::new();

    fixture
        .command()
        .args(["config", "migrate"])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "skillnet.toml: no config to migrate",
        ))
        .stdout(predicate::str::contains(
            "skillnet.catalog.toml: no config to migrate",
        ));
}

#[test]
fn migrate_noop_when_only_xdg_present() {
    let fixture = Fixture::new();
    fixture.write_xdg("skillnet.toml", "legacy = false\n");
    fixture.write_xdg("skillnet.catalog.toml", "catalog = true\n");

    fixture
        .command()
        .args(["config", "migrate"])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "skillnet.toml: already centralised",
        ))
        .stdout(predicate::str::contains(
            "skillnet.catalog.toml: already centralised",
        ));
}

#[test]
fn migrate_moves_legacy_to_xdg_when_xdg_absent() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", "legacy = true\n");

    fixture
        .command()
        .args(["config", "migrate"])
        .assert()
        .success()
        .stdout(predicate::str::contains("skillnet.toml: moved to"));

    assert!(!fixture.legacy("skillnet.toml").exists());
    assert_eq!(
        fs::read_to_string(fixture.xdg("skillnet.toml")).unwrap(),
        "legacy = true\n"
    );
    assert_breadcrumb_points_to(&fixture, "skillnet.toml");
}

#[test]
fn migrate_deletes_legacy_when_xdg_equal() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", "same = true\n");
    fixture.write_xdg("skillnet.toml", "same = true\n");

    fixture
        .command()
        .args(["config", "migrate"])
        .assert()
        .success()
        .stdout(predicate::str::contains("XDG copy retained"));

    assert!(!fixture.legacy("skillnet.toml").exists());
    assert_eq!(
        fs::read_to_string(fixture.xdg("skillnet.toml")).unwrap(),
        "same = true\n"
    );
    assert_breadcrumb_points_to(&fixture, "skillnet.toml");
}

#[test]
fn migrate_refuses_when_xdg_differs_without_force() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", "legacy = true\n");
    fixture.write_xdg("skillnet.toml", "xdg = true\n");

    fixture
        .command()
        .args(["config", "migrate"])
        .assert()
        .code(1)
        .stderr(predicate::str::contains("--force"));

    assert_eq!(
        fs::read_to_string(fixture.legacy("skillnet.toml")).unwrap(),
        "legacy = true\n"
    );
    assert_eq!(
        fs::read_to_string(fixture.xdg("skillnet.toml")).unwrap(),
        "xdg = true\n"
    );
}

#[test]
fn migrate_overwrites_xdg_with_force() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", "legacy = true\n");
    fixture.write_xdg("skillnet.toml", "xdg = true\n");

    fixture
        .command()
        .args(["config", "migrate", "--force"])
        .assert()
        .success()
        .stdout(predicate::str::contains("--force"));

    assert!(!fixture.legacy("skillnet.toml").exists());
    assert_eq!(
        fs::read_to_string(fixture.xdg("skillnet.toml")).unwrap(),
        "legacy = true\n"
    );
    assert_breadcrumb_points_to(&fixture, "skillnet.toml");
}

#[test]
fn migrate_dry_run_makes_no_changes() {
    for (legacy, xdg, force, success) in [
        (None, None, false, true),
        (None, Some("xdg = true\n"), false, true),
        (Some("legacy = true\n"), None, false, true),
        (Some("same = true\n"), Some("same = true\n"), false, true),
        (Some("legacy = true\n"), Some("xdg = true\n"), false, false),
        (Some("legacy = true\n"), Some("xdg = true\n"), true, true),
    ] {
        let fixture = Fixture::new();
        if let Some(body) = legacy {
            fixture.write_legacy("skillnet.toml", body);
        }
        if let Some(body) = xdg {
            fixture.write_xdg("skillnet.toml", body);
        }
        let legacy_before = read_optional(&fixture.legacy("skillnet.toml"));
        let xdg_before = read_optional(&fixture.xdg("skillnet.toml"));

        let mut command = fixture.command();
        command.args(["config", "migrate", "--dry-run"]);
        if force {
            command.arg("--force");
        }
        let assert = command.assert();
        if success {
            assert.success();
        } else {
            assert.code(1);
        }

        assert_eq!(
            read_optional(&fixture.legacy("skillnet.toml")),
            legacy_before
        );
        assert_eq!(read_optional(&fixture.xdg("skillnet.toml")), xdg_before);
        assert!(!fixture.breadcrumb("skillnet.toml").exists());
    }
}

#[test]
fn migrate_remove_breadcrumbs_deletes_them() {
    let fixture = Fixture::new();
    fs::create_dir_all(fixture.cwd()).unwrap();
    fs::write(fixture.breadcrumb("skillnet.toml"), "one\n").unwrap();
    fs::write(fixture.breadcrumb("skillnet.catalog.toml"), "two\n").unwrap();

    fixture
        .command()
        .args(["config", "migrate", "--remove-breadcrumbs"])
        .assert()
        .success()
        .stdout(predicate::str::contains("removed breadcrumb"));

    assert!(!fixture.breadcrumb("skillnet.toml").exists());
    assert!(!fixture.breadcrumb("skillnet.catalog.toml").exists());
}

#[test]
fn deprecation_warning_fires_on_legacy_pickup() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", &minimal_config(fixture.root()));

    let assert = fixture.command().args(["scope", "list"]).assert().success();
    let stderr = String::from_utf8_lossy(&assert.get_output().stderr);

    assert_eq!(stderr.matches("deprecated").count(), 1);
    assert!(stderr.contains("0.7.0"));
}

#[test]
fn no_deprecation_warning_on_xdg_pickup() {
    let fixture = Fixture::new();
    fixture.write_legacy("skillnet.toml", &minimal_config(fixture.root()));
    fixture.write_xdg("skillnet.toml", &minimal_config(fixture.root()));

    fixture
        .command()
        .args(["scope", "list"])
        .assert()
        .success()
        .stderr(predicate::str::contains("deprecated").not());
}