provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use assert_cmd::Command;
use predicates::prelude::*;
use provenance_macros::verifies;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

const GROUNDED_DESCRIPTION: &str = "Write specific, evidence-grounded statements for requirements, rules, sources, resolutions, and boundaries — not generic capability language. Use before calling `requirements create/update`, `rules create/update`, `sources create/update`, `resolutions create/update`, or `boundaries create`, especially for a root or mid-level requirement, a statement merging several candidates, or a resolution's position and rationale.";

const LEGACY_NAMES: [&str; 4] = [
    "shaping",
    "fork-tournament",
    "swarm-backtrace",
    "grounded-writing",
];

#[test]
#[verifies("rule_legacy_cleanup_ownership", examples)]
fn cleanup_validates_payload_hash_and_header_placement() {
    let dir = tempfile::tempdir().unwrap();
    let roots = [".claude/skills", ".agents/skills"];
    for (index, root) in roots.iter().enumerate() {
        let valid = legacy_dir(dir.path(), root, LEGACY_NAMES[index]);
        write_managed_skill(&valid, "---\nname: old\n---\n", "payload\n");

        let forged = legacy_dir(dir.path(), root, LEGACY_NAMES[index + 2]);
        std::fs::create_dir_all(&forged).unwrap();
        std::fs::write(
            forged.join("SKILL.md"),
            "---\nname: old\n---\n<!-- Installed by provenance 0.1.0; content hash fnv1a64:0000000000000000 -->\npayload\n",
        )
        .unwrap();
    }

    install_local(dir.path()).success();

    for (index, root) in roots.iter().enumerate() {
        assert!(!legacy_dir(dir.path(), root, LEGACY_NAMES[index]).exists());
        assert!(legacy_dir(dir.path(), root, LEGACY_NAMES[index + 2]).exists());
    }
}

#[test]
#[verifies("rule_legacy_cleanup_ownership", examples)]
fn cleanup_preserves_modified_payload_and_misplaced_valid_header() {
    let dir = tempfile::tempdir().unwrap();
    let modified = legacy_dir(dir.path(), ".claude/skills", "shaping");
    write_managed_skill(&modified, "---\nname: old\n---\n", "payload\n");
    std::fs::write(
        modified.join("SKILL.md"),
        format!(
            "---\nname: old\n---\n<!-- Installed by provenance 0.1.0; content hash fnv1a64:{} -->\nchanged\n",
            fnv1a64("payload\n")
        ),
    )
    .unwrap();
    let misplaced = legacy_dir(dir.path(), ".agents/skills", "fork-tournament");
    std::fs::create_dir_all(&misplaced).unwrap();
    std::fs::write(
        misplaced.join("SKILL.md"),
        format!(
            "<!-- Installed by provenance 0.1.0; content hash fnv1a64:{} -->\n---\nname: old\n---\npayload\n",
            fnv1a64("---\nname: old\n---\npayload\n")
        ),
    )
    .unwrap();

    install_local(dir.path()).success();

    assert!(modified.exists());
    assert!(misplaced.exists());
}

#[test]
#[verifies("rule_legacy_cleanup_ownership", examples)]
fn cleanup_removes_only_skill_file_and_keeps_user_files() {
    let dir = tempfile::tempdir().unwrap();
    let legacy = legacy_dir(dir.path(), ".claude/skills", "swarm-backtrace");
    write_managed_skill(&legacy, "---\nname: old\n---\n", "payload\n");
    std::fs::write(legacy.join("notes.txt"), "mine\n").unwrap();

    install_local(dir.path()).success();

    assert!(!legacy.join("SKILL.md").exists());
    assert_eq!(
        std::fs::read_to_string(legacy.join("notes.txt")).unwrap(),
        "mine\n"
    );
}

