use crate::context::Date;
use crate::instance::Input;
use crate::paint::{paint, ColorMode, Role};
use headwater_yaml::json::Json;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recorded {
pub lock: String,
pub clock: Date,
pub barriers: Vec<String>,
pub scoped: Vec<String>,
pub windowed: Vec<String>,
pub versions: Vec<(String, u32)>,
pub inputs: Vec<Input>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Malformed {
pub line: usize,
pub reason: String,
}
impl Malformed {
pub fn render(&self) -> String {
format!("line {}: {}", self.line, self.reason)
}
}
impl Recorded {
pub fn parse(text: &str) -> Result<Self, Malformed> {
let mut lock: Option<String> = None;
let mut clock: Option<Date> = None;
let mut barriers = Vec::new();
let mut scoped = Vec::new();
let mut windowed = Vec::new();
let mut versions = Vec::new();
let mut inputs = Vec::new();
for (offset, line) in text.lines().enumerate() {
let at = offset + 1;
let refuse = |reason: &str| Malformed {
line: at,
reason: reason.to_string(),
};
if line.trim().is_empty() {
continue;
}
let fields: Vec<&str> = line.split(' ').collect();
match fields.as_slice() {
["lock", digest] => lock = Some((*digest).to_string()),
["clock", day] => match Date::parse(day) {
Some(day) => clock = Some(day),
None => return Err(refuse("the clock is not a date of the form YYYY-MM-DD")),
},
["barrier", rule] => barriers.push((*rule).to_string()),
["change-scoped", rule] => scoped.push((*rule).to_string()),
["windowed", rule] => windowed.push((*rule).to_string()),
["version", rule, edition] => match edition.parse::<u32>() {
Ok(edition) => versions.push(((*rule).to_string(), edition)),
Err(_) => return Err(refuse("the edition of a rule is not a whole number")),
},
["input", path, "-"] => inputs.push(Input::new(*path, None)),
["input", path, digest] => inputs.push(Input::new(*path, Some(digest))),
_ => return Err(refuse("no keyword of a read set opens this line")),
}
}
match (lock, clock) {
(Some(lock), Some(clock)) => Ok(Recorded {
lock,
clock,
barriers,
scoped,
windowed,
versions,
inputs,
}),
(None, _) => Err(Malformed {
line: 0,
reason: "the artifact states no lock, so it describes no taxonomy".to_string(),
}),
(_, None) => Err(Malformed {
line: 0,
reason: "the artifact states no clock, so it describes no day".to_string(),
}),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reason {
LockMoved { recorded: String, found: String },
Barrier { rule: String },
ChangeScoped { rule: String },
DayMoved {
rule: String,
recorded: Date,
asked: Date,
},
Moved {
path: String,
recorded: String,
found: String,
},
Gone { path: String },
Unhashed { path: String },
}
impl Reason {
pub fn token(&self) -> &'static str {
match self {
Reason::LockMoved { .. } => "lock_moved",
Reason::Barrier { .. } => "barrier",
Reason::ChangeScoped { .. } => "change_scoped",
Reason::DayMoved { .. } => "day_moved",
Reason::Moved { .. } => "moved",
Reason::Gone { .. } => "gone",
Reason::Unhashed { .. } => "unhashed",
}
}
fn json(&self) -> Json {
let mut members: Vec<(&'static str, Json)> = vec![
("reason", Json::string(self.token())),
("says", Json::string(self.render(ColorMode::Plain))),
];
let text = |value: &String| Json::string(value.clone());
match self {
Reason::LockMoved { recorded, found } => {
members.extend([("recorded", text(recorded)), ("found", text(found))]);
}
Reason::Barrier { rule } | Reason::ChangeScoped { rule } => {
members.push(("rule", text(rule)));
}
Reason::DayMoved {
rule,
recorded,
asked,
} => members.extend([
("rule", text(rule)),
("recorded", Json::string(recorded.render())),
("asked", Json::string(asked.render())),
]),
Reason::Moved {
path,
recorded,
found,
} => members.extend([
("path", text(path)),
("recorded", text(recorded)),
("found", text(found)),
]),
Reason::Gone { path } | Reason::Unhashed { path } => members.push(("path", text(path))),
}
Json::object(members)
}
pub fn render(&self, mode: ColorMode) -> String {
match self {
Reason::LockMoved { recorded, found } => {
format!("the taxonomy lock reads {found} and this verdict rests on {recorded}")
}
Reason::Barrier { rule } => format!(
"{rule} is a barrier: its verdict is a predicate over the extent of the census, \
and a list of members states no extent"
),
Reason::ChangeScoped { rule } => format!(
"{rule} read the version each document stood at before a change, and a read set \
lists corpus paths rather than changes"
),
Reason::DayMoved {
rule,
recorded,
asked,
} => format!(
"{rule} read the clock on {}, and this gate is asked about {}",
recorded.render(),
asked.render()
),
Reason::Moved {
path,
recorded,
found,
} => format!(
"{} reads {found} and this verdict rests on {recorded}",
paint(Role::Path, path, mode)
),
Reason::Gone { path } => {
format!("{} is not in this tree", paint(Role::Path, path, mode))
}
Reason::Unhashed { path } => {
format!(
"{} carried no hash when it was read, so nothing compares",
paint(Role::Path, path, mode)
)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Verdict {
pub reasons: Vec<Reason>,
pub listed: usize,
}
impl Verdict {
pub fn carries(&self) -> bool {
self.reasons.is_empty()
}
pub fn render(&self, mode: ColorMode) -> String {
use std::fmt::Write;
let mut out = String::new();
let listed = self.listed;
match self.carries() {
true => {
let _ = writeln!(
out,
"{} the verdicts this run reached about the {listed} inputs it listed carry \
to this tree",
paint(Role::Heading, "gate", mode)
);
}
false => {
let _ = writeln!(
out,
"{} the verdicts this run reached do not carry to this tree",
paint(Role::Heading, "gate", mode)
);
for reason in &self.reasons {
let _ = writeln!(out, " {}", reason.render(mode));
}
}
}
let _ = writeln!(out, "{LIMIT}");
out
}
pub fn render_json(&self) -> String {
let Verdict { reasons, listed } = self;
Json::object([
("version", Json::string(VERSION)),
("carries", Json::Bool(self.carries())),
("listed", Json::Raw(listed.to_string())),
(
"reasons",
Json::Array(reasons.iter().map(Reason::json).collect()),
),
("limit", Json::string(LIMIT)),
])
.render_pretty()
}
}
pub const VERSION: &str = "1.0";
pub const LIMIT: &str = "this states nothing about a document this tree gained. A read set lists \
what a run read, and never that those were all there was";
pub fn decide(
recorded: &Recorded,
lock: &str,
asked: Date,
digest_of: impl Fn(&str) -> Option<String>,
) -> Verdict {
let mut reasons = Vec::new();
if recorded.lock != lock {
reasons.push(Reason::LockMoved {
recorded: recorded.lock.clone(),
found: lock.to_string(),
});
}
for rule in &recorded.barriers {
reasons.push(Reason::Barrier { rule: rule.clone() });
}
for rule in &recorded.scoped {
reasons.push(Reason::ChangeScoped { rule: rule.clone() });
}
if asked != recorded.clock {
for rule in &recorded.windowed {
reasons.push(Reason::DayMoved {
rule: rule.clone(),
recorded: recorded.clock,
asked,
});
}
}
for input in &recorded.inputs {
match (&input.digest, digest_of(&input.path)) {
(None, _) => reasons.push(Reason::Unhashed {
path: input.path.clone(),
}),
(Some(_), None) => reasons.push(Reason::Gone {
path: input.path.clone(),
}),
(Some(recorded), Some(found)) if *recorded != found => reasons.push(Reason::Moved {
path: input.path.clone(),
recorded: recorded.clone(),
found,
}),
(Some(_), Some(_)) => {}
}
}
Verdict {
reasons,
listed: recorded.inputs.len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instance::{Instance, Outcome};
use crate::readset::{ReadSet, Rule};
use crate::scope::Grain;
fn day(text: &str) -> Date {
Date::parse(text).expect("a date")
}
fn recorded(text: &str) -> Recorded {
Recorded::parse(text).expect("an artifact this module wrote")
}
fn plain() -> Recorded {
recorded("lock sha256:lock\nclock 2026-08-13\nversion a.rule 1\ninput docs/a.md sha256:a\n")
}
fn tree<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |path: &str| {
pairs
.iter()
.find(|(known, _)| *known == path)
.map(|(_, digest)| (*digest).to_string())
}
}
#[test]
fn what_a_run_renders_is_what_a_gate_parses() {
let set = ReadSet::of(
"sha256:lock",
day("2026-08-13"),
&[
Rule {
name: "wide.rule",
version: 2,
needs_clock: false,
needs_prior: false,
},
Rule {
name: "dated.rule",
version: 1,
needs_clock: true,
needs_prior: false,
},
],
&[
Instance::of(
"wide.rule",
Grain::Corpus,
vec![Input::new("docs/a.md", Some("sha256:a"))],
Outcome::Passed,
),
Instance::of(
"dated.rule",
Grain::Document,
vec![Input::new("docs/b.md", None)],
Outcome::Passed,
),
],
);
let read = Recorded::parse(&set.render()).expect("the artifact this run wrote");
assert_eq!(read.lock, "sha256:lock");
assert_eq!(read.clock, day("2026-08-13"));
assert_eq!(read.barriers, vec!["wide.rule".to_string()]);
assert_eq!(read.windowed, vec!["dated.rule".to_string()]);
assert_eq!(
read.versions,
vec![("wide.rule".to_string(), 2), ("dated.rule".to_string(), 1)]
);
assert_eq!(
read.inputs,
vec![
Input::new("docs/a.md", Some("sha256:a")),
Input::new("docs/b.md", None),
]
);
}
#[test]
fn an_unchanged_tree_carries_the_verdict() {
let verdict = decide(
&plain(),
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert!(verdict.carries());
assert!(verdict
.render(ColorMode::Plain)
.contains("carry to this tree"));
assert!(verdict
.render(ColorMode::Plain)
.contains("nothing about a document this tree gained"));
}
#[test]
fn a_moved_hash_voids_the_verdict() {
let verdict = decide(
&plain(),
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:other")]),
);
assert!(!verdict.carries());
assert_eq!(
verdict.reasons,
vec![Reason::Moved {
path: "docs/a.md".to_string(),
recorded: "sha256:a".to_string(),
found: "sha256:other".to_string(),
}]
);
}
#[test]
fn a_deleted_input_voids_the_verdict() {
let verdict = decide(&plain(), "sha256:lock", day("2026-08-13"), tree(&[]));
assert_eq!(
verdict.reasons,
vec![Reason::Gone {
path: "docs/a.md".to_string()
}]
);
}
#[test]
fn a_moved_lock_voids_the_verdict() {
let verdict = decide(
&plain(),
"sha256:other",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert!(!verdict.carries());
assert!(matches!(verdict.reasons[0], Reason::LockMoved { .. }));
}
#[test]
fn a_barrier_never_carries_even_when_every_listed_hash_stands() {
let held = recorded(
"lock sha256:lock\nclock 2026-08-13\nbarrier identifier.claimed_twice\n\
version identifier.claimed_twice 1\ninput docs/a.md sha256:a\n",
);
let verdict = decide(
&held,
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert!(!verdict.carries());
assert_eq!(
verdict.reasons,
vec![Reason::Barrier {
rule: "identifier.claimed_twice".to_string()
}]
);
assert!(verdict
.render(ColorMode::Plain)
.contains("a list of members states no extent"));
}
#[test]
fn a_later_day_voids_a_windowed_rule() {
let held = recorded(
"lock sha256:lock\nclock 2026-08-13\nwindowed relation.participation.overdue\n\
version relation.participation.overdue 1\ninput docs/a.md sha256:a\n",
);
let standing = tree(&[("docs/a.md", "sha256:a")]);
assert!(decide(&held, "sha256:lock", day("2026-08-13"), &standing).carries());
let later = decide(&held, "sha256:lock", day("2026-09-13"), &standing);
assert_eq!(
later.reasons,
vec![Reason::DayMoved {
rule: "relation.participation.overdue".to_string(),
recorded: day("2026-08-13"),
asked: day("2026-09-13"),
}]
);
}
#[test]
fn a_later_day_carries_a_run_that_read_no_clock() {
let verdict = decide(
&plain(),
"sha256:lock",
day("2027-01-01"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert!(verdict.carries());
}
#[test]
fn an_input_with_no_hash_stops_the_gate() {
let held = recorded("lock sha256:lock\nclock 2026-08-13\ninput docs/a.md -\n");
let verdict = decide(
&held,
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert_eq!(
verdict.reasons,
vec![Reason::Unhashed {
path: "docs/a.md".to_string()
}]
);
}
#[test]
fn an_unknown_keyword_is_refused() {
let refusal = Recorded::parse("lock sha256:l\nclock 2026-08-13\nrumour docs/a.md\n")
.expect_err("a line no keyword opens");
assert_eq!(refusal.line, 3);
assert!(refusal.render().contains("no keyword of a read set"));
}
#[test]
fn an_artifact_with_no_lock_is_refused() {
let refusal = Recorded::parse("clock 2026-08-13\n").expect_err("no lock");
assert!(refusal.render().contains("states no lock"));
}
#[test]
fn a_change_scoped_verdict_never_carries_even_when_every_listed_hash_stands() {
let held = recorded(
"lock sha256:lock\nclock 2026-08-13\nchange-scoped warrant.promoted\n\
version warrant.promoted 1\ninput docs/a.md sha256:a\n",
);
assert_eq!(held.scoped, vec!["warrant.promoted".to_string()]);
let verdict = decide(
&held,
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
assert!(!verdict.carries(), "{:?}", verdict.reasons);
assert!(matches!(
verdict.reasons.first(),
Some(Reason::ChangeScoped { .. })
));
assert!(verdict
.render(ColorMode::Plain)
.contains("lists corpus paths rather than changes"));
}
#[test]
fn each_line_reaches_the_role_the_palette_gives_it() {
struct Case {
what: &'static str,
opens_with: &'static str,
}
let cases = [
Case {
what: "the report opens on a heading, bold in the default color",
opens_with: "\u{1b}[1mgate\u{1b}[0m the verdicts",
},
Case {
what: "a moved path is a path, cyan",
opens_with: "\u{1b}[36mdocs/a.md\u{1b}[0m reads",
},
];
let carrying = decide(
&plain(),
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:a")]),
);
let moved = decide(
&plain(),
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:different")]),
);
let painted = format!(
"{}{}",
carrying.render(ColorMode::Ansi),
moved.render(ColorMode::Ansi)
);
for case in cases {
assert!(
painted.contains(case.opens_with),
"{}: no `{}` in\n{painted}",
case.what,
case.opens_with.escape_debug()
);
}
}
#[test]
fn plain_mode_writes_no_escape_sequence() {
let verdict = decide(
&plain(),
"sha256:lock",
day("2026-08-13"),
tree(&[("docs/a.md", "sha256:different")]),
);
assert!(!verdict.render(ColorMode::Plain).contains('\x1b'));
}
}