qctl 0.5.4

Control in-repo YAML work queues
mod common;

use common::{LedgerDir, qctl, stderr, stdout};
use indoc::indoc;

#[test]
fn queued_edit_updates_only_requested_fields_and_preserves_flow_lists() {
    let dir = LedgerDir::empty();
    dir.write(indoc! {"
        schema_version: 4
        prefix: QCTL
        active: QCTL-001
        queue:
          - id: QCTL-001
            title: Old
            scope: core
            outcome: Keep the outcome.
            blocked_by: []
            acceptance: [It holds.]
            notes:
              - First note.
              - Second note.
            links: [https://old.example/a:b]

          - id: QCTL-002
            title: Untouched
            scope: other
            outcome: This row stays byte-identical.
            blocked_by: []
            acceptance: [It holds.]
        archive: []
        horizon: []
    "});
    let untouched_before = dir
        .read()
        .split_once("  - id: QCTL-002")
        .expect("second row")
        .1
        .to_owned();
    let path = dir.path.to_string_lossy();
    let output = qctl(&[
        "edit",
        "QCTL-001",
        "-f",
        &path,
        "-t",
        "New",
        "-n",
        "Third note.",
        "-a",
        "A second condition.",
        "-L",
        "https://new.example",
        "-x",
        "note:1",
        "-x",
        "link:https://old.example/a:b",
        "-R",
        "1",
    ]);
    assert!(output.status.success(), "{}", stderr(&output));

    let body = dir.read();
    assert!(body.contains("title: New"), "{body}");
    assert!(
        body.contains("acceptance: [It holds., A second condition.]"),
        "{body}"
    );
    assert!(!body.contains("First note."), "{body}");
    assert!(body.contains("- Second note."), "{body}");
    assert!(body.contains("- Third note."), "{body}");
    assert!(!body.contains("https://old.example/a:b"), "{body}");
    assert!(body.contains("links: [https://new.example]"), "{body}");
    let untouched_after = body.split_once("  - id: QCTL-002").expect("second row").1;
    assert_eq!(untouched_after, untouched_before);
}

#[test]
fn queue_position_revalidates_every_dependency() {
    let dir = LedgerDir::empty();
    dir.write(indoc! {"
        schema_version: 4
        prefix: QCTL
        active: QCTL-001
        queue:
          - id: QCTL-001
            title: First
            scope: core
            outcome: First.
            blocked_by: []
            acceptance: [It holds.]
          - id: QCTL-002
            title: Second
            scope: core
            outcome: Second.
            blocked_by: [QCTL-001]
            acceptance: [It holds.]
          - id: QCTL-003
            title: Third
            scope: core
            outcome: Third.
            blocked_by: []
            acceptance: [It holds.]
        archive: []
        horizon: []
    "});
    let path = dir.path.to_string_lossy();

    let moved = qctl(&[
        "edit",
        "QCTL-003",
        "-f",
        &path,
        "-b",
        "QCTL-002",
        "-p",
        "after:QCTL-002",
    ]);
    assert!(moved.status.success(), "{}", stderr(&moved));
    let body = dir.read();
    assert!(
        body.find("id: QCTL-002") < body.find("id: QCTL-003"),
        "{body}"
    );

    let refused = qctl(&["edit", "QCTL-002", "-f", &path, "-p", "after:QCTL-003"]);
    assert!(!refused.status.success());
    assert!(stderr(&refused).contains("QCTL-003 <- QCTL-002 is not earlier"));
    assert_eq!(dir.read(), body);
}

#[test]
fn row_kind_policy_is_explicit() {
    let dir = LedgerDir::empty();
    dir.write(indoc! {"
        schema_version: 4
        prefix: QCTL
        active: null
        queue: []
        archive:
          - id: QCTL-001
            title: Done
            scope: core
            completed: 2026-08-01T09:12:00
            outcome: Done.
            evidence: [landed]
            notes: [Keep]
            links: [https://example.com]
        horizon:
          - id: QCTL-002
            title: Later
            scope: core
            kind: research
            outcome: Later.
            open: Need a fact.
            notes: [Old]
    "});
    let path = dir.path.to_string_lossy();

    let archive = qctl(&[
        "edit",
        "QCTL-001",
        "-f",
        &path,
        "-n",
        "New",
        "-e",
        "verified",
        "-x",
        "note:Keep",
        "-x",
        "link:1",
    ]);
    assert!(archive.status.success(), "{}", stderr(&archive));

    let archive_title = qctl(&["edit", "QCTL-001", "-f", &path, "-t", "Nope"]);
    assert!(!archive_title.status.success());
    assert!(stderr(&archive_title).contains("note, evidence, and link"));

    let horizon_position = qctl(&["edit", "QCTL-002", "-f", &path, "-p", "front"]);
    assert!(!horizon_position.status.success());
    assert!(stderr(&horizon_position).contains("horizon rows do not accept --position"));
}

#[test]
fn an_archived_row_follows_its_moved_plan() {
    let dir = LedgerDir::empty();
    dir.plant(
        "openspec/changes/archive/2026-08-01-done/proposal.md",
        "# Done",
    );
    dir.write(indoc! {"
        schema_version: 4
        prefix: QCTL
        active: null
        queue: []
        archive:
          - id: QCTL-001
            title: Done
            scope: core
            completed: 2026-08-01T09:12:00
            outcome: Done.
            evidence: [landed]
            plan: openspec/changes/done/proposal.md
        horizon: []
    "});
    let path = dir.path.to_string_lossy();
    let moved = "openspec/changes/archive/2026-08-01-done/proposal.md";

    let stale = qctl(&["check", "--no-git", "-f", &path]);
    assert!(!stale.status.success());
    let said = format!("{}{}", stdout(&stale), stderr(&stale));
    assert!(
        said.contains("missing plan openspec/changes/done/proposal.md"),
        "{said}"
    );

    let repoint = qctl(&["edit", "QCTL-001", "-f", &path, "-l", moved]);
    assert!(repoint.status.success(), "{}", stderr(&repoint));
    assert!(
        dir.read().contains(&format!("plan: {moved}")),
        "{}",
        dir.read()
    );
    let check = qctl(&["check", "--no-git", "-f", &path]);
    assert!(check.status.success(), "{}", stderr(&check));

    let missing = qctl(&["edit", "QCTL-001", "-f", &path, "-l", "nope.md"]);
    assert!(!missing.status.success());
    assert!(stderr(&missing).contains("missing plan nope.md"));

    for refused in [
        ["-t", "Nope"],
        ["-s", "other"],
        ["-o", "Other."],
        ["-a", "It holds."],
        ["-b", "QCTL-002"],
    ] {
        let mut args = vec!["edit", "QCTL-001", "-f", &path];
        args.extend(refused);
        let output = qctl(&args);
        assert!(!output.status.success(), "{refused:?} was accepted");
        assert!(
            stderr(&output).contains("archive rows accept only plan, note, evidence, and link ops"),
            "{}",
            stderr(&output)
        );
    }

    let unset = qctl(&["edit", "QCTL-001", "-f", &path, "-U", "plan"]);
    assert!(unset.status.success(), "{}", stderr(&unset));
    assert!(!dir.read().contains("plan:"), "{}", dir.read());
}

#[test]
fn id_completed_and_unknown_remove_fields_are_not_editable() {
    let dir = LedgerDir::empty();
    dir.write(common::MINIMAL);
    let path = dir.path.to_string_lossy();
    for flag in ["--id", "--completed"] {
        let output = qctl(&["edit", "QCTL-001", "-f", &path, flag, "value"]);
        assert!(!output.status.success());
        assert!(stderr(&output).contains("unexpected argument"));
    }
    let remove = qctl(&["edit", "QCTL-001", "-f", &path, "-x", "title:value"]);
    assert!(!remove.status.success());
    assert!(stderr(&remove).contains("remove field must be"));
}