#[test]
fn rerun_reports_legacy_cleanup_as_an_update() {
    let dir = tempfile::tempdir().unwrap();
    install_local(dir.path()).success();
    // The report's paths come from the child's physical working directory;
    // canonicalize so the expectation matches on macOS, where the temp area
    // sits behind the /var -> /private/var symlink.
    let physical = dir.path().canonicalize().unwrap();
    let legacy = legacy_dir(&physical, ".agents/skills", "shaping");
    write_managed_skill(&legacy, "---\nname: old\n---\n", "payload\n");

    let output = install_local(dir.path())
        .success()
        .get_output()
        .stdout
        .clone();
    let report: serde_json::Value = serde_json::from_slice(&output).unwrap();

    assert_eq!(report["status"], "updated");
    // Path spellings differ per platform (verbatim prefixes, separators,
    // Windows 8.3 short names); the stable identity is the repo-relative
    // suffix of an absolute path.
    assert!(report["files"].as_array().unwrap().iter().any(|file| {
        file["path"].as_str().is_some_and(|path| {
            Path::new(path).is_absolute()
                && normalized(path).ends_with(".agents/skills/shaping/SKILL.md")
        }) && file["status"] == "removed"
    }));
}

#[test]
fn global_cleanup_covers_both_skill_path_families() {
    let home = tempfile::tempdir().unwrap();
    let cwd = tempfile::tempdir().unwrap();
    for root in [".claude/skills", ".agents/skills"] {
        let legacy = legacy_dir(home.path(), root, "grounded-writing");
        write_managed_skill(&legacy, "---\nname: old\n---\n", "payload\n");
    }

    Command::cargo_bin("provenance")
        .unwrap()
        .current_dir(cwd.path())
        .env("HOME", home.path())
        .args(["skills", "install", "--global", "--copy"])
        .assert()
        .success();

    for root in [".claude/skills", ".agents/skills"] {
        assert!(!legacy_dir(home.path(), root, "grounded-writing").exists());
    }
}

#[test]
fn later_claude_conflict_leaves_all_legacy_artifacts_untouched() {
    let dir = tempfile::tempdir().unwrap();
    let legacy = legacy_dir(dir.path(), ".agents/skills", "shaping");
    write_managed_skill(&legacy, "---\nname: old\n---\n", "payload\n");
    let agents = dir.path().join("AGENTS.md");
    let drifted =
        "before\n<!-- BEGIN PROVENANCE SKILLS -->\ndrift\n<!-- END PROVENANCE SKILLS -->\nafter\n";
    std::fs::write(&agents, drifted).unwrap();
    let conflict = dir
        .path()
        .join(".claude/skills/provenance-swarm-backtrace/SKILL.md");
    std::fs::create_dir_all(conflict.parent().unwrap()).unwrap();
    std::fs::write(&conflict, "foreign\n").unwrap();

    install_local(dir.path())
        .failure()
        .stderr(predicate::str::contains("exists and differs"));

    assert!(legacy.join("SKILL.md").exists());
    assert_eq!(std::fs::read_to_string(agents).unwrap(), drifted);
}

#[test]
fn canonical_conflict_leaves_all_legacy_artifacts_untouched() {
    let dir = tempfile::tempdir().unwrap();
    let legacy = legacy_dir(dir.path(), ".claude/skills", "fork-tournament");
    write_managed_skill(&legacy, "---\nname: old\n---\n", "payload\n");
    let conflict = dir
        .path()
        .join(".agents/skills/provenance-fork-tournament/SKILL.md");
    std::fs::create_dir_all(conflict.parent().unwrap()).unwrap();
    std::fs::write(&conflict, "foreign\n").unwrap();

    install_local(dir.path())
        .failure()
        .stderr(predicate::str::contains("exists and differs"));

    assert!(legacy.join("SKILL.md").exists());
}

