use crate::context::Date;
use crate::instance::{Input, Instance, Outcome};
use crate::scope::Grain;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rule {
pub name: &'static str,
pub version: u32,
pub needs_clock: bool,
pub needs_prior: bool,
}
#[derive(Clone, Debug)]
pub struct ReadSet {
pub lock: String,
pub clock: Date,
pub versions: Vec<(&'static str, u32)>,
pub barriers: Vec<&'static str>,
pub windowed: Vec<&'static str>,
pub scoped: Vec<&'static str>,
pub inputs: Vec<Input>,
}
impl ReadSet {
pub fn of(lock: &str, clock: Date, rules: &[Rule], instances: &[Instance]) -> Self {
let mut inputs: Vec<Input> = Vec::new();
for instance in instances {
for input in &instance.reads {
match inputs.binary_search_by(|known| known.path.as_str().cmp(&input.path)) {
Ok(_) => {}
Err(at) => inputs.insert(at, input.clone()),
}
}
}
let barrier = |rule: &Rule| {
instances
.iter()
.any(|instance| instance.rule == rule.name && instance.grain == Grain::Corpus)
};
let ran = |rule: &Rule| instances.iter().any(|instance| instance.rule == rule.name);
let decided = |rule: &Rule| {
instances.iter().any(|instance| {
instance.rule == rule.name && !matches!(instance.outcome, Outcome::Skipped(_))
})
};
ReadSet {
lock: lock.to_string(),
clock,
versions: rules.iter().map(|rule| (rule.name, rule.version)).collect(),
barriers: rules
.iter()
.filter(|rule| barrier(rule))
.map(|rule| rule.name)
.collect(),
windowed: rules
.iter()
.filter(|rule| rule.needs_clock && ran(rule))
.map(|rule| rule.name)
.collect(),
scoped: rules
.iter()
.filter(|rule| rule.needs_prior && decided(rule))
.map(|rule| rule.name)
.collect(),
inputs,
}
}
pub fn unhashed(&self) -> usize {
self.inputs
.iter()
.filter(|input| input.digest.is_none())
.count()
}
pub fn summary(&self) -> String {
let mut line = match self.unhashed() {
0 => format!("{} documents in the read set", self.inputs.len()),
unhashed => format!(
"{} documents in the read set, {unhashed} of them with no content hash",
self.inputs.len()
),
};
if !self.barriers.is_empty() {
let barriers = self.barriers.len();
let noun = match barriers {
1 => "barrier",
_ => "barriers",
};
line.push_str(&format!(
", and {barriers} {noun} that no gate carries across a merge"
));
}
line
}
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "lock {}", self.lock);
let _ = writeln!(out, "clock {}", self.clock.render());
for rule in &self.barriers {
let _ = writeln!(out, "barrier {rule}");
}
for rule in &self.windowed {
let _ = writeln!(out, "windowed {rule}");
}
for rule in &self.scoped {
let _ = writeln!(out, "change-scoped {rule}");
}
for (rule, version) in &self.versions {
let _ = writeln!(out, "version {rule} {version}");
}
for input in &self.inputs {
let _ = match &input.digest {
Some(digest) => writeln!(out, "input {} {digest}", input.path),
None => writeln!(out, "input {} -", input.path),
};
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instance::Outcome;
fn date() -> Date {
Date::parse("2026-08-12").expect("a date")
}
fn rule(name: &'static str, needs_clock: bool) -> Rule {
Rule {
name,
version: 1,
needs_clock,
needs_prior: false,
}
}
fn prior_reading(name: &'static str) -> Rule {
Rule {
name,
version: 1,
needs_clock: false,
needs_prior: true,
}
}
fn skipped(rule: &'static str, reads: Vec<Input>) -> Instance {
Instance::skipped(
rule,
Grain::Document,
reads,
crate::scope::CHANGE_SCOPED_ONLY,
)
}
fn instance(rule: &'static str, reads: Vec<Input>) -> Instance {
Instance::of(rule, Grain::Document, reads, Outcome::Passed)
}
fn over_the_corpus(rule: &'static str) -> Instance {
Instance::of(
rule,
Grain::Corpus,
vec![Input::new("a.md", Some("sha256:a"))],
Outcome::Passed,
)
}
#[test]
fn the_union_holds_each_document_once_in_path_order() {
let set = ReadSet::of(
"sha256:lock",
date(),
&[rule("r", false)],
&[
instance("r", vec![Input::new("b.md", Some("sha256:b"))]),
instance(
"s",
vec![
Input::new("b.md", Some("sha256:b")),
Input::new("a.md", Some("sha256:a")),
],
),
],
);
let paths: Vec<&str> = set.inputs.iter().map(|i| i.path.as_str()).collect();
assert_eq!(paths, vec!["a.md", "b.md"]);
}
#[test]
fn an_input_with_no_digest_is_published_and_counted() {
let set = ReadSet::of(
"sha256:lock",
date(),
&[],
&[instance("r", vec![Input::new("a.md", None)])],
);
assert_eq!(set.unhashed(), 1);
assert!(set.render().contains("input a.md -"));
assert!(set.summary().contains("1 of them with no content hash"));
}
#[test]
fn the_artifact_carries_the_lock_the_clock_and_the_versions() {
let text = ReadSet::of(
"sha256:lock",
date(),
&[rule("first.rule", false), rule("second.rule", false)],
&[instance("first.rule", vec![Input::new("a.md", Some("d"))])],
)
.render();
assert_eq!(
text,
"lock sha256:lock\nclock 2026-08-12\nversion first.rule 1\nversion second.rule 1\ninput a.md d\n"
);
}
#[test]
fn a_corpus_grained_instance_writes_a_barrier_line() {
let set = ReadSet::of(
"sha256:lock",
date(),
&[rule("wide.rule", false)],
&[over_the_corpus("wide.rule")],
);
assert_eq!(set.barriers, vec!["wide.rule"]);
assert!(set.render().contains("\nbarrier wide.rule\n"));
assert!(set.summary().contains("1 barrier that no gate carries"));
}
#[test]
fn windowed_names_the_rules_that_read_the_clock_and_ran() {
let set = ReadSet::of(
"sha256:lock",
date(),
&[rule("dated.rule", true), rule("absent.rule", true)],
&[instance("dated.rule", vec![Input::new("a.md", Some("d"))])],
);
assert_eq!(set.windowed, vec!["dated.rule"]);
assert!(!set.render().contains("windowed absent.rule"));
}
#[test]
fn a_document_grained_rule_is_no_barrier() {
let set = ReadSet::of(
"sha256:lock",
date(),
&[rule("narrow.rule", false)],
&[instance("narrow.rule", vec![Input::new("a.md", Some("d"))])],
);
assert!(set.barriers.is_empty());
assert!(!set.render().contains("barrier"));
assert_eq!(set.summary(), "1 documents in the read set");
}
#[test]
fn a_verdict_that_rests_on_a_change_is_named_and_a_skipped_one_is_not() {
let inputs = vec![Input::new("a.md", Some("sha256:one"))];
let decided = ReadSet::of(
"sha256:lock",
date(),
&[prior_reading("warrant.promoted")],
&[instance("warrant.promoted", inputs.clone())],
);
assert_eq!(decided.scoped, vec!["warrant.promoted"]);
assert!(decided
.render()
.contains("change-scoped warrant.promoted\n"));
let skipped_only = ReadSet::of(
"sha256:lock",
date(),
&[prior_reading("warrant.promoted")],
&[skipped("warrant.promoted", inputs)],
);
assert!(
skipped_only.scoped.is_empty(),
"a run that decided nothing published a verdict a gate has to refuse"
);
assert!(!skipped_only.render().contains("change-scoped"));
}
}