use indoc::indoc;
use qctl::ledger::{graph_errors, load, read};
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: 4
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: 4
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: 4
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")));
}
#[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: 4
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: 4
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(|error| error == "id QCTL-001 has multiple statuses: queue, archive")
);
}
#[test]
fn rejects_duplicate_ids_within_one_status() {
let found = errors(indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-001
title: first
scope: s
outcome: o
blocked_by: []
acceptance: [a]
- id: QCTL-001
title: second
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive: []
"});
assert!(
found
.iter()
.any(|error| error == "duplicate id QCTL-001 appears 2 times in queue")
);
}
#[test]
fn rejects_gaps_across_the_combined_corpus() {
let found = errors(indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-001
title: queued
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive:
- id: QCTL-003
title: archived
scope: s
completed: 2026-08-16T09:12:00
outcome: o
evidence: [e]
horizon:
- id: QCTL-004
title: horizon
scope: s
outcome: o
kind: deferred
open: later
"});
assert!(
found
.iter()
.any(|error| error == "missing task ids: QCTL-002")
);
}
#[test]
fn statuses_partition_one_continuous_corpus() {
let found = errors(indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-001
title: queued
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive:
- id: QCTL-002
title: archived
scope: s
completed: 2026-08-16T09:12:00
outcome: o
evidence: [e]
horizon:
- id: QCTL-003
title: horizon
scope: s
outcome: o
kind: deferred
open: later
"});
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn rejects_ids_outside_the_bounded_space_without_enumerating_them() {
let root = TempDir::new().expect("tempdir");
let path = root.path().join("tasks.yaml");
fs::write(
&path,
indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-9999999999
title: too high
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive: []
"},
)
.expect("write");
assert!(load(&path).is_err(), "validated load must refuse the id");
let ledger = read(&path).expect("parse without validation");
let found = graph_errors(&ledger, &path);
assert!(
found
.iter()
.any(|error| error.contains("outside the id space"))
);
assert!(
!found
.iter()
.any(|error| error.starts_with("missing task ids"))
);
}
#[test]
fn next_id_refuses_the_valid_maximum() {
let root = TempDir::new().expect("tempdir");
let path = root.path().join("tasks.yaml");
fs::write(
&path,
indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-999999
title: last
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive: []
"},
)
.expect("write");
let ledger = load(&path).expect("parse");
let error = qctl::ledger::next_id(&ledger).expect_err("id space is exhausted");
assert_eq!(error.to_string(), "id space exhausted");
}
#[test]
fn next_id_ignores_an_already_invalid_overflowing_id() {
let root = TempDir::new().expect("tempdir");
let path = root.path().join("tasks.yaml");
fs::write(
&path,
indoc! {"
schema_version: 4
prefix: QCTL
active: null
queue:
- id: QCTL-4294967295
title: too high
scope: s
outcome: o
blocked_by: []
acceptance: [a]
archive: []
"},
)
.expect("write");
assert!(load(&path).is_err(), "validated load must refuse the id");
let ledger = read(&path).expect("parse without validation");
assert_eq!(qctl::ledger::next_id(&ledger).expect("next id"), "QCTL-001");
}
#[test]
fn rejects_active_that_is_not_queue_head() {
let found = errors(indoc! {"
schema_version: 4
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: 4
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: 4
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: 4
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: 4
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: 4
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: 4
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");
}