#[test]
fn agents_marker_block_with_unverifiable_ownership_is_preserved() {
    let dir = tempfile::tempdir().unwrap();
    let agents = dir.path().join("AGENTS.md");
    let drifted = "before\n<!-- BEGIN PROVENANCE SKILLS -->\n<!-- Installed by provenance 0.1.0; content hash fnv1a64:0000000000000000 -->\nchanged\n<!-- END PROVENANCE SKILLS -->\nafter\n";
    std::fs::write(&agents, drifted).unwrap();

    install_local(dir.path()).success();

    assert_eq!(std::fs::read_to_string(agents).unwrap(), drifted);
}

#[test]
fn authentic_legacy_agents_block_is_removed() {
    let dir = tempfile::tempdir().unwrap();
    let agents = dir.path().join("AGENTS.md");
    let sources = [
        "---\nname: fork-tournament\ndescription: Run a fork tournament when a shaping session hits a genuine design fork — mutually exclusive directions, expensive to reverse, and the human's preference unknowable without concrete artifacts to react to. Implements the `prototype` resolution method from docs/shaping.md - spawn stance-based agents producing competing artifacts as proposals (phase 1, end session), then present them for human disposal and land the decision as a Resolution (phase 2).\n---\n\n# Fork tournament\n",
        "---\nname: swarm-backtrace\ndescription: Reverse-engineer candidate requirements from an existing codebase with a multi-agent swarm. Use when the user wants to extract, mine, backtrace, or reverse-engineer requirements or rules from existing code, bootstrap a Provenance graph from a legacy system, or asks \"what must be true for this code to be correct\". Lands everything as proposals (promotion_state=proposed) against a commit-pinned source — never as active requirements.\n---\n\n# Swarm backtrace\n",
    ];
    let block = render_legacy_agents_block(&sources);
    std::fs::write(&agents, format!("before\n{block}after\n")).unwrap();

    install_local(dir.path()).success();

    assert_eq!(std::fs::read_to_string(agents).unwrap(), "before\nafter\n");
}

#[test]
fn authentic_four_skill_legacy_agents_block_is_removed() {
    let dir = tempfile::tempdir().unwrap();
    let agents = dir.path().join("AGENTS.md");
    let descriptions = [
        ("shaping", "Guide turn-based requirement shaping in Provenance. Use when a user brings a loose idea, asks to refine requirements, work through open shaping questions, graduate fog, or run the Chart/Work loop against an anchor requirement. Land every resolved decision immediately into the graph."),
        ("fork-tournament", "Run a fork tournament when a shaping session hits a genuine design fork — mutually exclusive directions, expensive to reverse, and the human's preference unknowable without concrete artifacts to react to. Implements the `prototype` resolution method from docs/shaping.md - spawn stance-based agents producing competing artifacts as proposals (phase 1, end session), then present them for human disposal and land the decision as a Resolution (phase 2)."),
        ("swarm-backtrace", "Reverse-engineer candidate requirements from an existing codebase with a multi-agent swarm. Use when the user wants to extract, mine, backtrace, or reverse-engineer requirements or rules from existing code, bootstrap a Provenance graph from a legacy system, or asks \"what must be true for this code to be correct\". Lands everything as proposals (promotion_state=proposed) against a commit-pinned source — never as active requirements."),
        ("grounded-writing", GROUNDED_DESCRIPTION),
    ];
    let sources = descriptions.map(|(name, description)| {
        format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n")
    });
    let source_refs = sources.iter().map(String::as_str).collect::<Vec<_>>();
    let block = render_legacy_agents_block(&source_refs);
    std::fs::write(&agents, format!("before\n{block}after\n")).unwrap();

    install_local(dir.path()).success();

    assert_eq!(std::fs::read_to_string(agents).unwrap(), "before\nafter\n");
}

