skillnet 0.6.0

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

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

struct Fixture {
    repo: TempDir,
    plans: Vec<TempDir>,
}

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

    fn add_plan(&mut self, body: &Value) -> PathBuf {
        let plan = tempdir().unwrap();
        fs::write(
            plan.path().join(".calibration.json"),
            serde_json::to_string_pretty(body).unwrap(),
        )
        .unwrap();
        let path = plan.path().to_path_buf();
        self.plans.push(plan);
        path
    }

    fn command(&self) -> Command {
        let mut command = Command::cargo_bin("skillnet").unwrap();
        command.env("skillnet_DATA_DIR", self.repo.path().join("data"));
        command.env("SKILLNET_CONFIG", self.repo.path().join("skillnet.toml"));
        command.env_remove("SKILLNET_DATABASE_URL");
        command.env_remove("SKILLNET_DB_URL");
        command.env_remove("DATABASE_URL");
        command
    }

    fn record(&self, plan_dir: &Path) {
        self.command()
            .args(["calibration", "record", plan_dir.to_str().unwrap()])
            .assert()
            .success();
    }
}

#[test]
fn tag_show_and_untag_manage_user_tags() {
    let mut fixture = Fixture::new();
    let plan_dir = fixture.add_plan(&sidecar("plan-codex", "codex", "refactor", true, 3));
    fixture.record(&plan_dir);

    fixture
        .command()
        .args(["calibration", "tag", "plan-codex", "project=auth"])
        .assert()
        .success();

    let show = fixture
        .command()
        .args(["calibration", "show", "plan-codex"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let value: Value = serde_json::from_slice(&show).unwrap();
    assert!(value["tags"]
        .as_array()
        .unwrap()
        .iter()
        .any(|tag| tag["key"] == "project" && tag["value"] == "auth"));

    fixture
        .command()
        .args(["calibration", "untag", "plan-codex", "project=auth"])
        .assert()
        .success();
    let show = fixture
        .command()
        .args(["calibration", "show", "plan-codex"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let value: Value = serde_json::from_slice(&show).unwrap();
    assert!(!value["tags"]
        .as_array()
        .unwrap()
        .iter()
        .any(|tag| tag["key"] == "project" && tag["value"] == "auth"));
}

#[test]
fn tag_key_validation_and_auto_tag_protection_are_enforced() {
    let mut fixture = Fixture::new();
    let plan_dir = fixture.add_plan(&sidecar("plan-codex", "codex", "refactor", true, 3));
    fixture.record(&plan_dir);

    fixture
        .command()
        .args(["calibration", "tag", "plan-codex", "BadKey=value"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("tag key must match"));

    fixture
        .command()
        .args(["calibration", "untag", "plan-codex", "flavor=codex"])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "cannot remove derived auto-tag key",
        ));
}

#[test]
fn query_filters_by_tags_and_triggers() {
    let mut fixture = Fixture::new();
    let codex = fixture.add_plan(&sidecar("plan-codex", "codex", "refactor", true, 3));
    let claude = fixture.add_plan(&sidecar("plan-claude", "claude", "docs", false, 1));
    let low = fixture.add_plan(&sidecar("plan-low", "codex", "docs", false, 1));
    fixture.record(&codex);
    fixture.record(&claude);
    fixture.record(&low);

    let out = fixture
        .command()
        .args(["calibration", "query", "--tag", "flavor=codex"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let text = String::from_utf8(out).unwrap();
    assert!(text.contains("plan-codex"));
    assert!(text.contains("plan-low"));
    assert!(!text.contains("plan-claude"));

    let out = fixture
        .command()
        .args([
            "calibration",
            "query",
            "--tag",
            "flavor=codex",
            "--tag",
            "risk=mixed",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let text = String::from_utf8(out).unwrap();
    assert!(text.contains("plan-codex"));
    assert!(!text.contains("plan-low"));

    let out = fixture
        .command()
        .args([
            "calibration",
            "query",
            "--trigger",
            "chain-depth",
            "--fired",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let text = String::from_utf8(out).unwrap();
    assert!(text.contains("plan-codex"));
    assert!(!text.contains("plan-claude"));
}

#[test]
fn query_json_export_migrate_and_vacuum_work() {
    let mut fixture = Fixture::new();
    let first = fixture.add_plan(&sidecar("plan-codex", "codex", "refactor", true, 3));
    let second = fixture.add_plan(&sidecar("plan-claude", "claude", "docs", false, 1));
    fixture.record(&first);
    fixture.record(&second);

    let out = fixture
        .command()
        .args(["calibration", "query", "--format", "json"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let rows: Value = serde_json::from_slice(&out).unwrap();
    assert_eq!(rows.as_array().unwrap().len(), 2);

    let out = fixture
        .command()
        .args(["calibration", "export", "--format", "jsonl"])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let lines = String::from_utf8(out).unwrap();
    let parsed = lines
        .lines()
        .map(|line| serde_json::from_str::<Value>(line).unwrap())
        .collect::<Vec<_>>();
    assert_eq!(parsed.len(), 2);
    assert!(parsed.iter().all(|plan| plan["triggers"].is_array()));

    fixture
        .command()
        .args(["calibration", "migrate"])
        .assert()
        .success();
    fixture
        .command()
        .args(["calibration", "vacuum"])
        .assert()
        .success();
}

#[test]
fn show_unknown_id_fails_clearly() {
    let fixture = Fixture::new();
    fixture
        .command()
        .args(["calibration", "show", "missing"])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "unknown calibration plan id missing",
        ));
}

#[cfg(not(feature = "postgres"))]
#[test]
fn postgres_target_without_feature_fails_clearly() {
    let fixture = Fixture::new();
    fixture
        .command()
        .args([
            "--database-url",
            "postgres://user@localhost/skillnet",
            "calibration",
            "migrate",
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "built without the `postgres` feature",
        ));
}

fn sidecar(id: &str, flavor: &str, worktype: &str, chain_fired: bool, repo_spread: u32) -> Value {
    let routing_dist = if repo_spread > 1 {
        json!({ "medium": 1, "high": 1 })
    } else {
        json!({ "low": 2 })
    };
    json!({
        "schema_version": 1,
        "plan": {
            "id": id,
            "name": format!("Synthetic {id}"),
            "flavor": flavor,
            "worktype": worktype,
            "created_at": 1717171717,
            "phase_count": 2,
            "wave_count": 1,
            "max_chain_depth": if chain_fired { 3 } else { 1 },
            "repo_spread": repo_spread,
            "routing_dist": routing_dist,
            "shape_hash": format!("shape-{id}")
        },
        "triggers": [
            {
                "name": "chain-depth",
                "input_value": if chain_fired { 3.0 } else { 1.0 },
                "threshold": 2.0,
                "fired": chain_fired,
                "section_added": "parallelism"
            },
            {
                "name": "repo-spread",
                "input_value": repo_spread,
                "threshold": 2.0,
                "fired": repo_spread > 1,
                "section_added": null
            }
        ],
        "phases": [
            {
                "ordinal": 1,
                "slug": "one",
                "routing_tier": "medium",
                "files": ["src/lib.rs"]
            },
            {
                "ordinal": 2,
                "slug": "two",
                "routing_tier": "high",
                "files": ["src/cli/args.rs"]
            }
        ],
        "meta_heuristics_fired": if chain_fired { json!(["chain-depth"]) } else { json!([]) },
        "tags": {
            "owner": "inspect"
        }
    })
}