Skip to main content

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(&nothing_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}