#[test]
fn authentic_block_preserves_non_utf8_surrounding_bytes() {
    let dir = tempfile::tempdir().unwrap();
    let agents = dir.path().join("AGENTS.md");
    let sources = [format!(
        "---\nname: grounded-writing\ndescription: {GROUNDED_DESCRIPTION}\n---\n\n# Grounded writing\n"
    )];
    let block = render_legacy_agents_block(&[sources[0].as_str()]);
    let mut contents = b"prefix\xff\n".to_vec();
    contents.extend_from_slice(block.as_bytes());
    contents.extend_from_slice(b"suffix\xfe\n");
    std::fs::write(&agents, contents).unwrap();

    install_local(dir.path()).success();

    assert_eq!(std::fs::read(agents).unwrap(), b"prefix\xff\nsuffix\xfe\n");
}

#[test]
fn drifted_authentic_legacy_agents_block_is_preserved() {
    let dir = tempfile::tempdir().unwrap();
    let agents = dir.path().join("AGENTS.md");
    let sources = [
        "---\nname: fork-tournament\ndescription: Run a fork tournament when a shaping session hits a genuine design fork — mutually exclusive directions, expensive to reverse, and the human's preference unknowable without concrete artifacts to react to. Implements the `prototype` resolution method from docs/shaping.md - spawn stance-based agents producing competing artifacts as proposals (phase 1, end session), then present them for human disposal and land the decision as a Resolution (phase 2).\n---\n\n# Fork tournament\n",
        "---\nname: swarm-backtrace\ndescription: Reverse-engineer candidate requirements from an existing codebase with a multi-agent swarm. Use when the user wants to extract, mine, backtrace, or reverse-engineer requirements or rules from existing code, bootstrap a Provenance graph from a legacy system, or asks \"what must be true for this code to be correct\". Lands everything as proposals (promotion_state=proposed) against a commit-pinned source — never as active requirements.\n---\n\n# Swarm backtrace\n",
    ];
    let block = render_legacy_agents_block(&sources).replace("# Swarm", "user edit\n# Swarm");
    let contents = format!("before\n{block}after\n");
    std::fs::write(&agents, &contents).unwrap();

    install_local(dir.path()).success();

    assert_eq!(std::fs::read_to_string(agents).unwrap(), contents);
}

fn render_legacy_agents_block(sources: &[&str]) -> String {
    let source = sources.concat();
    let mut block = format!(
        "<!-- BEGIN PROVENANCE SKILLS -->\n<!-- Installed by provenance 0.1.0; content hash fnv1a64:{} -->\n# Provenance Skills\n\nThese skills are distributed with the provenance CLI and should match the installed binary.\n\nBefore shaping or backtrace work, run `provenance skills install --target agents-md` if skills are absent.\n",
        fnv1a64(&source)
    );
    for source in sources {
        let name = source
            .lines()
            .find_map(|line| line.strip_prefix("name: "))
            .unwrap();
        let body = source.split_once("\n---\n").unwrap().1.trim_start();
        write!(block, "\n## Skill: {name}\n\n{body}").unwrap();
        if !block.ends_with('\n') {
            block.push('\n');
        }
    }
    block.push_str("<!-- END PROVENANCE SKILLS -->\n");
    block
}

fn install_local(path: &Path) -> assert_cmd::assert::Assert {
    Command::cargo_bin("provenance")
        .unwrap()
        .current_dir(path)
        .args(["skills", "install", "--copy"])
        .assert()
}

fn legacy_dir(base: &Path, root: &str, name: &str) -> PathBuf {
    base.join(root).join(name)
}

fn write_managed_skill(dir: &Path, frontmatter: &str, payload: &str) {
    std::fs::create_dir_all(dir).unwrap();
    std::fs::write(
        dir.join("SKILL.md"),
        format!(
            "{frontmatter}<!-- Installed by provenance 0.1.0; content hash fnv1a64:{} -->\n{payload}",
            fnv1a64(&format!("{frontmatter}{payload}"))
        ),
    )
    .unwrap();
}

fn fnv1a64(content: &str) -> String {
    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
    for byte in content.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{hash:016x}")
}

fn normalized(path: &str) -> String {
    path.trim_start_matches("\\\\?\\").replace('\\', "/")
}