use crate::change::Prior;
use crate::context::Date;
use crate::finding::{Finding, Severity};
use crate::instance::{Input, Outcome};
use crate::patch::Patch;
use crate::scope::{Grain, Scope};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const CACHE: &str = ".headwater/cache/checks";
pub const FORMAT: &str = "headwater check cache 2";
pub fn rules_digest() -> String {
digest_of(&crate::RULES)
}
fn digest_of(rules: &[&str]) -> String {
let mut sorted: Vec<&str> = rules.to_vec();
sorted.sort_unstable();
let mut text = String::new();
for rule in sorted {
text.push_str(rule);
text.push('\n');
}
headwater_hash::hex(text.as_bytes())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Report {
pub hits: usize,
pub misses: usize,
pub unkeyed: usize,
}
impl Report {
pub fn render(&self) -> String {
format!(
"{} served from cache, {} evaluated, {} not keyed\n",
self.hits, self.misses, self.unkeyed
)
}
}
#[derive(Clone, Debug)]
pub struct Cache {
lock: Option<String>,
rules: String,
found: BTreeMap<String, String>,
used: BTreeMap<String, String>,
report: Report,
}
impl Cache {
pub fn disabled() -> Self {
Cache {
lock: None,
rules: String::new(),
found: BTreeMap::new(),
used: BTreeMap::new(),
report: Report::default(),
}
}
pub fn at(root: &Path, lock: &str, rules: &str) -> Self {
let found = std::fs::read_to_string(Self::path(root))
.ok()
.map(|text| read(&text))
.unwrap_or_default();
Cache {
lock: Some(lock.to_string()),
rules: rules.to_string(),
found,
used: BTreeMap::new(),
report: Report::default(),
}
}
pub fn path(root: &Path) -> PathBuf {
root.join(CACHE)
}
pub fn report(&self) -> Report {
self.report
}
pub fn write(&self, root: &Path) -> Result<(), (PathBuf, std::io::Error)> {
let Some(_) = &self.lock else {
return Ok(());
};
let path = Self::path(root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| (parent.to_path_buf(), error))?;
let ignore = parent.join(".gitignore");
std::fs::write(&ignore, "*\n!.gitignore\n").map_err(|error| (ignore, error))?;
}
let mut text = String::from(FORMAT);
text.push('\n');
for (key, record) in &self.used {
text.push_str(key);
text.push('\t');
text.push_str(record);
text.push('\n');
}
std::fs::write(&path, text).map_err(|error| (path, error))
}
pub(crate) fn undecided(&mut self) {
self.report.unkeyed += 1;
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn outcome<F>(
&mut self,
rule: &'static str,
version: u32,
scope: Scope,
target: &str,
reads: &[Input],
clock: Option<Date>,
prior: Option<Prior<'_>>,
resolution: Option<&str>,
evaluate: F,
) -> Outcome
where
F: FnOnce() -> Outcome,
{
let Some(key) = self.key(
rule, version, scope, target, reads, clock, prior, resolution,
) else {
self.report.unkeyed += 1;
return evaluate();
};
if let Some(record) = self.found.get(&key).cloned() {
if let Some(outcome) = decode(rule, &record) {
self.report.hits += 1;
self.used.insert(key, record);
return outcome;
}
}
let outcome = evaluate();
match encode(&outcome) {
Some(record) => {
self.report.misses += 1;
self.used.insert(key, record);
}
None => self.report.unkeyed += 1,
}
outcome
}
#[allow(clippy::too_many_arguments)]
fn key(
&self,
rule: &'static str,
version: u32,
scope: Scope,
target: &str,
reads: &[Input],
clock: Option<Date>,
prior: Option<Prior<'_>>,
resolution: Option<&str>,
) -> Option<String> {
let lock = self.lock.as_ref()?;
let mut text = String::from("headwater check key 1\n");
text.push_str(&format!("lock {lock}\n"));
text.push_str(&format!("rules {}\n", self.rules));
text.push_str(&format!("rule {rule}\n"));
text.push_str(&format!("version {version}\n"));
text.push_str(&format!(
"scope {} body={} phase_a={} clock={} prior={} claims={} observations={}\n",
scope.grain().name(),
scope.needs_body(),
scope.needs_phase_a(),
scope.needs_clock(),
scope.needs_prior(),
scope.needs_claims(),
scope.needs_observations()
));
if scope.needs_clock() {
text.push_str(&format!("clock {}\n", clock?.render()));
}
if scope.needs_prior() && scope.grain() != Grain::Corpus {
text.push_str(&format!("prior {}\n", prior?.key()));
}
text.push_str(&format!("target {}\n", escape(target)));
if let Some(resolution) = resolution {
text.push_str(&format!("resolution {}\n", escape(resolution)));
}
for input in reads {
let digest = input.digest.as_ref()?;
text.push_str(&format!("input {} {digest}\n", escape(&input.path)));
}
Some(headwater_hash::hex(text.as_bytes()))
}
#[cfg(test)]
fn plain_key(
&self,
rule: &'static str,
version: u32,
scope: Scope,
target: &str,
reads: &[Input],
clock: Option<Date>,
) -> Option<String> {
self.key(rule, version, scope, target, reads, clock, None, None)
}
}
fn encode(outcome: &Outcome) -> Option<String> {
cached_form(outcome)
}
#[doc(hidden)]
pub fn cached_form(outcome: &Outcome) -> Option<String> {
match outcome {
Outcome::Passed => Some("passed".to_string()),
Outcome::Skipped(_) => None,
Outcome::Failed(findings) => {
let mut record = format!("failed\t{}", findings.len());
for finding in findings {
record.push_str(&format!(
"\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
finding.severity,
finding.line,
finding.column,
escape(&finding.path),
escape(&finding.message),
escape(&finding.remediation),
encode_patch(finding.patch.as_ref()),
));
}
Some(record)
}
}
}
fn encode_patch(patch: Option<&Patch>) -> String {
match patch {
None => "none".to_string(),
Some(Patch::Text {
path,
start,
end,
expect,
replacement,
}) => format!(
"text\t{start}\t{end}\t{}\t{}\t{}",
escape(path),
escape(expect),
escape(replacement)
),
Some(Patch::Half {
path,
relation,
id,
attributes,
}) if attributes.is_empty() => format!(
"half\t{}\t{}\t{}",
escape(path),
escape(relation),
escape(id)
),
Some(Patch::Half {
path,
relation,
id,
attributes,
}) => {
let mut record = format!(
"attributed\t{}\t{}\t{}\t{}",
escape(path),
escape(relation),
escape(id),
attributes.len()
);
for (name, value) in attributes {
record.push_str(&format!("\t{}\t{}", escape(name), escape(value)));
}
record
}
Some(Patch::Create { path, contents }) => {
format!("create\t{}\t{}", escape(path), escape(contents))
}
}
}
fn decode_patch<'a>(fields: &mut impl Iterator<Item = &'a str>) -> Option<Option<Patch>> {
match fields.next()? {
"none" => Some(None),
"text" => Some(Some(Patch::Text {
start: fields.next()?.parse().ok()?,
end: fields.next()?.parse().ok()?,
path: unescape(fields.next()?),
expect: unescape(fields.next()?),
replacement: unescape(fields.next()?),
})),
"half" => Some(Some(Patch::Half {
path: unescape(fields.next()?),
relation: unescape(fields.next()?),
id: unescape(fields.next()?),
attributes: Vec::new(),
})),
"attributed" => {
let path = unescape(fields.next()?);
let relation = unescape(fields.next()?);
let id = unescape(fields.next()?);
let count: usize = fields.next()?.parse().ok()?;
let mut attributes = Vec::with_capacity(count);
for _ in 0..count {
attributes.push((unescape(fields.next()?), unescape(fields.next()?)));
}
Some(Some(Patch::Half {
path,
relation,
id,
attributes,
}))
}
"create" => Some(Some(Patch::Create {
path: unescape(fields.next()?),
contents: unescape(fields.next()?),
})),
_ => None,
}
}
fn decode(rule: &'static str, record: &str) -> Option<Outcome> {
let mut fields = record.split('\t');
match fields.next()? {
"passed" => match fields.next() {
None => Some(Outcome::Passed),
Some(_) => None,
},
"failed" => {
let count: usize = fields.next()?.parse().ok()?;
if count == 0 {
return None;
}
let mut findings = Vec::with_capacity(count);
for _ in 0..count {
let severity = match fields.next()? {
"error" => Severity::Error,
"warn" => Severity::Warn,
"info" => Severity::Info,
_ => return None,
};
findings.push(Finding {
rule,
severity,
obligation: None,
line: fields.next()?.parse().ok()?,
column: fields.next()?.parse().ok()?,
path: unescape(fields.next()?),
message: unescape(fields.next()?),
remediation: unescape(fields.next()?),
patch: decode_patch(&mut fields)?,
});
}
match fields.next() {
None => Some(Outcome::Failed(findings)),
Some(_) => None,
}
}
_ => None,
}
}
fn read(text: &str) -> BTreeMap<String, String> {
let mut lines = text.lines();
if lines.next() != Some(FORMAT) {
return BTreeMap::new();
}
lines
.filter_map(|line| {
let (key, record) = line.split_once('\t')?;
Some((key.to_string(), record.to_string()))
})
.collect()
}
fn escape(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('\t', "\\t")
.replace('\n', "\\n")
}
fn unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character != '\\' {
out.push(character);
continue;
}
match characters.next() {
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn a_half_with_attributes_round_trips_and_a_bare_half_keeps_its_shape() {
let attributed = Patch::Half {
path: "docs/a.md".to_string(),
relation: "governs".to_string(),
id: ".githooks/pre-commit".to_string(),
attributes: vec![
("cue".to_string(), "the gate".to_string()),
("verified_revision".to_string(), "sha256:ab".to_string()),
],
};
let bare = Patch::Half {
path: "docs/a.md".to_string(),
relation: "cited_by".to_string(),
id: "D-1".to_string(),
attributes: Vec::new(),
};
let record = format!(
"{}\t{}",
encode_patch(Some(&attributed)),
encode_patch(Some(&bare))
);
assert!(encode_patch(Some(&bare)).starts_with("half\t"));
let mut fields = record.split('\t');
assert_eq!(decode_patch(&mut fields), Some(Some(attributed)));
assert_eq!(decode_patch(&mut fields), Some(Some(bare)));
assert_eq!(fields.next(), None);
}
use super::*;
use crate::context::Date;
use crate::scope::Scope;
use headwater_yaml::Mapping;
#[test]
fn the_rules_digest_is_membership_not_order() {
let a = digest_of(&["a", "b", "c"]);
let reordered = digest_of(&["c", "a", "b"]);
let added = digest_of(&["a", "b", "c", "d"]);
let removed = digest_of(&["a", "b"]);
assert_eq!(a, reordered, "reordering the same rules moved the digest");
assert_ne!(a, added, "adding a rule left the digest where it was");
assert_ne!(a, removed, "removing a rule left the digest where it was");
assert_ne!(added, removed, "two different sets collided");
}
fn finding() -> Finding {
Finding {
rule: "test.rule",
severity: Severity::Warn,
obligation: None,
path: "docs/spec/12-check-layer.md".to_string(),
line: 12,
column: 3,
message: "a message with a\ttab and a\nnewline and a \\ in it".to_string(),
remediation: "do the thing".to_string(),
patch: Some(Patch::Text {
path: "docs/spec/12-check-layer.md".to_string(),
start: 41,
end: 50,
expect: "behaviour".to_string(),
replacement: "behavior".to_string(),
}),
}
}
#[test]
fn a_failed_outcome_round_trips_through_a_record() {
let record = encode(&Outcome::Failed(vec![finding()])).expect("a verdict is stored");
assert!(!record.contains('\n'), "a record is one line: {record}");
let Some(Outcome::Failed(back)) = decode("test.rule", &record) else {
panic!("the record did not read back");
};
assert_eq!(back, vec![finding()]);
}
#[test]
fn every_finding_of_one_verdict_survives_the_record() {
let mut second = finding();
second.line = 40;
second.message = "another\tone".to_string();
let outcome = Outcome::Failed(vec![finding(), second.clone()]);
let record = encode(&outcome).expect("a verdict is stored");
let Some(Outcome::Failed(back)) = decode("test.rule", &record) else {
panic!("the record did not read back");
};
assert_eq!(back, vec![finding(), second]);
}
#[test]
fn a_passed_outcome_round_trips_and_a_skip_is_never_stored() {
assert!(matches!(
decode("r", &encode(&Outcome::Passed).expect("stored")),
Some(Outcome::Passed)
));
assert_eq!(encode(&Outcome::Skipped("a reason".to_string())), None);
}
#[test]
fn a_record_this_engine_cannot_read_is_a_miss() {
for record in [
"failed\t1\tcritical\t1\t1\tp\tm\tr\tnone",
"failed\t1\terror\tnot-a-line\t1\tp\tm\tr\tnone",
"failed\t1\terror\t1\t1\tp\tm\tr",
"failed\t1\terror\t1\t1\tp\tm\tr\tnone\tone-more",
"failed\t2\terror\t1\t1\tp\tm\tr\tnone",
"failed\t0",
"failed\tmany\terror\t1\t1\tp\tm\tr\tnone",
"failed\terror\t1\t1\tp\tm\tr\tnone",
"failed\t1\terror\t1\t1\tp\tm\tr\tsomething-else\tx",
"failed\t1\terror\t1\t1\tp\tm\tr\ttext\t3",
"failed\t1\terror\t1\t1\tp\tm\tr\ttext\tnot-an-offset\t4\tp\ta\tb",
"failed\t1\terror\t1\t1\tp\tm\tr\thalf\tp\trel",
"passed\tand-something-else",
"reused",
"",
] {
assert!(decode("r", record).is_none(), "{record} read as an outcome");
}
}
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for Scratch {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for Scratch {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for Scratch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
#[test]
fn write_leaves_a_gitignore_that_excludes_the_cache_and_keeps_itself() {
let root = Scratch(std::env::temp_dir().join(format!(
"headwater-write-leaves-a-gitignore-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&root);
Cache::at(&root, "sha256:lock", "sha256:rules")
.write(&root)
.expect("the cache writes");
let ignore = std::fs::read_to_string(root.join(".headwater/cache/.gitignore"))
.expect("write created the ignore file");
assert_eq!(ignore, "*\n!.gitignore\n");
assert!(Cache::path(&root).is_file(), "no cache file was written");
}
#[test]
fn a_file_this_engine_did_not_write_reads_as_an_empty_cache() {
assert!(read("headwater check cache 3\nk\tpassed\n").is_empty());
assert!(read("").is_empty());
assert_eq!(
read(&format!("{FORMAT}\nk\tpassed\nno-tab-here\n")).len(),
1
);
}
fn inputs(digest: Option<&str>) -> Vec<Input> {
vec![Input::new("a.md", digest)]
}
fn cache() -> Cache {
Cache::at(Path::new("/nonexistent"), "sha256:lock", "sha256:rules")
}
fn day(text: &str) -> Option<Date> {
Some(Date::parse(text).expect("a date"))
}
#[test]
fn every_component_of_the_key_moves_it() {
let scope = Scope::document(false, false, false, false);
let base = cache()
.plain_key("r", 1, scope, "a.md", &inputs(Some("sha256:one")), None)
.expect("a key");
let others = [
cache().plain_key("other", 1, scope, "a.md", &inputs(Some("sha256:one")), None),
cache().plain_key("r", 2, scope, "a.md", &inputs(Some("sha256:one")), None),
cache().plain_key(
"r",
1,
Scope::document(true, false, false, false),
"a.md",
&inputs(Some("sha256:one")),
None,
),
cache().plain_key(
"r",
1,
Scope::document(false, true, false, false),
"a.md",
&inputs(Some("sha256:one")),
None,
),
cache().plain_key(
"r",
1,
Scope::edge(false, false),
"a.md",
&inputs(Some("sha256:one")),
None,
),
cache().plain_key(
"r",
1,
Scope::neighbourhood(false),
"a.md",
&inputs(Some("sha256:one")),
None,
),
cache().plain_key("r", 1, scope, "b.md", &inputs(Some("sha256:one")), None),
cache().plain_key("r", 1, scope, "a.md", &inputs(Some("sha256:two")), None),
cache().plain_key(
"r",
1,
scope,
"a.md",
&[Input::new("b.md", Some("sha256:one"))],
None,
),
cache().plain_key(
"r",
1,
Scope::document(false, false, true, false),
"a.md",
&inputs(Some("sha256:one")),
day("2026-08-12"),
),
Cache::at(Path::new("/nonexistent"), "sha256:other", "sha256:rules").plain_key(
"r",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
None,
),
Cache::at(
Path::new("/nonexistent"),
"sha256:lock",
"sha256:other-rules",
)
.plain_key("r", 1, scope, "a.md", &inputs(Some("sha256:one")), None),
];
for (index, other) in others.iter().enumerate() {
assert_ne!(
Some(&base),
other.as_ref(),
"component {index} is not keyed"
);
}
}
#[test]
fn two_days_are_two_keys_for_a_check_that_reads_the_clock() {
let scope = Scope::document(false, false, true, false);
let monday = cache()
.plain_key(
"r",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
day("2026-08-12"),
)
.expect("a key");
let tuesday = cache()
.plain_key(
"r",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
day("2026-08-13"),
)
.expect("a key");
assert_ne!(monday, tuesday, "the clock is not in the key");
}
#[test]
fn a_check_that_does_not_read_the_clock_keys_the_same_on_every_day() {
let scope = Scope::document(false, false, false, false);
let monday = cache().plain_key(
"r",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
day("2026-08-12"),
);
let tuesday = cache().plain_key(
"r",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
day("2026-08-13"),
);
assert_eq!(monday, tuesday);
assert!(monday.is_some());
}
#[test]
fn two_editions_of_one_rule_are_two_keys() {
let scope = Scope::document(true, false, false, false);
let one = cache().plain_key("r", 1, scope, "a.md", &inputs(Some("sha256:one")), None);
let two = cache().plain_key("r", 2, scope, "a.md", &inputs(Some("sha256:one")), None);
assert!(one.is_some());
assert_ne!(one, two);
}
#[test]
fn two_prior_versions_are_two_keys_for_a_check_that_reads_one() {
let scope = Scope::document(false, false, false, true);
let key = |prior: Prior<'_>| {
cache().key(
"warrant.promoted",
1,
scope,
"a.md",
&inputs(Some("sha256:one")),
None,
Some(prior),
None,
)
};
let facets = Mapping::default();
let states = [
key(Prior::Unchanged),
key(Prior::Added),
key(Prior::Committed {
digest: "sha256:before",
facets: &facets,
}),
key(Prior::Committed {
digest: "sha256:another",
facets: &facets,
}),
];
for (index, state) in states.iter().enumerate() {
assert!(state.is_some(), "state {index} lost its key");
for other in &states[index + 1..] {
assert_ne!(state, other, "two states of the prior version share a key");
}
}
}
#[test]
fn a_prior_reading_scope_with_no_prior_is_not_keyed() {
assert_eq!(
cache().key(
"warrant.promoted",
1,
Scope::document(false, false, false, true),
"a.md",
&inputs(Some("sha256:one")),
None,
None,
None,
),
None
);
assert_ne!(
cache().plain_key(
"r",
1,
Scope::document(false, false, false, true),
"a.md",
&inputs(Some("sha256:one")),
None
),
cache().plain_key(
"r",
1,
Scope::document(false, false, false, false),
"a.md",
&inputs(Some("sha256:one")),
None
)
);
}
#[test]
fn a_clock_reading_scope_with_no_clock_is_not_keyed() {
assert_eq!(
cache().plain_key(
"r",
1,
Scope::document(false, false, true, false),
"a.md",
&inputs(Some("sha256:one")),
None
),
None
);
}
#[test]
fn an_input_with_no_digest_is_not_keyed() {
assert_eq!(
cache().plain_key(
"r",
1,
Scope::document(false, false, false, false),
"a.md",
&inputs(None),
None
),
None
);
}
#[test]
fn two_bindings_of_one_anchor_are_two_keys_and_neither_is_unkeyed() {
let scope = Scope::edge(false, false);
let target = "HW-SPEC-ai-integration\u{1f}governs\u{1f}.claude/hooks/lib.sh";
let key = |resolution: Option<&str>| {
cache().key(
"relation.target.unresolved",
1,
scope,
target,
&inputs(Some("sha256:one")),
None,
None,
resolution,
)
};
let bound = key(Some("Anchor { normalized: \".claude/hooks/lib.sh\" }"));
let gone = key(Some("Unbound(AnchorUnresolved { .. })"));
assert_ne!(bound, gone, "the two states of one anchor share a key");
assert!(bound.is_some(), "a resolved anchor lost its key");
assert!(gone.is_some(), "an unresolved anchor lost its key");
assert_eq!(
key(None),
cache().plain_key(
"relation.target.unresolved",
1,
scope,
target,
&inputs(Some("sha256:one")),
None
)
);
}
#[test]
fn a_disabled_cache_keys_nothing() {
assert_eq!(
Cache::disabled().plain_key(
"r",
1,
Scope::document(false, false, false, false),
"a.md",
&inputs(Some("d")),
None
),
None
);
}
}