qctl 0.2.0

Control in-repo YAML work queues
Documentation
//! Graph rules that JSON Schema cannot express.

use indoc::indoc;
use qctl::ledger::{graph_errors, load};
use std::fs;
use tempfile::TempDir;

fn errors(body: &str) -> Vec<String> {
    let root = TempDir::new().expect("tempdir");
    let path = root.path().join("tasks.yaml");
    fs::write(&path, body).expect("write");
    let ledger = load(&path).expect("parse");
    graph_errors(&ledger, &path)
}

#[test]
fn empty_ledger_is_clean() {
    assert_eq!(
        errors(indoc! {"
            schema_version: 3
            prefix: QCTL
            active: null
            queue: []
            archive: []
        "}),
        Vec::<String>::new()
    );
}

#[test]
fn missing_horizon_defaults_to_empty() {
    let root = TempDir::new().expect("tempdir");
    let path = root.path().join("tasks.yaml");
    fs::write(
        &path,
        indoc! {"
            schema_version: 3
            prefix: PST
            active: null
            queue: []
            archive: []
        "},
    )
    .expect("write");
    let ledger = load(&path).expect("parse");
    assert!(ledger.horizon.is_empty());
}

#[test]
fn rejects_an_id_from_another_repository() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: null
        queue:
          - id: OMX-001
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
        archive: []
    "});
    assert!(found.iter().any(|e| e.contains("OMX-001")));
}

/// A well-formed id that belongs to another repository is this ledger's own
/// business, so `graph_errors` reports it. An id that is not an id at all never
/// reaches the graph: the types refuse it at the boundary.
#[test]
fn refuses_a_malformed_id_before_the_graph_sees_it() {
    let root = TempDir::new().expect("tempdir");
    let path = root.path().join("tasks.yaml");
    fs::write(
        &path,
        indoc! {"
            schema_version: 3
            prefix: QCTL
            active: null
            queue:
              - id: QCTL-01
                title: t
                scope: s
                outcome: o
                blocked_by: []
                acceptance: [a]
            archive: []
        "},
    )
    .expect("write");
    let error = format!("{:#}", load(&path).expect_err("QCTL-01 is not an id"));
    assert!(error.contains("queue[0].id"), "{error}");
}

#[test]
fn rejects_duplicate_ids_across_lists() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: null
        queue:
          - id: QCTL-001
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
        archive:
          - id: QCTL-001
            title: t
            scope: s
            completed: 2026-08-16T09:12:00
            outcome: o
            evidence: [e]
        horizon:
          - id: QCTL-002
            title: t
            scope: s
            outcome: o
            kind: research
            open: why
    "});
    assert!(found.iter().any(|e| e.contains("duplicate id QCTL-001")));
}

#[test]
fn rejects_active_that_is_not_queue_head() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: QCTL-002
        queue:
          - id: QCTL-001
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
          - id: QCTL-002
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
        archive: []
    "});
    assert!(found.iter().any(|e| e.contains("must be queue[0]")));
}

#[test]
fn rejects_blocked_active() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: QCTL-002
        queue:
          - id: QCTL-002
            title: t
            scope: s
            outcome: o
            blocked_by: [QCTL-001]
            acceptance: [a]
          - id: QCTL-001
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
        archive: []
    "});
    assert!(found.iter().any(|e| e.contains("is blocked")));
}

#[test]
fn rejects_horizon_active() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: QCTL-009
        queue: []
        archive: []
        horizon:
          - id: QCTL-009
            title: later
            scope: s
            outcome: o
            kind: evaluation
            open: no start
    "});
    assert!(found.iter().any(|e| e.contains("horizon")));
}

#[test]
fn rejects_blocker_not_earlier_or_missing() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: null
        queue:
          - id: QCTL-001
            title: t
            scope: s
            outcome: o
            blocked_by: [QCTL-002]
            acceptance: [a]
          - id: QCTL-002
            title: t
            scope: s
            outcome: o
            blocked_by: [QCTL-099]
            acceptance: [a]
        archive: []
    "});
    assert!(found.iter().any(|e| e.contains("QCTL-001 <- QCTL-002")));
    assert!(found.iter().any(|e| e.contains("QCTL-002 <- QCTL-099")));
}

#[test]
fn rejects_archive_not_newest_first() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: null
        queue: []
        archive:
          - id: QCTL-001
            title: old
            scope: s
            completed: 2026-01-01T09:12:00
            outcome: o
            evidence: [e]
          - id: QCTL-002
            title: new
            scope: s
            completed: 2026-08-16T09:12:00
            outcome: o
            evidence: [e]
    "});
    assert!(found.iter().any(|e| e.contains("newest-first")));
}

#[test]
fn rejects_missing_plan_path() {
    let found = errors(indoc! {"
        schema_version: 3
        prefix: QCTL
        active: null
        queue:
          - id: QCTL-001
            title: t
            scope: s
            outcome: o
            blocked_by: []
            acceptance: [a]
            plan: missing-plan.md
        archive: []
    "});
    assert!(found.iter().any(|e| e.contains("missing plan")));
}

#[test]
fn next_id_skips_horizon_and_archive() {
    let root = TempDir::new().expect("tempdir");
    let path = root.path().join("tasks.yaml");
    fs::write(
        &path,
        indoc! {"
            schema_version: 3
            prefix: QCTL
            active: null
            queue: []
            archive:
              - id: QCTL-002
                title: t
                scope: s
                completed: 2026-08-16T09:12:00
                outcome: o
                evidence: [e]
            horizon:
              - id: QCTL-010
                title: t
                scope: s
                outcome: o
                kind: deferred
                open: later
        "},
    )
    .expect("write");
    let ledger = load(&path).expect("parse");
    assert_eq!(qctl::ledger::next_id(&ledger).expect("id"), "QCTL-011");
}