majordomus_cli/evidence/freshness.rs
1//! Freshness: whether a recorded run still proves anything at the revision a reader is shown.
2//!
3//! A passing run is a fact about the tree it measured. Whether it is also a fact about the
4//! tree in front of a reader depends on three things the run cannot know: whether the
5//! revision presented contains the commit the run was recorded on, what changed between the
6//! two, and whether either tree was the commit it claims to be. This module decides that,
7//! once, for every surface — the evidence report, the rules report, and every later reader
8//! of the ledger — so that no two of them can disagree about what a run still proves.
9//!
10//! # The pieces
11//!
12//! * [`Presented`] — what is judged: the reader's working tree, or the checked-out commit
13//! judged as committed ([`presented_commit`]), with the ledger committed in it
14//! ([`ledger_at`]);
15//! * [`compare`] — the containment of the evidence commit in the presented revision, and
16//! the paths that changed between them ([`changed_between`]);
17//! * [`freshness`] — the one truth table, pure, from a recorded run to a [`Judgement`];
18//! * [`current`] — whether a judgement is current evidence, for a reader that counts a route
19//! as met: `proven`, or `inputs_unchanged` from a run on a tree that was its commit;
20//! * [`weakened_by`] — the monotone rule: a supplementary record may withhold `proven` and
21//! never grant it; its first source is [`uncommitted`], the executions the working ledger
22//! holds that the presented commit's ledger does not;
23//! * [`aggregate`] — how several states make one, with a failure outranking an absence.
24//!
25//! # Example
26//!
27//! The truth table without a repository: a recorded skip is not a failure, and only a pass
28//! the presented revision contains, with nothing but the ledger changed since, on a clean
29//! tree at both ends, is `proven`.
30//!
31//! ```
32//! use majordomus_cli::evidence::freshness::{freshness, Comparison, Recorded, TreeState};
33//! use majordomus_cli::evidence::{Execution, ProofState};
34//! use majordomus_cli::git::Containment;
35//!
36//! let run = |outcome: &str| -> Execution {
37//! serde_json::from_value(serde_json::json!({
38//! "test": "suite:07_scope", "runner": "suite", "source": "test/cases/07_scope.sh",
39//! "outcome": outcome, "seconds": 1, "commit": "a".repeat(40),
40//! "working_tree": "clean", "digest": "sha256:0", "at": "2026-09-26T00:00:00Z",
41//! "origin": "local", "command": "bash test/run.sh 07_scope"
42//! }))
43//! .unwrap()
44//! };
45//! let nothing_changed = Comparison {
46//! containment: Containment::Contains,
47//! changed: Some(Default::default()),
48//! };
49//! let inputs = vec!["test/cases/07_scope.sh".to_string()];
50//! let judge = |e: &Execution| {
51//! freshness(
52//! Recorded::Ran(e),
53//! Some(&inputs),
54//! "test/cases/07_scope.sh",
55//! false,
56//! Some(¬hing_changed),
57//! TreeState::Clean,
58//! )
59//! };
60//! assert_eq!(judge(&run("pass")).state, ProofState::Proven);
61//! assert_eq!(judge(&run("skip")).state, ProofState::NotRun);
62//! assert_eq!(judge(&run("error")).state, ProofState::Failing);
63//! ```
64
65use std::collections::BTreeSet;
66use std::path::Path;
67
68use schemars::JsonSchema;
69use serde::{Deserialize, Serialize};
70
71use super::{capped_by_working_tree, Execution, Ledger, Outcome, ProofState, LEDGER_PATH};
72use crate::error::{Error, Result};
73use crate::git::{self, Containment};
74
75/// The phrase a detail uses for a run the working ledger holds and the presented commit's
76/// ledger does not: the first source of [`weakened_by`].
77pub const UNCOMMITTED_RUN: &str = "an uncommitted run in the working ledger";
78
79// ---------------------------------------------------------------- the tree
80
81/// Whether a tree was the commit it sits on: the three words the ledger and git already
82/// use, typed.
83///
84/// `unknown` is not a softer `clean`. It is what is recorded when git could not be asked,
85/// and not knowing is not proof, so every rule here treats it as not clean.
86///
87/// ```
88/// use majordomus_cli::evidence::freshness::TreeState;
89///
90/// assert_eq!(TreeState::parse("clean"), TreeState::Clean);
91/// assert_eq!(TreeState::parse("dirty"), TreeState::Dirty);
92/// // a word nobody wrote down is not a clean tree
93/// assert_eq!(TreeState::parse("spotless"), TreeState::Unknown);
94/// assert_eq!(TreeState::Dirty.as_str(), "dirty");
95/// assert_eq!(serde_json::to_value(TreeState::Unknown).unwrap(), "unknown");
96/// ```
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98#[serde(rename_all = "snake_case")]
99#[schemars(rename = "EvidenceTreeState")]
100pub enum TreeState {
101 /// The tree was the commit: nothing but the evidence ledger differed from it.
102 Clean,
103 /// Something other than the evidence ledger differed from the commit.
104 Dirty,
105 /// Git could not be asked, or the word recorded is none of the three.
106 Unknown,
107}
108
109impl TreeState {
110 /// Read the word a recorder or git wrote: `clean` and `dirty` are themselves, and
111 /// anything else is [`TreeState::Unknown`], never a guess at one of the other two.
112 ///
113 /// ```
114 /// use majordomus_cli::evidence::freshness::TreeState;
115 /// assert_eq!(TreeState::parse("clean"), TreeState::Clean);
116 /// assert_eq!(TreeState::parse("Clean"), TreeState::Unknown, "the words are exact");
117 /// assert_eq!(TreeState::parse(""), TreeState::Unknown);
118 /// ```
119 pub fn parse(word: &str) -> TreeState {
120 match word {
121 "clean" => TreeState::Clean,
122 "dirty" => TreeState::Dirty,
123 _ => TreeState::Unknown,
124 }
125 }
126
127 /// The word itself, as the ledger and the report spell it.
128 ///
129 /// ```
130 /// use majordomus_cli::evidence::freshness::TreeState;
131 /// for t in [TreeState::Clean, TreeState::Dirty, TreeState::Unknown] {
132 /// assert_eq!(TreeState::parse(t.as_str()), t, "the word reads back as itself");
133 /// }
134 /// ```
135 pub fn as_str(self) -> &'static str {
136 match self {
137 TreeState::Clean => "clean",
138 TreeState::Dirty => "dirty",
139 TreeState::Unknown => "unknown",
140 }
141 }
142
143 /// The weaker of two statements about one tree: clean only when both say clean, dirty
144 /// when either says dirty, and unknown otherwise.
145 ///
146 /// This is how a caller's own knowledge meets a measurement. A site build that knows its
147 /// tree was dirty can weaken a clean measurement; nothing a caller says can make a
148 /// measured dirty tree clean.
149 ///
150 /// ```
151 /// use majordomus_cli::evidence::freshness::TreeState::{Clean, Dirty, Unknown};
152 /// assert_eq!(Clean.weaker(Clean), Clean);
153 /// assert_eq!(Clean.weaker(Dirty), Dirty);
154 /// assert_eq!(Dirty.weaker(Clean), Dirty, "a given clean never overrides a dirty tree");
155 /// assert_eq!(Unknown.weaker(Dirty), Dirty);
156 /// assert_eq!(Clean.weaker(Unknown), Unknown);
157 /// ```
158 pub fn weaker(self, other: TreeState) -> TreeState {
159 match (self, other) {
160 (TreeState::Clean, TreeState::Clean) => TreeState::Clean,
161 (TreeState::Dirty, _) | (_, TreeState::Dirty) => TreeState::Dirty,
162 _ => TreeState::Unknown,
163 }
164 }
165}
166
167// ---------------------------------------------------------------- what is presented
168
169/// The revision a verdict is about.
170///
171/// Every verdict is a verdict *at* something, and saying which is half of the answer. The
172/// working tree is what a person at a terminal is looking at; the checked-out commit judged
173/// as committed is the reading a site built from that commit needs.
174///
175/// ```
176/// use majordomus_cli::evidence::freshness::Presented;
177///
178/// // the default of every existing reader: the checkout, with whatever it carries
179/// let here = Presented::WorkingTree;
180/// assert_eq!(here.commit(), None);
181/// ```
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum Presented {
184 /// The reader's checkout: HEAD plus every tracked, staged and untracked change in it.
185 WorkingTree,
186 /// The checked-out commit, judged as committed: its own ledger, its own tree. Only
187 /// [`presented_commit`] builds one, because only it checks that the commit is the one
188 /// checked out and measures the tree.
189 #[non_exhaustive]
190 Commit {
191 /// The full commit id.
192 commit: String,
193 /// Whether the checkout was that commit, ignoring the evidence ledger's working
194 /// copy, weakened by anything the caller knew.
195 tree: TreeState,
196 },
197}
198
199impl Presented {
200 /// The presented commit, when a commit rather than the working tree is presented.
201 ///
202 /// ```
203 /// use majordomus_cli::evidence::freshness::{presented_commit, Presented};
204 /// use std::process::Command;
205 ///
206 /// let dir = tempfile::tempdir().unwrap();
207 /// let git = |args: &[&str]| {
208 /// let out = Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap();
209 /// String::from_utf8_lossy(&out.stdout).trim().to_string()
210 /// };
211 /// git(&["init", "-q"]);
212 /// git(&["config", "user.email", "t@example.com"]);
213 /// git(&["config", "user.name", "t"]);
214 /// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
215 ///
216 /// let p = presented_commit(dir.path(), "HEAD", None).unwrap();
217 /// assert_eq!(p.commit(), Some(git(&["rev-parse", "HEAD"]).as_str()));
218 /// assert_eq!(Presented::WorkingTree.commit(), None);
219 /// ```
220 pub fn commit(&self) -> Option<&str> {
221 match self {
222 Presented::WorkingTree => None,
223 Presented::Commit { commit, .. } => Some(commit),
224 }
225 }
226
227 /// The presented tree as the truth table reads it: [`TreeState::Clean`] for the working
228 /// tree, whose changes are already in the diff, and the measured tree for a commit.
229 ///
230 /// ```
231 /// use majordomus_cli::evidence::freshness::{Presented, TreeState};
232 /// assert_eq!(Presented::WorkingTree.tree(), TreeState::Clean);
233 /// ```
234 pub fn tree(&self) -> TreeState {
235 match self {
236 Presented::WorkingTree => TreeState::Clean,
237 Presented::Commit { tree, .. } => *tree,
238 }
239 }
240}
241
242/// The first twelve characters of a commit, for a sentence a person reads. The full id is
243/// what every payload carries.
244fn short12(commit: &str) -> String {
245 commit.chars().take(12).collect()
246}
247
248/// Whether git would read `rev` as an option rather than a revision. A ledger row's commit
249/// is data anyone can write, and `--output=<file>` in a revision's place makes `git diff` or
250/// `git show` write that file; no commit begins with a dash, so such a word names none and
251/// never reaches git as a revision.
252fn option_shaped(rev: &str) -> bool {
253 rev.starts_with('-')
254}
255
256/// The full commit a revision names, or `None` when it names none here.
257fn commit_of(root: &Path, rev: &str) -> Option<String> {
258 git::read_only(root)
259 .args([
260 "rev-parse",
261 "--verify",
262 "--quiet",
263 &format!("{rev}^{{commit}}"),
264 ])
265 .output()
266 .ok()
267 .filter(|out| out.status.success())
268 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
269 .filter(|id| !id.is_empty())
270}
271
272/// The checked-out commit, presented as committed.
273///
274/// `rev` must name the commit that is checked out: the claims matrix, the tests' sources and
275/// the digests a verdict reads come from the checkout, so a verdict "at" some other commit
276/// would mix two trees. The tree is measured, ignoring the evidence ledger's working copy —
277/// the ledger is read from the commit, so its working copy is not part of what is judged —
278/// and `given` can only weaken what was measured.
279///
280/// ```
281/// use majordomus_cli::evidence::freshness::{presented_commit, TreeState};
282/// use std::process::Command;
283///
284/// let dir = tempfile::tempdir().unwrap();
285/// let git = |args: &[&str]| {
286/// Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap()
287/// };
288/// git(&["init", "-q"]);
289/// git(&["config", "user.email", "t@example.com"]);
290/// git(&["config", "user.name", "t"]);
291/// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
292///
293/// assert_eq!(presented_commit(dir.path(), "HEAD", None).unwrap().tree(), TreeState::Clean);
294/// // a caller that knows better can weaken it
295/// let told = presented_commit(dir.path(), "HEAD", Some(TreeState::Dirty)).unwrap();
296/// assert_eq!(told.tree(), TreeState::Dirty);
297/// // a revision that names nothing is refused, and the refusal names it
298/// let refused = presented_commit(dir.path(), "no-such-branch", None).unwrap_err();
299/// assert!(refused.contains("no-such-branch"), "{refused}");
300/// ```
301pub fn presented_commit(
302 root: &Path,
303 rev: &str,
304 given: Option<TreeState>,
305) -> std::result::Result<Presented, String> {
306 let commit = match git::resolves(root, rev) {
307 true => commit_of(root, rev),
308 false => None,
309 }
310 .ok_or_else(|| format!("`{rev}` names no commit in this repository"))?;
311 let head = commit_of(root, "HEAD").ok_or_else(|| {
312 "this repository has no checked-out commit, so no commit can be judged as committed"
313 .to_string()
314 })?;
315 if commit != head {
316 return Err(format!(
317 "{rev} is {}, not the checked-out commit {}; check it out to judge at it",
318 short12(&commit),
319 short12(&head)
320 ));
321 }
322 let measured = TreeState::parse(&git::working_tree_ignoring(root, &[LEDGER_PATH]));
323 let tree = measured.weaker(given.unwrap_or(TreeState::Clean));
324 Ok(Presented::Commit { commit, tree })
325}
326
327/// The ledger as `commit` holds it: `git show <commit>:.ai/repo/evidence/ledger.json`.
328///
329/// A commit that holds no ledger has an empty one, as a repository with no ledger file does.
330/// A commit this clone does not have is an error, not an empty ledger: "nothing recorded"
331/// and "nothing to read" are different answers.
332///
333/// ```
334/// use majordomus_cli::evidence::freshness::ledger_at;
335/// use majordomus_cli::evidence::Ledger;
336/// use std::process::Command;
337///
338/// let dir = tempfile::tempdir().unwrap();
339/// let git = |args: &[&str]| {
340/// let out = Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap();
341/// String::from_utf8_lossy(&out.stdout).trim().to_string()
342/// };
343/// git(&["init", "-q"]);
344/// git(&["config", "user.email", "t@example.com"]);
345/// git(&["config", "user.name", "t"]);
346/// git(&["commit", "-q", "--allow-empty", "-m", "nothing recorded yet"]);
347/// let head = git(&["rev-parse", "HEAD"]);
348///
349/// assert_eq!(ledger_at(dir.path(), &head).unwrap(), Ledger::empty());
350/// assert!(ledger_at(dir.path(), &"0".repeat(40)).is_err());
351/// ```
352pub fn ledger_at(root: &Path, commit: &str) -> Result<Ledger> {
353 if option_shaped(commit) {
354 return Err(Error::Git {
355 reason: format!("`{commit}` is not a commit: git would read it as an option"),
356 });
357 }
358 let spec = format!("{commit}:{LEDGER_PATH}");
359 let shown = git::read_only(root)
360 .args(["show", &spec])
361 .output()
362 .ok()
363 .filter(|out| out.status.success());
364 if let Some(out) = shown {
365 return Ledger::parse(&String::from_utf8_lossy(&out.stdout));
366 }
367 if git::resolves(root, commit) {
368 // the commit is here and holds no ledger: nothing was recorded at it
369 return Ok(Ledger::empty());
370 }
371 Err(Error::Git {
372 reason: format!("`{commit}` names no commit git can read in this repository"),
373 })
374}
375
376/// Every execution the working copy of the ledger holds that `committed` does not.
377///
378/// The ledger keeps one execution per test, so a row that differs from the committed row of
379/// its test, or that the committed ledger has no row for, is a run recorded since. These are
380/// read only through [`weakened_by`]: they withhold `proven` and never decide a verdict.
381///
382/// An absent working copy holds none. An unreadable one is an error rather than none: a run
383/// it may hold cannot be ruled out, so a verdict that judged past it could read `proven` over
384/// a failure the checkout has.
385///
386/// ```
387/// use majordomus_cli::evidence::freshness::uncommitted;
388/// use majordomus_cli::evidence::{Ledger, LEDGER_PATH};
389///
390/// let root = tempfile::tempdir().unwrap();
391/// // nothing in the working tree: nothing uncommitted
392/// assert!(uncommitted(root.path(), &Ledger::empty()).unwrap().is_empty());
393///
394/// std::fs::create_dir_all(root.path().join(".ai/repo/evidence")).unwrap();
395/// std::fs::write(root.path().join(LEDGER_PATH), "not json").unwrap();
396/// let refused = uncommitted(root.path(), &Ledger::empty()).unwrap_err().to_string();
397/// assert!(refused.contains(LEDGER_PATH), "{refused}");
398/// ```
399pub fn uncommitted(root: &Path, committed: &Ledger) -> Result<Vec<Execution>> {
400 if !Ledger::present(root) {
401 return Ok(Vec::new());
402 }
403 let working = Ledger::load(root).map_err(|e| Error::InvalidSurface {
404 surface: "evidence".into(),
405 reason: format!(
406 "the working copy of {LEDGER_PATH} cannot be read, so a run it may hold cannot be \
407 ruled out: {e}"
408 ),
409 })?;
410 Ok(working
411 .executions
412 .into_iter()
413 .filter(|e| committed.latest(&e.test) != Some(e))
414 .collect())
415}
416
417// ---------------------------------------------------------------- the comparison
418
419/// Every path that differs between the evidence commit and the presented revision, or
420/// `None` when git cannot answer — which no caller may read as "nothing changed".
421///
422/// For the working tree this is the evidence module's own working-tree diff: tracked
423/// changes committed since, staged or edited, and untracked files. For a presented commit
424/// it is the committed difference between the two commits, and nothing in the checkout.
425///
426/// An evidence commit git would read as an option (`--output=<file>`, which makes `git
427/// diff` write that file) is `None` before git is asked: a ledger row is data, and no data
428/// may choose git's options.
429///
430/// ```
431/// use majordomus_cli::evidence::freshness::{changed_between, presented_commit, Presented};
432/// use std::process::Command;
433///
434/// let dir = tempfile::tempdir().unwrap();
435/// let git = |args: &[&str]| {
436/// let out = Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap();
437/// String::from_utf8_lossy(&out.stdout).trim().to_string()
438/// };
439/// git(&["init", "-q"]);
440/// git(&["config", "user.email", "t@example.com"]);
441/// git(&["config", "user.name", "t"]);
442/// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
443/// let e = git(&["rev-parse", "HEAD"]);
444/// std::fs::write(dir.path().join("a.md"), "a").unwrap();
445/// git(&["add", "-A"]);
446/// git(&["commit", "-q", "-m", "two"]);
447/// std::fs::write(dir.path().join("b.md"), "b").unwrap();
448///
449/// // the working tree carries the untracked file; the commit does not
450/// let here = changed_between(dir.path(), &e, &Presented::WorkingTree).unwrap();
451/// assert!(here.contains("a.md") && here.contains("b.md"));
452/// let committed = presented_commit(dir.path(), "HEAD", None).unwrap();
453/// let there = changed_between(dir.path(), &e, &committed).unwrap();
454/// assert_eq!(there.into_iter().collect::<Vec<_>>(), ["a.md"]);
455/// ```
456pub fn changed_between(
457 root: &Path,
458 evidence_commit: &str,
459 presented: &Presented,
460) -> Option<BTreeSet<String>> {
461 if option_shaped(evidence_commit) {
462 return None;
463 }
464 match presented {
465 Presented::WorkingTree => super::changed_since(root, evidence_commit),
466 Presented::Commit { commit, .. } => git::read_only(root)
467 // both sides of a rename, as the working-tree comparison lists them
468 .args([
469 "diff",
470 "--no-renames",
471 "--name-only",
472 evidence_commit,
473 commit.as_str(),
474 "--",
475 ])
476 .output()
477 .ok()
478 .filter(|out| out.status.success())
479 .map(|out| {
480 String::from_utf8_lossy(&out.stdout)
481 .lines()
482 .map(|l| l.trim().to_string())
483 .filter(|l| !l.is_empty())
484 .collect()
485 }),
486 }
487}
488
489/// What git says about one evidence commit against the presented revision: whether the
490/// revision contains it, and what changed between them.
491///
492/// ```
493/// use majordomus_cli::evidence::freshness::Comparison;
494/// use majordomus_cli::git::Containment;
495///
496/// // git could not be asked: the truth table reads this as `stale`, never as unchanged
497/// let unknown = Comparison { containment: Containment::CommitUnknown, changed: None };
498/// assert!(unknown.changed.is_none());
499/// ```
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct Comparison {
502 /// Whether the presented revision contains the evidence commit.
503 pub containment: Containment,
504 /// The paths that differ between the two, or `None` when git could not answer.
505 pub changed: Option<BTreeSet<String>>,
506}
507
508/// Compare one evidence commit with the presented revision. For the working tree the
509/// containing commit is HEAD (an unborn HEAD contains nothing anyone can name); for a
510/// presented commit it is that commit.
511///
512/// When git cannot place the evidence commit in the presented revision — either of the two
513/// names no commit here — nothing is diffed and `changed` is `None`: the truth table reads
514/// that as `stale` already, and a word that names no commit never reaches `git diff`.
515///
516/// ```
517/// use majordomus_cli::evidence::freshness::{compare, Presented};
518/// use majordomus_cli::git::Containment;
519/// use std::process::Command;
520///
521/// let dir = tempfile::tempdir().unwrap();
522/// let git = |args: &[&str]| {
523/// let out = Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap();
524/// String::from_utf8_lossy(&out.stdout).trim().to_string()
525/// };
526/// git(&["init", "-q"]);
527/// git(&["config", "user.email", "t@example.com"]);
528/// git(&["config", "user.name", "t"]);
529/// git(&["commit", "-q", "--allow-empty", "-m", "one"]);
530/// let e = git(&["rev-parse", "HEAD"]);
531///
532/// let c = compare(dir.path(), &e, &Presented::WorkingTree);
533/// assert_eq!(c.containment, Containment::Contains);
534/// assert_eq!(c.changed, Some(Default::default()));
535/// let gone = compare(dir.path(), &"0".repeat(40), &Presented::WorkingTree);
536/// assert_eq!(gone.containment, Containment::CommitUnknown);
537/// ```
538pub fn compare(root: &Path, evidence_commit: &str, presented: &Presented) -> Comparison {
539 let containing = presented.commit().unwrap_or("HEAD");
540 let containment = git::contains(root, containing, evidence_commit);
541 let changed = match containment {
542 Containment::CommitUnknown => None,
543 _ => changed_between(root, evidence_commit, presented),
544 };
545 Comparison {
546 containment,
547 changed,
548 }
549}
550
551// ---------------------------------------------------------------- the judgement
552
553/// What the ledger holds for one route's test, before any comparison.
554///
555/// ```
556/// use majordomus_cli::evidence::freshness::Recorded;
557/// // a claim that names no test is a different fact from one whose test never ran
558/// assert!(!matches!(Recorded::NoTest, Recorded::NotRun));
559/// ```
560#[derive(Debug, Clone, Copy)]
561pub enum Recorded<'a> {
562 /// The route names no test.
563 NoTest,
564 /// The route names a path no runner drives.
565 Unrunnable,
566 /// A runner owns the test, and the ledger holds no execution of it.
567 NotRun,
568 /// The ledger's execution of the test.
569 Ran(&'a Execution),
570}
571
572/// One verdict, with what it rests on.
573///
574/// ```
575/// use majordomus_cli::evidence::freshness::Judgement;
576/// use majordomus_cli::evidence::ProofState;
577///
578/// let j = Judgement {
579/// state: ProofState::Stale,
580/// changed: vec!["lib/alpha.sh".into()],
581/// detail: None,
582/// };
583/// assert!(j.state.passing(), "a stale pass is still a pass");
584/// ```
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct Judgement {
587 /// The state.
588 pub state: ProofState,
589 /// The declared inputs that changed since the run, in the route's own order, when they
590 /// are what made it `stale`.
591 pub changed: Vec<String>,
592 /// Why, in a sentence, when the state alone does not say.
593 pub detail: Option<String>,
594}
595
596impl Judgement {
597 fn of(state: ProofState) -> Judgement {
598 Judgement {
599 state,
600 changed: Vec::new(),
601 detail: None,
602 }
603 }
604
605 fn because(state: ProofState, detail: impl Into<String>) -> Judgement {
606 Judgement {
607 state,
608 changed: Vec::new(),
609 detail: Some(detail.into()),
610 }
611 }
612}
613
614/// The one truth table: what a recorded run proves at the presented revision.
615///
616/// Pure: every fact it needs is an argument. `inputs` is what the route names — a claim's
617/// source, implementation and test source, a rule's test source and definition — or `None`
618/// for a route that declares none. `test_moved` is whether the test's source no longer
619/// hashes to the recorded digest. `presented_tree` is [`TreeState::Clean`] for the working
620/// tree, whose changes are already in `comparison`, and the measured tree for a commit.
621///
622/// Let `changed'` be the comparison's paths without the ledger's own. The first row that
623/// matches decides:
624///
625/// | # | condition | state |
626/// |---|---|---|
627/// | 1–3 | no test, unrunnable, nothing recorded | `no_test`, `unrunnable`, `not_run` |
628/// | 4 | the run was a skip | `not_run` |
629/// | 5 | it failed, timed out or errored | `failing` |
630/// | 6 | a pass git could not compare | `stale` |
631/// | 7 | a pass the presented revision does not contain | `stale` |
632/// | 8 | a pass whose test moved, no declared input changed | `stale`, naming the test |
633/// | 9 | a pass, a declared input changed | `stale`, naming those inputs |
634/// | 10 | a pass, `changed'` empty, both trees clean | `proven` |
635/// | 11 | as 10, the recorded tree not clean | `inputs_unchanged` |
636/// | 12 | as 10, the presented tree not clean | `inputs_unchanged` |
637/// | 13 | a pass, `changed'` not empty, no declared input among it | `inputs_unchanged` |
638/// | 14 | as 13, but the route declares no inputs | `stale` |
639///
640/// ```
641/// use majordomus_cli::evidence::freshness::{freshness, Comparison, Recorded, TreeState};
642/// use majordomus_cli::evidence::{Execution, ProofState};
643/// use majordomus_cli::git::Containment;
644///
645/// let e: Execution = serde_json::from_value(serde_json::json!({
646/// "test": "suite:07_scope", "runner": "suite", "source": "test/cases/07_scope.sh",
647/// "outcome": "pass", "seconds": 1, "commit": "a".repeat(40), "working_tree": "clean",
648/// "digest": "sha256:0", "at": "2026-09-26T00:00:00Z", "origin": "local",
649/// "command": "bash test/run.sh 07_scope"
650/// }))
651/// .unwrap();
652/// let inputs = vec!["docs/SCOPE.md".to_string()];
653/// let other_history = Comparison {
654/// containment: Containment::DoesNotContain,
655/// changed: Some(Default::default()),
656/// };
657/// let j = freshness(
658/// Recorded::Ran(&e),
659/// Some(&inputs),
660/// "test/cases/07_scope.sh",
661/// false,
662/// Some(&other_history),
663/// TreeState::Clean,
664/// );
665/// // a run on a commit the presented revision does not contain proves some other history
666/// assert_eq!(j.state, ProofState::Stale);
667/// assert!(j.detail.unwrap().contains("does not contain"));
668/// ```
669pub fn freshness(
670 recorded: Recorded<'_>,
671 inputs: Option<&[String]>,
672 test_source: &str,
673 test_moved: bool,
674 comparison: Option<&Comparison>,
675 presented_tree: TreeState,
676) -> Judgement {
677 let e = match recorded {
678 Recorded::NoTest => return Judgement::of(ProofState::NoTest),
679 Recorded::Unrunnable => return Judgement::of(ProofState::Unrunnable),
680 Recorded::NotRun => return Judgement::of(ProofState::NotRun),
681 Recorded::Ran(e) => e,
682 };
683 match e.outcome {
684 Outcome::Pass => {}
685 Outcome::Skip => {
686 return Judgement::because(ProofState::NotRun, "the test declined to run");
687 }
688 Outcome::Fail => return Judgement::because(ProofState::Failing, "failed"),
689 Outcome::Timeout => return Judgement::because(ProofState::Failing, "timed out"),
690 Outcome::Error => {
691 return Judgement::because(ProofState::Failing, "the harness could not run it");
692 }
693 }
694
695 let uncompared = || {
696 Judgement::because(
697 ProofState::Stale,
698 format!(
699 "git could not compare {} with the presented revision",
700 short12(&e.commit)
701 ),
702 )
703 };
704 let Some(comparison) = comparison else {
705 return uncompared();
706 };
707 let changed = match (comparison.containment, &comparison.changed) {
708 (Containment::CommitUnknown, _) | (_, None) => return uncompared(),
709 (Containment::DoesNotContain, Some(_)) => {
710 return Judgement::because(
711 ProofState::Stale,
712 format!(
713 "recorded on {}, which the presented revision does not contain",
714 short12(&e.commit)
715 ),
716 );
717 }
718 (Containment::Contains, Some(changed)) => changed,
719 };
720
721 let declared_changed: Vec<String> = inputs
722 .unwrap_or_default()
723 .iter()
724 .filter(|p| changed.contains(*p))
725 .cloned()
726 .collect();
727 if test_moved && declared_changed.is_empty() {
728 return Judgement {
729 state: ProofState::Stale,
730 changed: vec![test_source.to_string()],
731 detail: None,
732 };
733 }
734 if !declared_changed.is_empty() {
735 return Judgement {
736 state: ProofState::Stale,
737 changed: declared_changed,
738 detail: None,
739 };
740 }
741
742 let changed_at_all = changed.iter().any(|p| p != LEDGER_PATH);
743 if !changed_at_all {
744 // the diff says the presented revision is the commit the run sat on; the run's own
745 // tree says whether it measured that commit, and the presented tree whether what is
746 // shown is that commit. All three, or it is not proven.
747 let capped = capped_by_working_tree(ProofState::Proven, &e.working_tree);
748 if capped != ProofState::Proven {
749 let why = if TreeState::parse(&e.working_tree) == TreeState::Dirty {
750 "the run measured a tree that was not its commit"
751 } else {
752 "the run's tree was not measured, so it cannot be shown to be its commit"
753 };
754 return Judgement::because(capped, why);
755 }
756 if presented_tree != TreeState::Clean {
757 return Judgement::because(
758 ProofState::InputsUnchanged,
759 "the presented revision was built from a tree that was not its commit",
760 );
761 }
762 return Judgement::of(ProofState::Proven);
763 }
764 match inputs {
765 Some(_) => Judgement::of(ProofState::InputsUnchanged),
766 None => Judgement::because(
767 ProofState::Stale,
768 "the route names no inputs, so a change since the run cannot be ruled out",
769 ),
770 }
771}
772
773// ---------------------------------------------------------------- currency
774
775/// Whether a verdict of [`freshness`] is *current* evidence of what its route names: the one
776/// answer a reader that counts a route as met — an intent criterion, for one — asks for, so
777/// that no such reader draws its own line between a pass and a proof.
778///
779/// `proven` is current. `inputs_unchanged` is current only when both trees were the commits
780/// they sit on: the run measured its own commit (`recorded`), and the presented revision is
781/// its own (`presented`; [`TreeState::Clean`] for the working tree, whose changes are already
782/// in the comparison). That is the line [`freshness`] cannot draw by itself, because rows 11,
783/// 12 and 13 of its table all say `inputs_unchanged`: a clean run with nothing it names
784/// changed since is current, and a run on a dirty tree is not — its commit does not describe
785/// what ran ([`super::capped_by_working_tree`]), so no later tree can be matched against it.
786/// Currency is therefore a property of the inputs a route names and of the recorded digest,
787/// never of commit equality with HEAD: committing the ledger after a run, or any change the
788/// route does not name, leaves a current verdict current. Every other state — `stale`,
789/// `failing`, `not_run`, `unrunnable`, `no_test` — is not current.
790///
791/// ```
792/// use majordomus_cli::evidence::freshness::{current, TreeState::{Clean, Dirty, Unknown}};
793/// use majordomus_cli::evidence::ProofState;
794///
795/// assert!(current(ProofState::Proven, Clean, Clean));
796/// assert!(current(ProofState::InputsUnchanged, Clean, Clean));
797/// // a pass on a tree that was not its commit is not current, whatever changed since
798/// assert!(!current(ProofState::InputsUnchanged, Dirty, Clean));
799/// assert!(!current(ProofState::InputsUnchanged, Unknown, Clean));
800/// assert!(!current(ProofState::InputsUnchanged, Clean, Dirty));
801/// // and a stale pass is a pass of something else
802/// assert!(!current(ProofState::Stale, Clean, Clean));
803/// assert!(!current(ProofState::Failing, Clean, Clean));
804/// ```
805pub fn current(state: ProofState, recorded: TreeState, presented: TreeState) -> bool {
806 match state {
807 ProofState::Proven => true,
808 ProofState::InputsUnchanged => {
809 recorded == TreeState::Clean && presented == TreeState::Clean
810 }
811 _ => false,
812 }
813}
814
815// ---------------------------------------------------------------- the monotone rule
816
817/// A record read beside the verdict's own execution, which may weaken it and never
818/// strengthens it.
819///
820/// Its two containments are computed by the caller with [`git::contains`], so that
821/// [`weakened_by`] stays pure: `after_evidence` is whether the record's commit contains the
822/// evidence commit, `in_presented` whether the presented commit contains the record's.
823///
824/// ```
825/// use majordomus_cli::evidence::freshness::{Supplementary, UNCOMMITTED_RUN};
826/// use majordomus_cli::evidence::Execution;
827/// use majordomus_cli::git::Containment;
828///
829/// let e: Execution = serde_json::from_value(serde_json::json!({
830/// "test": "suite:07_scope", "runner": "suite", "source": "test/cases/07_scope.sh",
831/// "outcome": "fail", "seconds": 1, "commit": "b".repeat(40), "working_tree": "clean",
832/// "digest": "sha256:0", "at": "2026-09-26T00:00:00Z", "origin": "local",
833/// "command": "bash test/run.sh 07_scope"
834/// }))
835/// .unwrap();
836/// let record = Supplementary {
837/// execution: &e,
838/// named: UNCOMMITTED_RUN,
839/// after_evidence: Containment::Contains,
840/// in_presented: Containment::Contains,
841/// };
842/// assert_eq!(record.named, "an uncommitted run in the working ledger");
843/// ```
844#[derive(Debug, Clone, Copy)]
845pub struct Supplementary<'a> {
846 /// The record's execution.
847 pub execution: &'a Execution,
848 /// How a detail names the record, for example [`UNCOMMITTED_RUN`].
849 pub named: &'a str,
850 /// Whether the record's commit contains the evidence commit: a record from before the
851 /// evidence says nothing against it.
852 pub after_evidence: Containment,
853 /// Whether the presented commit contains the record's: a record from another history
854 /// says nothing about this one.
855 pub in_presented: Containment,
856}
857
858/// The monotone rule: a supplementary record may withhold `proven` and never grant it.
859///
860/// It changes a judgement only when the judgement is `proven` or `inputs_unchanged` and some
861/// record failed, timed out or errored on a clean tree, at a commit that contains the
862/// evidence commit and that the presented revision contains. Then the state becomes
863/// `stale`, naming the record. The cap is `stale` and not `failing`: a supplementary record
864/// withholds `proven` and never decides a verdict.
865///
866/// ```
867/// use majordomus_cli::evidence::freshness::{weakened_by, Judgement, Supplementary, UNCOMMITTED_RUN};
868/// use majordomus_cli::evidence::{Execution, ProofState};
869/// use majordomus_cli::git::Containment;
870///
871/// let failed: Execution = serde_json::from_value(serde_json::json!({
872/// "test": "suite:07_scope", "runner": "suite", "source": "test/cases/07_scope.sh",
873/// "outcome": "fail", "seconds": 1, "commit": "b".repeat(40), "working_tree": "clean",
874/// "digest": "sha256:0", "at": "2026-09-26T00:00:00Z", "origin": "local",
875/// "command": "bash test/run.sh 07_scope"
876/// }))
877/// .unwrap();
878/// let record = Supplementary {
879/// execution: &failed,
880/// named: UNCOMMITTED_RUN,
881/// after_evidence: Containment::Contains,
882/// in_presented: Containment::Contains,
883/// };
884/// let proven = Judgement { state: ProofState::Proven, changed: vec![], detail: None };
885/// let j = weakened_by(proven, &[record]);
886/// assert_eq!(j.state, ProofState::Stale);
887/// assert!(j.detail.unwrap().contains(&"b".repeat(12)));
888///
889/// // and never the other way
890/// let failing = Judgement { state: ProofState::Failing, changed: vec![], detail: None };
891/// assert_eq!(weakened_by(failing.clone(), &[record]), failing);
892/// ```
893pub fn weakened_by(judgement: Judgement, records: &[Supplementary<'_>]) -> Judgement {
894 if judgement.state >= ProofState::Stale {
895 return judgement;
896 }
897 let counter = records.iter().find_map(|r| {
898 let verb = match r.execution.outcome {
899 Outcome::Fail => "failed",
900 Outcome::Timeout => "timed out",
901 Outcome::Error => "errored",
902 Outcome::Pass | Outcome::Skip => return None,
903 };
904 let clean = TreeState::parse(&r.execution.working_tree) == TreeState::Clean;
905 let placed =
906 r.after_evidence == Containment::Contains && r.in_presented == Containment::Contains;
907 (clean && placed).then_some((r, verb))
908 });
909 match counter {
910 None => judgement,
911 Some((r, verb)) => Judgement {
912 state: ProofState::Stale,
913 changed: judgement.changed,
914 detail: Some(format!(
915 "{} recorded on {} {verb}, so the pass before it proves nothing at the presented \
916 revision",
917 r.named,
918 short12(&r.execution.commit)
919 )),
920 },
921 }
922}
923
924// ---------------------------------------------------------------- aggregation
925
926/// Several states made one: a failure outranks an absence, and otherwise the weakest part
927/// that can carry proof decides.
928///
929/// Each part is a state and whether it can carry proof. The result is `failing` when any
930/// part is `failing`; otherwise the weakest (the greatest, by the enum's declared order) of
931/// the parts that can carry proof; otherwise the weakest of all parts; and `None` for none.
932///
933/// Why the first rule: the declared order ranks `failing` above `not_run`, so a plain
934/// maximum over a failing test and a test that never ran reads `not_run`, and hides the
935/// counter-evidence behind an absence. Why the second: a path no runner and no gate drives
936/// can never carry proof, so it counts only when it is all there is.
937///
938/// ```
939/// use majordomus_cli::evidence::freshness::aggregate;
940/// use majordomus_cli::evidence::ProofState::{Failing, NotRun, Proven, Unrunnable};
941///
942/// assert_eq!(aggregate(&[(Failing, true), (NotRun, true)], Failing), Some(Failing));
943/// assert_eq!(aggregate(&[(Proven, true), (NotRun, true)], Failing), Some(NotRun));
944/// assert_eq!(aggregate(&[(Proven, true), (Unrunnable, false)], Failing), Some(Proven));
945/// assert_eq!(aggregate(&[(Unrunnable, false)], Failing), Some(Unrunnable));
946/// assert_eq!(aggregate::<majordomus_cli::evidence::ProofState>(&[], Failing), None);
947/// ```
948pub fn aggregate<S: Ord + Copy>(parts: &[(S, bool)], failing: S) -> Option<S> {
949 if parts.iter().any(|(s, _)| *s == failing) {
950 return Some(failing);
951 }
952 parts
953 .iter()
954 .filter(|(_, carries)| *carries)
955 .map(|(s, _)| *s)
956 .max()
957 .or_else(|| parts.iter().map(|(s, _)| *s).max())
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use std::process::Command;
964
965 const E: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
966 const R: &str = "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr";
967 const SOURCE: &str = "test/cases/01_alpha.sh";
968
969 /// One execution, built from the ledger's own JSON so that a field added to
970 /// [`Execution`] with a serde default reaches every test here unchanged.
971 fn ran(outcome: &str, commit: &str, tree: &str) -> Execution {
972 serde_json::from_value(serde_json::json!({
973 "test": "suite:01_alpha",
974 "runner": "suite",
975 "source": SOURCE,
976 "outcome": outcome,
977 "seconds": 1,
978 "commit": commit,
979 "working_tree": tree,
980 "digest": "sha256:0",
981 "at": "2026-09-26T00:00:00Z",
982 "origin": "local",
983 "command": "bash test/run.sh 01_alpha"
984 }))
985 .unwrap()
986 }
987
988 fn inputs() -> Vec<String> {
989 vec![
990 "docs/ALPHA.md".to_string(),
991 "lib/alpha.sh".to_string(),
992 SOURCE.to_string(),
993 ]
994 }
995
996 fn contains(paths: &[&str]) -> Comparison {
997 Comparison {
998 containment: Containment::Contains,
999 changed: Some(paths.iter().map(|p| p.to_string()).collect()),
1000 }
1001 }
1002
1003 /// A pass on a clean tree judged against `comparison` and `presented`, with the claim's
1004 /// three inputs and a test that has not moved.
1005 fn judge(e: &Execution, comparison: Option<&Comparison>, presented: TreeState) -> Judgement {
1006 let i = inputs();
1007 freshness(
1008 Recorded::Ran(e),
1009 Some(&i),
1010 SOURCE,
1011 false,
1012 comparison,
1013 presented,
1014 )
1015 }
1016
1017 /// A git repository with one commit, for the rules that ask git.
1018 struct Repo {
1019 dir: tempfile::TempDir,
1020 }
1021
1022 impl Repo {
1023 fn new() -> Repo {
1024 let r = Repo {
1025 dir: tempfile::tempdir().unwrap(),
1026 };
1027 r.git(&["init", "-q"]);
1028 r.git(&["config", "user.email", "t@example.com"]);
1029 r.git(&["config", "user.name", "t"]);
1030 r.git(&["commit", "-q", "--allow-empty", "-m", "init"]);
1031 r
1032 }
1033 fn root(&self) -> &Path {
1034 self.dir.path()
1035 }
1036 fn git(&self, args: &[&str]) -> String {
1037 let out = Command::new("git")
1038 .arg("-C")
1039 .arg(self.root())
1040 .args(args)
1041 .output()
1042 .unwrap();
1043 String::from_utf8_lossy(&out.stdout).trim().to_string()
1044 }
1045 fn write(&self, path: &str, text: &str) {
1046 let p = self.root().join(path);
1047 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1048 std::fs::write(p, text).unwrap();
1049 }
1050 fn commit(&self, path: &str, text: &str) -> String {
1051 self.write(path, text);
1052 self.git(&["add", "-A"]);
1053 self.git(&["commit", "-q", "-m", path]);
1054 self.git(&["rev-parse", "HEAD"])
1055 }
1056 fn ledger(&self, executions: &[Execution]) {
1057 let mut l = Ledger::empty();
1058 l.merge(executions.iter().cloned());
1059 l.save(self.root()).unwrap();
1060 }
1061 }
1062
1063 // ---------------------------------------------------------------- the rows
1064
1065 #[test]
1066 fn row_01_no_test_is_no_test() {
1067 let j = freshness(Recorded::NoTest, None, "", false, None, TreeState::Clean);
1068 assert_eq!(j, Judgement::of(ProofState::NoTest));
1069 }
1070
1071 #[test]
1072 fn row_02_a_path_no_runner_drives_is_unrunnable() {
1073 let j = freshness(
1074 Recorded::Unrunnable,
1075 None,
1076 "",
1077 false,
1078 None,
1079 TreeState::Clean,
1080 );
1081 assert_eq!(j, Judgement::of(ProofState::Unrunnable));
1082 }
1083
1084 #[test]
1085 fn row_03_nothing_recorded_is_not_run() {
1086 let j = freshness(
1087 Recorded::NotRun,
1088 None,
1089 SOURCE,
1090 false,
1091 None,
1092 TreeState::Clean,
1093 );
1094 assert_eq!(j, Judgement::of(ProofState::NotRun));
1095 }
1096
1097 /// A test that declined to run proved nothing and failed nothing: `not_run`, with a
1098 /// detail saying it declined, however fresh everything else is.
1099 #[test]
1100 fn row_04_a_skip_is_not_run() {
1101 let e = ran("skip", E, "clean");
1102 let j = judge(&e, Some(&contains(&[])), TreeState::Clean);
1103 assert_eq!(j.state, ProofState::NotRun);
1104 assert_eq!(j.detail.as_deref(), Some("the test declined to run"));
1105 assert!(j.changed.is_empty());
1106 }
1107
1108 #[test]
1109 fn row_05_a_failure_a_timeout_and_an_error_are_failing() {
1110 for (outcome, detail) in [
1111 ("fail", "failed"),
1112 ("timeout", "timed out"),
1113 ("error", "the harness could not run it"),
1114 ] {
1115 let e = ran(outcome, E, "clean");
1116 let j = judge(&e, Some(&contains(&[])), TreeState::Clean);
1117 assert_eq!(j.state, ProofState::Failing, "{outcome}");
1118 assert_eq!(j.detail.as_deref(), Some(detail), "{outcome}");
1119 }
1120 }
1121
1122 /// Not knowing is not proof: no comparison, a commit git cannot find, and a diff git
1123 /// could not produce are all `stale`, and the detail names the commit.
1124 #[test]
1125 fn row_06_a_pass_git_could_not_compare_is_stale() {
1126 let e = ran("pass", E, "clean");
1127 let unknown = Comparison {
1128 containment: Containment::CommitUnknown,
1129 changed: Some(BTreeSet::new()),
1130 };
1131 let no_diff = Comparison {
1132 containment: Containment::Contains,
1133 changed: None,
1134 };
1135 let no_diff_elsewhere = Comparison {
1136 containment: Containment::DoesNotContain,
1137 changed: None,
1138 };
1139 for c in [
1140 None,
1141 Some(&unknown),
1142 Some(&no_diff),
1143 Some(&no_diff_elsewhere),
1144 ] {
1145 let j = judge(&e, c, TreeState::Clean);
1146 assert_eq!(j.state, ProofState::Stale, "{c:?}");
1147 let d = j.detail.unwrap();
1148 assert!(d.contains("git could not compare"), "{d}");
1149 assert!(d.contains(&E[..12]), "{d}");
1150 }
1151 }
1152
1153 #[test]
1154 fn row_07_a_pass_the_presented_revision_does_not_contain_is_stale() {
1155 let e = ran("pass", E, "clean");
1156 let elsewhere = Comparison {
1157 containment: Containment::DoesNotContain,
1158 changed: Some(BTreeSet::new()),
1159 };
1160 let j = judge(&e, Some(&elsewhere), TreeState::Clean);
1161 assert_eq!(j.state, ProofState::Stale);
1162 assert_eq!(
1163 j.detail.as_deref(),
1164 Some(
1165 format!(
1166 "recorded on {}, which the presented revision does not contain",
1167 &E[..12]
1168 )
1169 .as_str()
1170 )
1171 );
1172 }
1173
1174 /// A test whose source no longer hashes to what ran is not the test that ran, even
1175 /// with no diff to show for it (an edit made and reverted around the run).
1176 #[test]
1177 fn row_08_a_test_that_moved_is_stale_and_named() {
1178 let e = ran("pass", E, "clean");
1179 let i = inputs();
1180 let j = freshness(
1181 Recorded::Ran(&e),
1182 Some(&i),
1183 SOURCE,
1184 true,
1185 Some(&contains(&[])),
1186 TreeState::Clean,
1187 );
1188 assert_eq!(j.state, ProofState::Stale);
1189 assert_eq!(j.changed, [SOURCE]);
1190 assert_eq!(j.detail, None);
1191 }
1192
1193 #[test]
1194 fn row_09_a_declared_input_that_changed_is_stale_and_named_in_input_order() {
1195 let e = ran("pass", E, "clean");
1196 let c = contains(&[SOURCE, "lib/alpha.sh", "unrelated.txt", LEDGER_PATH]);
1197 let j = judge(&e, Some(&c), TreeState::Clean);
1198 assert_eq!(j.state, ProofState::Stale);
1199 assert_eq!(
1200 j.changed,
1201 ["lib/alpha.sh", SOURCE],
1202 "the route's order, not the diff's"
1203 );
1204 // and a moved test whose file also shows in the diff is named once, by the diff
1205 let i = inputs();
1206 let moved = freshness(
1207 Recorded::Ran(&e),
1208 Some(&i),
1209 SOURCE,
1210 true,
1211 Some(&contains(&[SOURCE])),
1212 TreeState::Clean,
1213 );
1214 assert_eq!(moved.changed, [SOURCE]);
1215 }
1216
1217 /// The only proven: contained, nothing but the ledger changed, both trees clean.
1218 #[test]
1219 fn row_10_a_contained_pass_on_clean_trees_with_nothing_changed_is_proven() {
1220 let e = ran("pass", E, "clean");
1221 assert_eq!(
1222 judge(&e, Some(&contains(&[])), TreeState::Clean),
1223 Judgement::of(ProofState::Proven)
1224 );
1225 // the ledger's own row does not age the proof it records
1226 assert_eq!(
1227 judge(&e, Some(&contains(&[LEDGER_PATH])), TreeState::Clean),
1228 Judgement::of(ProofState::Proven)
1229 );
1230 }
1231
1232 #[test]
1233 fn row_11_a_run_on_a_tree_that_was_not_its_commit_is_inputs_unchanged() {
1234 for tree in ["dirty", "unknown", "anything else"] {
1235 let e = ran("pass", E, tree);
1236 for presented in [TreeState::Clean, TreeState::Dirty, TreeState::Unknown] {
1237 let j = judge(&e, Some(&contains(&[])), presented);
1238 assert_eq!(j.state, ProofState::InputsUnchanged, "{tree}");
1239 let detail = if tree == "dirty" {
1240 "the run measured a tree that was not its commit"
1241 } else {
1242 "the run's tree was not measured, so it cannot be shown to be its commit"
1243 };
1244 assert_eq!(
1245 j.detail.as_deref(),
1246 Some(detail),
1247 "{tree} / {presented:?}: row 11 comes before row 12"
1248 );
1249 }
1250 }
1251 }
1252
1253 #[test]
1254 fn row_12_a_presented_tree_that_was_not_its_commit_is_inputs_unchanged() {
1255 let e = ran("pass", E, "clean");
1256 for presented in [TreeState::Dirty, TreeState::Unknown] {
1257 let j = judge(&e, Some(&contains(&[])), presented);
1258 assert_eq!(j.state, ProofState::InputsUnchanged);
1259 assert_eq!(
1260 j.detail.as_deref(),
1261 Some("the presented revision was built from a tree that was not its commit")
1262 );
1263 }
1264 }
1265
1266 #[test]
1267 fn row_13_a_change_the_route_does_not_name_is_inputs_unchanged_for_any_tree() {
1268 for tree in ["clean", "dirty", "unknown"] {
1269 let e = ran("pass", E, tree);
1270 for presented in [TreeState::Clean, TreeState::Dirty, TreeState::Unknown] {
1271 let j = judge(&e, Some(&contains(&["unrelated.txt"])), presented);
1272 assert_eq!(j, Judgement::of(ProofState::InputsUnchanged), "{tree}");
1273 }
1274 }
1275 }
1276
1277 #[test]
1278 fn row_14_a_change_a_route_with_no_inputs_cannot_rule_out_is_stale() {
1279 let e = ran("pass", E, "clean");
1280 let j = freshness(
1281 Recorded::Ran(&e),
1282 None,
1283 SOURCE,
1284 false,
1285 Some(&contains(&["unrelated.txt"])),
1286 TreeState::Clean,
1287 );
1288 assert_eq!(j.state, ProofState::Stale);
1289 assert_eq!(
1290 j.detail.as_deref(),
1291 Some("the route names no inputs, so a change since the run cannot be ruled out")
1292 );
1293 // with nothing changed there is nothing to rule out
1294 let none = freshness(
1295 Recorded::Ran(&e),
1296 None,
1297 SOURCE,
1298 false,
1299 Some(&contains(&[])),
1300 TreeState::Clean,
1301 );
1302 assert_eq!(none.state, ProofState::Proven);
1303 }
1304
1305 /// The working tree's untracked files are part of what is presented: a new file no
1306 /// claim names takes a run from `proven` to `inputs_unchanged` (row 13).
1307 #[test]
1308 fn an_untracked_file_in_the_working_tree_is_a_change_since_the_run() {
1309 let r = Repo::new();
1310 let e = r.git(&["rev-parse", "HEAD"]);
1311 let run = ran("pass", &e, "clean");
1312 let before = compare(r.root(), &e, &Presented::WorkingTree);
1313 assert_eq!(
1314 judge(&run, Some(&before), TreeState::Clean).state,
1315 ProofState::Proven
1316 );
1317
1318 r.write("new-and-untracked.txt", "x");
1319 let after = compare(r.root(), &e, &Presented::WorkingTree);
1320 assert!(after
1321 .changed
1322 .as_ref()
1323 .unwrap()
1324 .contains("new-and-untracked.txt"));
1325 assert_eq!(
1326 judge(&run, Some(&after), TreeState::Clean),
1327 Judgement::of(ProofState::InputsUnchanged)
1328 );
1329 }
1330
1331 /// A file moved since the run changed at both of its paths: the one the run measured is
1332 /// gone. With git's rename detection only the new path was listed, so an input moved
1333 /// away compared as untouched — in the working tree and against a presented commit.
1334 #[test]
1335 fn a_file_moved_since_the_run_changed_at_its_old_path_too() {
1336 let r = Repo::new();
1337 let e = r.commit(
1338 "src/input.rs",
1339 "a body long enough for git to pair it as a rename\n",
1340 );
1341 r.git(&["mv", "src/input.rs", "src/moved.rs"]);
1342 let here = changed_between(r.root(), &e, &Presented::WorkingTree).unwrap();
1343 assert!(here.contains("src/input.rs"), "{here:?}");
1344 assert!(here.contains("src/moved.rs"), "{here:?}");
1345
1346 r.git(&["commit", "-q", "-m", "move"]);
1347 let head = presented_commit(r.root(), "HEAD", None).unwrap();
1348 let there = changed_between(r.root(), &e, &head).unwrap();
1349 assert_eq!(
1350 there.into_iter().collect::<Vec<_>>(),
1351 ["src/input.rs", "src/moved.rs"]
1352 );
1353 }
1354
1355 /// Row 10 is the only way to `proven`, over every combination of every input: a result
1356 /// of `proven` implies a pass, containment, a clean recorded tree, a clean presented
1357 /// tree and nothing but the ledger changed.
1358 #[test]
1359 fn no_combination_is_proven_unless_every_condition_holds() {
1360 let outcomes = ["pass", "fail", "skip", "timeout", "error"];
1361 let containments = [
1362 Containment::Contains,
1363 Containment::DoesNotContain,
1364 Containment::CommitUnknown,
1365 ];
1366 let trees = ["clean", "dirty", "unknown"];
1367 let presented = [TreeState::Clean, TreeState::Dirty, TreeState::Unknown];
1368 let diffs: [Option<&[&str]>; 5] = [
1369 None,
1370 Some(&[]),
1371 Some(&[LEDGER_PATH]),
1372 Some(&["unrelated.txt"]),
1373 Some(&["lib/alpha.sh"]),
1374 ];
1375 let i = inputs();
1376 let mut proven = 0;
1377 for outcome in outcomes {
1378 for containment in containments {
1379 for tree in trees {
1380 for q in presented {
1381 for diff in diffs {
1382 for moved in [false, true] {
1383 for declared in [Some(i.as_slice()), None] {
1384 let e = ran(outcome, E, tree);
1385 let c = Comparison {
1386 containment,
1387 changed: diff
1388 .map(|d| d.iter().map(|p| p.to_string()).collect()),
1389 };
1390 let j = freshness(
1391 Recorded::Ran(&e),
1392 declared,
1393 SOURCE,
1394 moved,
1395 Some(&c),
1396 q,
1397 );
1398 if j.state != ProofState::Proven {
1399 continue;
1400 }
1401 proven += 1;
1402 let only_ledger =
1403 diff.is_some_and(|d| d.iter().all(|p| *p == LEDGER_PATH));
1404 assert!(
1405 outcome == "pass"
1406 && containment == Containment::Contains
1407 && tree == "clean"
1408 && q == TreeState::Clean
1409 && only_ledger
1410 && !moved,
1411 "proven from {outcome} {containment:?} {tree} {q:?} \
1412 {diff:?} moved={moved}"
1413 );
1414 }
1415 }
1416 }
1417 }
1418 }
1419 }
1420 }
1421 assert!(
1422 proven > 0,
1423 "the property held over a set with no proven member"
1424 );
1425 }
1426
1427 // ---------------------------------------------------------------- aggregation
1428
1429 #[test]
1430 fn a_failure_outranks_an_absence() {
1431 use ProofState::*;
1432 assert_eq!(
1433 aggregate(&[(Failing, true), (NotRun, true)], Failing),
1434 Some(Failing)
1435 );
1436 // whichever order, and whether or not the failing part is the one that carries proof
1437 assert_eq!(
1438 aggregate(&[(NotRun, true), (Failing, false)], Failing),
1439 Some(Failing)
1440 );
1441 }
1442
1443 #[test]
1444 fn a_part_nothing_drives_counts_only_when_it_is_all_there_is() {
1445 use ProofState::*;
1446 assert_eq!(
1447 aggregate(&[(Proven, true), (Unrunnable, false)], Failing),
1448 Some(Proven)
1449 );
1450 assert_eq!(aggregate(&[(Unrunnable, false)], Failing), Some(Unrunnable));
1451 }
1452
1453 #[test]
1454 fn otherwise_the_weakest_part_decides_and_nothing_is_nothing() {
1455 use ProofState::*;
1456 assert_eq!(
1457 aggregate(&[(Proven, true), (NotRun, true)], Failing),
1458 Some(NotRun)
1459 );
1460 assert_eq!(
1461 aggregate(&[(InputsUnchanged, true), (Proven, true)], Failing),
1462 Some(InputsUnchanged)
1463 );
1464 assert_eq!(aggregate::<ProofState>(&[], Failing), None);
1465 }
1466
1467 // ---------------------------------------------------------------- the presented commit
1468
1469 #[test]
1470 fn a_revision_other_than_the_checked_out_commit_is_refused() {
1471 let r = Repo::new();
1472 let first = r.git(&["rev-parse", "HEAD"]);
1473 let second = r.commit("a.md", "a");
1474 let refused = presented_commit(r.root(), &first, None).unwrap_err();
1475 assert!(refused.contains(&first[..12]), "{refused}");
1476 assert!(refused.contains(&second[..12]), "{refused}");
1477 assert!(refused.contains("not the checked-out commit"), "{refused}");
1478 let p = presented_commit(r.root(), &second, None).unwrap();
1479 assert_eq!(p.commit(), Some(second.as_str()));
1480
1481 let zeros = "0".repeat(40);
1482 let unknown = presented_commit(r.root(), &zeros, None).unwrap_err();
1483 assert!(unknown.contains(&zeros), "{unknown}");
1484 }
1485
1486 /// A checkout with nothing checked out has no commit to judge as committed, even when
1487 /// the revision named is a commit this clone has.
1488 #[test]
1489 fn an_unborn_head_has_no_commit_to_present() {
1490 let r = Repo::new();
1491 let first = r.git(&["rev-parse", "HEAD"]);
1492 r.git(&["checkout", "-q", "--orphan", "nothing-yet"]);
1493 let refused = presented_commit(r.root(), &first, None).unwrap_err();
1494 assert!(refused.contains("no checked-out commit"), "{refused}");
1495 }
1496
1497 #[test]
1498 fn a_measured_dirty_tree_is_not_overridden_by_a_given_clean_one() {
1499 let r = Repo::new();
1500 r.write("pending.txt", "x");
1501 let p = presented_commit(r.root(), "HEAD", Some(TreeState::Clean)).unwrap();
1502 assert_eq!(p.tree(), TreeState::Dirty);
1503 }
1504
1505 #[test]
1506 fn a_given_dirty_tree_weakens_a_clean_measurement() {
1507 let r = Repo::new();
1508 assert_eq!(
1509 presented_commit(r.root(), "HEAD", None).unwrap().tree(),
1510 TreeState::Clean
1511 );
1512 for given in [TreeState::Dirty, TreeState::Unknown] {
1513 let p = presented_commit(r.root(), "HEAD", Some(given)).unwrap();
1514 assert_eq!(p.tree(), given);
1515 }
1516 }
1517
1518 #[test]
1519 fn a_change_to_the_ledgers_working_copy_alone_keeps_the_presented_tree_clean() {
1520 let r = Repo::new();
1521 r.ledger(&[ran("pass", E, "clean")]);
1522 let p = presented_commit(r.root(), "HEAD", None).unwrap();
1523 assert_eq!(p.tree(), TreeState::Clean, "an untracked ledger");
1524 r.git(&["add", "-A"]);
1525 r.git(&["commit", "-q", "-m", "ledger"]);
1526 r.ledger(&[ran("fail", E, "clean")]);
1527 let p = presented_commit(r.root(), "HEAD", None).unwrap();
1528 assert_eq!(p.tree(), TreeState::Clean, "a modified ledger");
1529 }
1530
1531 #[test]
1532 fn a_commit_without_a_ledger_has_an_empty_one_and_a_commit_with_one_has_that_one() {
1533 let r = Repo::new();
1534 let before = r.git(&["rev-parse", "HEAD"]);
1535 r.ledger(&[ran("pass", E, "clean")]);
1536 r.git(&["add", "-A"]);
1537 r.git(&["commit", "-q", "-m", "ledger"]);
1538 let after = r.git(&["rev-parse", "HEAD"]);
1539 assert_eq!(ledger_at(r.root(), &before).unwrap(), Ledger::empty());
1540 let at = ledger_at(r.root(), &after).unwrap();
1541 assert_eq!(at.executions, [ran("pass", E, "clean")]);
1542 // the working copy moving on does not move what the commit holds
1543 r.ledger(&[ran("fail", E, "clean")]);
1544 assert_eq!(ledger_at(r.root(), &after).unwrap(), at);
1545 assert!(ledger_at(r.root(), &"0".repeat(40)).is_err());
1546 }
1547
1548 #[test]
1549 fn the_diff_to_a_presented_commit_is_what_was_committed_between_the_two() {
1550 let r = Repo::new();
1551 let e = r.git(&["rev-parse", "HEAD"]);
1552 r.commit("a.md", "a");
1553 r.write("pending.txt", "x");
1554 let p = presented_commit(r.root(), "HEAD", None).unwrap();
1555 let c = compare(r.root(), &e, &p);
1556 assert_eq!(c.containment, Containment::Contains);
1557 assert_eq!(c.changed.unwrap().into_iter().collect::<Vec<_>>(), ["a.md"]);
1558 let gone = compare(r.root(), &"0".repeat(40), &p);
1559 assert_eq!(gone.containment, Containment::CommitUnknown);
1560 assert!(gone.changed.is_none());
1561 }
1562
1563 /// A commit git cannot place is not diffed at all, even where a diff would answer: on an
1564 /// orphan branch HEAD is unborn, so the working tree contains nothing anyone can name,
1565 /// and what `git diff <E>` would say about it is not read.
1566 #[test]
1567 fn a_commit_git_cannot_place_is_not_diffed() {
1568 let r = Repo::new();
1569 let e = r.git(&["rev-parse", "HEAD"]);
1570 r.git(&["checkout", "-q", "--orphan", "fresh"]);
1571 r.write("new.md", "new");
1572 assert!(changed_between(r.root(), &e, &Presented::WorkingTree).is_some());
1573 let c = compare(r.root(), &e, &Presented::WorkingTree);
1574 assert_eq!(c.containment, Containment::CommitUnknown);
1575 assert_eq!(c.changed, None, "git could not place {e}");
1576 }
1577
1578 /// A ledger row's commit is data: one that git would read as an option reaches no git
1579 /// command, so `--output=<file>` in its place writes nothing, through every function
1580 /// that hands a commit to git.
1581 #[test]
1582 fn a_commit_git_would_read_as_an_option_writes_nothing() {
1583 let r = Repo::new();
1584 let out = tempfile::tempdir().unwrap();
1585 let target = |name: &str| out.path().join(name);
1586 let option = |name: &str| format!("--output={}", target(name).display());
1587 let head = presented_commit(r.root(), "HEAD", None).unwrap();
1588
1589 assert_eq!(
1590 changed_between(r.root(), &option("here"), &Presented::WorkingTree),
1591 None
1592 );
1593 assert_eq!(changed_between(r.root(), &option("there"), &head), None);
1594 for presented in [&Presented::WorkingTree, &head] {
1595 let c = compare(r.root(), &option("compared"), presented);
1596 assert_eq!(c.containment, Containment::CommitUnknown);
1597 assert_eq!(c.changed, None);
1598 }
1599 // `git show <commit>:<ledger>` would write to that path, were its directory there
1600 let shown = target(&format!("shown:{LEDGER_PATH}"));
1601 std::fs::create_dir_all(shown.parent().unwrap()).unwrap();
1602 let refused = ledger_at(r.root(), &option("shown"))
1603 .map(|_| ())
1604 .unwrap_err();
1605 assert!(!shown.exists(), "git show wrote the ledger");
1606 assert!(refused.to_string().contains("option"), "{refused}");
1607
1608 for name in ["here", "there", "compared"] {
1609 assert!(!target(name).exists(), "git wrote {name}");
1610 }
1611 }
1612
1613 // ---------------------------------------------------------------- the monotone rule
1614
1615 fn record(e: &Execution, after: Containment, within: Containment) -> Supplementary<'_> {
1616 Supplementary {
1617 execution: e,
1618 named: UNCOMMITTED_RUN,
1619 after_evidence: after,
1620 in_presented: within,
1621 }
1622 }
1623
1624 #[test]
1625 fn a_clean_failing_record_after_the_evidence_caps_a_pass_at_stale() {
1626 use Containment::Contains;
1627 for (outcome, verb) in [
1628 ("fail", "failed"),
1629 ("timeout", "timed out"),
1630 ("error", "errored"),
1631 ] {
1632 let e = ran(outcome, R, "clean");
1633 for state in [ProofState::Proven, ProofState::InputsUnchanged] {
1634 let before = Judgement {
1635 state,
1636 changed: vec!["kept".into()],
1637 detail: None,
1638 };
1639 let j = weakened_by(before, &[record(&e, Contains, Contains)]);
1640 assert_eq!(j.state, ProofState::Stale, "{outcome}");
1641 assert_eq!(j.changed, ["kept"]);
1642 let d = j.detail.unwrap();
1643 assert!(d.starts_with(UNCOMMITTED_RUN), "{d}");
1644 assert!(d.contains(&format!("{} {verb}", &R[..12])), "{d}");
1645 }
1646 }
1647 }
1648
1649 #[test]
1650 fn a_passing_or_skipped_record_changes_nothing() {
1651 use Containment::Contains;
1652 for outcome in ["pass", "skip"] {
1653 let e = ran(outcome, R, "clean");
1654 let j = Judgement::of(ProofState::Proven);
1655 assert_eq!(weakened_by(j.clone(), &[record(&e, Contains, Contains)]), j);
1656 }
1657 }
1658
1659 #[test]
1660 fn a_failing_record_on_a_tree_that_was_not_its_commit_changes_nothing() {
1661 use Containment::Contains;
1662 for tree in ["dirty", "unknown"] {
1663 let e = ran("fail", R, tree);
1664 let j = Judgement::of(ProofState::Proven);
1665 assert_eq!(weakened_by(j.clone(), &[record(&e, Contains, Contains)]), j);
1666 }
1667 }
1668
1669 #[test]
1670 fn a_failing_record_from_another_history_changes_nothing() {
1671 use Containment::*;
1672 let e = ran("fail", R, "clean");
1673 let j = Judgement::of(ProofState::Proven);
1674 for (after, within) in [
1675 (DoesNotContain, Contains),
1676 (Contains, DoesNotContain),
1677 (CommitUnknown, Contains),
1678 (Contains, CommitUnknown),
1679 ] {
1680 assert_eq!(
1681 weakened_by(j.clone(), &[record(&e, after, within)]),
1682 j,
1683 "{after:?} {within:?}"
1684 );
1685 }
1686 assert_eq!(weakened_by(j.clone(), &[]), j);
1687 }
1688
1689 /// Monotone: whatever the records, the result is never stronger than what went in.
1690 #[test]
1691 fn no_record_ever_strengthens_a_verdict() {
1692 use Containment::*;
1693 let states = [
1694 ProofState::Proven,
1695 ProofState::InputsUnchanged,
1696 ProofState::Stale,
1697 ProofState::Failing,
1698 ProofState::NotRun,
1699 ProofState::Unrunnable,
1700 ProofState::NoTest,
1701 ];
1702 for state in states {
1703 for outcome in ["pass", "fail", "skip", "timeout", "error"] {
1704 for tree in ["clean", "dirty", "unknown"] {
1705 for after in [Contains, DoesNotContain, CommitUnknown] {
1706 for within in [Contains, DoesNotContain, CommitUnknown] {
1707 let e = ran(outcome, R, tree);
1708 let j = weakened_by(Judgement::of(state), &[record(&e, after, within)]);
1709 assert!(j.state >= state, "{state:?} became {:?}", j.state);
1710 // and a verdict at or below stale is returned exactly as it was
1711 if state >= ProofState::Stale {
1712 assert_eq!(j, Judgement::of(state));
1713 }
1714 }
1715 }
1716 }
1717 }
1718 }
1719 }
1720
1721 // ---------------------------------------------------------------- uncommitted runs
1722
1723 #[test]
1724 fn the_uncommitted_runs_are_exactly_the_rows_that_differ() {
1725 let r = Repo::new();
1726 let pass = ran("pass", E, "clean");
1727 let mut other = ran("pass", E, "clean");
1728 other.test = "suite:02_beta".into();
1729 other.source = "test/cases/02_beta.sh".into();
1730 r.ledger(&[pass.clone(), other.clone()]);
1731 let committed = Ledger::load(r.root()).unwrap();
1732 assert!(uncommitted(r.root(), &committed).unwrap().is_empty());
1733
1734 // one row replaced, one row new, one row as committed
1735 let failed = ran("fail", R, "clean");
1736 let mut third = ran("pass", R, "clean");
1737 third.test = "suite:03_gamma".into();
1738 r.ledger(&[failed.clone(), other.clone(), third.clone()]);
1739 let got = uncommitted(r.root(), &committed).unwrap();
1740 assert_eq!(got, [failed, third]);
1741 }
1742
1743 #[test]
1744 fn an_absent_working_ledger_holds_no_uncommitted_run() {
1745 let r = Repo::new();
1746 let mut committed = Ledger::empty();
1747 committed.merge([ran("pass", E, "clean")]);
1748 assert!(uncommitted(r.root(), &committed).unwrap().is_empty());
1749 }
1750
1751 #[test]
1752 fn an_unreadable_working_ledger_is_an_error_and_not_an_absence() {
1753 let r = Repo::new();
1754 r.write(LEDGER_PATH, "{ not a ledger");
1755 let err = uncommitted(r.root(), &Ledger::empty())
1756 .unwrap_err()
1757 .to_string();
1758 assert!(err.contains(LEDGER_PATH), "{err}");
1759 assert!(err.contains("cannot be ruled out"), "{err}");
1760 }
1761
1762 #[test]
1763 fn a_tree_word_reads_back_and_the_weaker_of_two_is_never_cleaner() {
1764 use TreeState::*;
1765 for a in [Clean, Dirty, Unknown] {
1766 assert_eq!(TreeState::parse(a.as_str()), a);
1767 for b in [Clean, Dirty, Unknown] {
1768 let w = a.weaker(b);
1769 assert_eq!(w, b.weaker(a), "weaker is symmetric");
1770 assert_eq!(w == Clean, a == Clean && b == Clean);
1771 assert_eq!(w == Dirty, a == Dirty || b == Dirty);
1772 }
1773 }
1774 }
1775}