Skip to main content

majordomus_cli/evidence/
run.rs

1//! What a run is made of, beyond the executions it recorded.
2//!
3//! A recording reads the reports a run wrote: the suite's TSV, `cargo test`'s output, a
4//! coverage summary. Every entry of a run belongs to exactly one of those reports, and
5//! [`EvidenceProducer`] is the word that names which. Not everything a report holds is
6//! something a claim can name: the crate's own unit-test binary and its doctests ran, and no
7//! claim of `docs/CLAIMS.yaml` can point at one of their tests yet. Such an entry is listed as
8//! an [`EvidenceDropped`], with the report it came from and the reason, rather than ignored,
9//! because a recording that silently threw part of a run away reads as a run that was
10//! smaller than it was.
11//!
12//! # What a run is made of
13//!
14//! A producer word reads back as the producer it names and nothing else reads at all; a
15//! `cargo test` output with the crate's unit tests and one integration binary yields one
16//! binary a claim can name and one dropped entry that says why the other is not one:
17//!
18//! ```
19//! use majordomus_cli::evidence::{read_crate_output, EvidenceProducer, Outcome};
20//!
21//! for p in [EvidenceProducer::Suite, EvidenceProducer::Crate, EvidenceProducer::Coverage] {
22//!     assert_eq!(EvidenceProducer::parse(p.as_str()), Some(p));
23//! }
24//! assert_eq!(EvidenceProducer::parse("cargo"), None, "a word no report is named by");
25//!
26//! let out = "     Running unittests src/lib.rs (target/debug/deps/majordomus_cli-1)\n\
27//!            test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
28//!            finished in 0.50s\n\
29//!                 Running tests/alpha.rs (target/debug/deps/alpha-1)\n\
30//!            test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
31//!            finished in 1.40s\n";
32//! let read = read_crate_output(out);
33//! assert_eq!(read.binaries.len(), 1);
34//! assert_eq!(read.binaries[0].name, "alpha");
35//! assert_eq!(read.binaries[0].outcome, Outcome::Pass);
36//!
37//! assert_eq!(read.dropped.len(), 1, "the unit tests ran, and are listed rather than ignored");
38//! let dropped = &read.dropped[0];
39//! assert_eq!(dropped.producer, EvidenceProducer::Crate);
40//! assert_eq!(dropped.what, "unittests src/lib.rs");
41//! assert!(dropped.reason.contains("no claim can name"), "{}", dropped.reason);
42//! ```
43//!
44//! # A recording, as a typed run
45//!
46//! Every recording returns an [`EvidenceRunRecord`]: its commit, the tree each report's run
47//! measured, its totals, what was absent, dropped or unknown, and the executions, each of
48//! which maps back to the record's id:
49//!
50//! ```
51//! use majordomus_cli::evidence::{record, EvidenceProducer, EvidenceRunRecord, Origin, RecordRequest, TreeState};
52//! use std::process::Command;
53//!
54//! let root = tempfile::tempdir().unwrap();
55//! let git = |args: &[&str]| {
56//!     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
57//! };
58//! git(&["init", "-q"]);
59//! git(&["config", "user.email", "t@example.com"]);
60//! git(&["config", "user.name", "t"]);
61//! std::fs::create_dir_all(root.path().join("test/cases")).unwrap();
62//! std::fs::write(root.path().join("test/cases/01_a.sh"), "echo a\n").unwrap();
63//! git(&["add", "-A"]);
64//! git(&["commit", "-qm", "init"]);
65//!
66//! let reports = tempfile::tempdir().unwrap();
67//! let tsv = reports.path().join("run.tsv");
68//! std::fs::write(&tsv, "01_a\tok\t1\tparallel\n").unwrap();
69//! let out = reports.path().join("run.json");
70//! let req = RecordRequest {
71//!     suite: Some(tsv),
72//!     run_record: Some(out.clone()),
73//!     ..RecordRequest::new(Origin::Local)
74//! };
75//! let run = record(root.path(), &req).unwrap().run_record;
76//!
77//! // no measurement came with the report, so the run's tree is not vouched for
78//! assert_eq!(run.working_tree, TreeState::Unknown);
79//! assert_eq!(run.totals.executions, 1);
80//! assert_eq!(run.absent[0].producer, EvidenceProducer::Crate);
81//! let written: EvidenceRunRecord =
82//!     serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
83//! assert_eq!(written, run);
84//! assert_eq!(EvidenceRunRecord::id_of(&run.executions[0]), run.id);
85//! ```
86
87use std::collections::BTreeMap;
88use std::path::Path;
89
90use schemars::JsonSchema;
91use serde::{Deserialize, Serialize};
92
93use super::coverage::EvidenceCoverage;
94use super::freshness::{weakened_by, Judgement, Presented, Supplementary, TreeState};
95use super::ledger::{outcome_counts, LedgerTarget};
96use super::provenance::EvidenceProvenance;
97use super::{Execution, Origin, RunRef};
98use crate::git::Containment;
99
100/// Which report an entry of a run belongs to: the suite's TSV, `cargo test`'s output, or a
101/// coverage summary.
102///
103/// The serde word is the word a person types and a document carries (`suite`, `crate`,
104/// `coverage`), and [`EvidenceProducer::parse`] reads exactly those three back. A word that
105/// names no report is not a producer, so a misspelled one is refused where it is read
106/// rather than guessed at.
107///
108/// ```
109/// use majordomus_cli::evidence::EvidenceProducer;
110///
111/// assert_eq!(EvidenceProducer::parse("crate"), Some(EvidenceProducer::Crate));
112/// assert_eq!(EvidenceProducer::Coverage.as_str(), "coverage");
113/// assert_eq!(serde_json::to_value(EvidenceProducer::Suite).unwrap(), "suite");
114/// assert_eq!(EvidenceProducer::parse("Suite"), None, "the word is exact");
115/// ```
116#[derive(
117    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
118)]
119#[serde(rename_all = "snake_case")]
120pub enum EvidenceProducer {
121    /// The suite runner's TSV report (`MJ_TEST_REPORT` of `test/run.sh`).
122    Suite,
123    /// `cargo test`'s output: one result line per test binary.
124    Crate,
125    /// A coverage summary (`scripts/rust-coverage --summary-json`).
126    Coverage,
127}
128
129impl EvidenceProducer {
130    /// The producer a word names, or `None` when it names none.
131    ///
132    /// The three words are exact: no case folding and no trimming, because every caller
133    /// reads a word a program wrote and a near-miss is a defect to name, not to absorb.
134    ///
135    /// ```
136    /// use majordomus_cli::evidence::EvidenceProducer;
137    /// assert_eq!(EvidenceProducer::parse("suite"), Some(EvidenceProducer::Suite));
138    /// assert_eq!(EvidenceProducer::parse(" suite"), None);
139    /// assert_eq!(EvidenceProducer::parse(""), None);
140    /// ```
141    pub fn parse(word: &str) -> Option<EvidenceProducer> {
142        match word {
143            "suite" => Some(EvidenceProducer::Suite),
144            "crate" => Some(EvidenceProducer::Crate),
145            "coverage" => Some(EvidenceProducer::Coverage),
146            _ => None,
147        }
148    }
149
150    /// The word this producer is written as: the serde word, and what
151    /// [`EvidenceProducer::parse`] reads back.
152    ///
153    /// ```
154    /// use majordomus_cli::evidence::EvidenceProducer;
155    /// assert_eq!(EvidenceProducer::Crate.as_str(), "crate");
156    /// assert_eq!(
157    ///     EvidenceProducer::parse(EvidenceProducer::Suite.as_str()),
158    ///     Some(EvidenceProducer::Suite)
159    /// );
160    /// ```
161    pub fn as_str(self) -> &'static str {
162        match self {
163            EvidenceProducer::Suite => "suite",
164            EvidenceProducer::Crate => "crate",
165            EvidenceProducer::Coverage => "coverage",
166        }
167    }
168}
169
170/// Something a report held that no claim can name yet, listed instead of ignored.
171///
172/// `cargo test` runs the crate's own unit-test binary and its doctests beside the
173/// integration binaries under `tests/`. They ran, and a claim names a test by its path, so
174/// none of their tests is anything a claim can point at until a runner records them one by
175/// one. A recording lists each such entry with the report it came from ([`producer`]), what
176/// it was ([`what`]) and why it is not recorded ([`reason`]), so that the part of a run
177/// nobody can use yet is visible rather than silently smaller.
178///
179/// [`producer`]: EvidenceDropped::producer
180/// [`what`]: EvidenceDropped::what
181/// [`reason`]: EvidenceDropped::reason
182///
183/// ```
184/// use majordomus_cli::evidence::{EvidenceDropped, EvidenceProducer};
185///
186/// let d: EvidenceDropped = serde_json::from_value(serde_json::json!({
187///     "producer": "crate",
188///     "what": "doc-tests majordomus_cli",
189///     "reason": "doctests, which no claim can name until a runner records them one by one",
190/// }))
191/// .unwrap();
192/// assert_eq!(d.producer, EvidenceProducer::Crate);
193/// assert_eq!(serde_json::to_value(&d).unwrap()["producer"], "crate");
194/// ```
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
196pub struct EvidenceDropped {
197    /// The report the entry was read from.
198    pub producer: EvidenceProducer,
199    /// What the entry was, in the report's own words: `unittests src/lib.rs`,
200    /// `doc-tests <crate>`, the path of a binary that is not under `tests/`, or `a result
201    /// line` when a result belonged to no binary at all.
202    pub what: String,
203    /// Why it is listed rather than recorded. It carries no counts.
204    pub reason: String,
205}
206
207/// The version of the [`EvidenceRunRecord`] document.
208pub const RUN_RECORD_SCHEMA: u32 = 1;
209
210/// One recording, as a typed run: what it recorded, against what, measured how, and what
211/// the reports held that it could not record.
212///
213/// Every `evidence record` returns one and writes it with `--run-record <file>`. It is
214/// supplementary: a verdict is derived from the tracked ledger only, and a run record can
215/// weaken one only through the one monotone rule ([`weakened_by_records`]).
216///
217/// ```
218/// use majordomus_cli::evidence::{EvidenceRunRecord, LedgerTarget, TreeState};
219/// let r: EvidenceRunRecord = serde_json::from_value(serde_json::json!({
220///     "schema": 1, "id": "local:0123456789ab:20260926T120000Z", "origin": "local",
221///     "commit": "0123456789abcdef0123456789abcdef01234567", "working_tree": "clean",
222///     "recorded_at": "2026-09-26T12:00:00Z", "ledger": "repo"
223/// })).unwrap();
224/// assert_eq!((r.working_tree, r.ledger), (TreeState::Clean, LedgerTarget::Repo));
225/// assert!(r.executions.is_empty() && r.coverage.is_none());
226/// ```
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
228pub struct EvidenceRunRecord {
229    /// [`RUN_RECORD_SCHEMA`].
230    pub schema: u32,
231    /// [`run_id`] of the recording.
232    pub id: String,
233    /// Where the run happened.
234    pub origin: Origin,
235    /// The recorder's HEAD, which every measurement given equals.
236    pub commit: String,
237    /// The weakest tree over the reports given: `unknown` for a report with no measurement.
238    pub working_tree: TreeState,
239    /// RFC 3339 UTC, the `at` of every execution.
240    pub recorded_at: String,
241    /// The ledger the executions were merged into.
242    pub ledger: LedgerTarget,
243    /// The CI run, when there was one.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub run: Option<RunRef>,
246    /// Each report's measurement, ordered suite, crate, coverage, each naming its producer.
247    #[serde(default)]
248    pub provenance: Vec<EvidenceProvenance>,
249    /// Counts over the executions.
250    #[serde(default)]
251    pub totals: EvidenceRunTotals,
252    /// The producers that gave nothing, and why.
253    #[serde(default)]
254    pub absent: Vec<EvidenceAbsent>,
255    /// What the reports held that no claim can name yet.
256    #[serde(default)]
257    pub dropped: Vec<EvidenceDropped>,
258    /// Tests the reports named that this repository does not have.
259    #[serde(default)]
260    pub unknown: Vec<String>,
261    /// The coverage measurement, bound to its commit.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub coverage: Option<EvidenceCoverage>,
264    /// The executions recorded.
265    #[serde(default)]
266    pub executions: Vec<Execution>,
267}
268
269/// A recording is ordered by when it was recorded, and its id — unique, the [`run_id`] —
270/// makes the order total when two were recorded in the same second. RFC 3339 UTC reads in
271/// time order under the canonical comparison, its digit runs being fixed-width.
272impl crate::order::Ordered for EvidenceRunRecord {
273    fn order_key(&self) -> crate::order::OrderKey<'_> {
274        crate::order::OrderKey::plain(&self.recorded_at, &self.id)
275    }
276}
277
278/// Counts over a run's executions: how many, of each outcome, of each runner. A zero is
279/// absent rather than 0.
280///
281/// ```
282/// use majordomus_cli::evidence::EvidenceRunTotals;
283/// let t = EvidenceRunTotals::default();
284/// assert_eq!(serde_json::to_value(&t).unwrap()["executions"], 0);
285/// ```
286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
287pub struct EvidenceRunTotals {
288    /// How many executions the run recorded.
289    pub executions: usize,
290    /// By outcome word, as [`crate::evidence::outcome_counts`] counts them.
291    pub outcomes: BTreeMap<String, usize>,
292    /// By runner prefix (`suite`, `crate`).
293    pub runners: BTreeMap<String, usize>,
294}
295
296impl EvidenceRunTotals {
297    /// The totals of `executions`: how many there are, how many of each outcome word, and
298    /// how many of each runner, with a count nothing reached left out rather than written 0.
299    ///
300    /// ```
301    /// use majordomus_cli::evidence::EvidenceRunTotals;
302    /// assert_eq!(EvidenceRunTotals::of(&[]), EvidenceRunTotals::default());
303    /// ```
304    pub fn of(executions: &[Execution]) -> EvidenceRunTotals {
305        let mut runners = BTreeMap::new();
306        for e in executions {
307            *runners.entry(e.runner.prefix().to_string()).or_insert(0) += 1;
308        }
309        EvidenceRunTotals {
310            executions: executions.len(),
311            outcomes: outcome_counts(executions),
312            runners,
313        }
314    }
315}
316
317/// A producer that gave the run nothing, and why.
318///
319/// ```
320/// use majordomus_cli::evidence::{EvidenceAbsent, EvidenceProducer};
321/// let a = EvidenceAbsent { producer: EvidenceProducer::Coverage, reason: "no coverage report was given".into() };
322/// assert_eq!(serde_json::to_value(&a).unwrap()["producer"], "coverage");
323/// ```
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325pub struct EvidenceAbsent {
326    /// The producer.
327    pub producer: EvidenceProducer,
328    /// Why it gave nothing.
329    pub reason: String,
330}
331
332/// The id of a recording: `<origin>:<provider>:<run id>:<attempt>` for a CI run, else
333/// `<origin>:<commit[..12]>:<at without '-' and ':'>`.
334///
335/// ```
336/// use majordomus_cli::evidence::{run_id, Origin};
337/// let c = "0123456789abcdef0123456789abcdef01234567";
338/// assert_eq!(run_id(Origin::Local, None, c, "2026-09-26T12:00:00Z"),
339///     "local:0123456789ab:20260926T120000Z");
340/// ```
341pub fn run_id(origin: Origin, run: Option<&RunRef>, commit: &str, at: &str) -> String {
342    match run {
343        Some(r) => format!(
344            "{}:{}:{}:{}",
345            origin_word(origin),
346            r.provider,
347            r.id,
348            r.attempt
349        ),
350        None => format!(
351            "{}:{}:{}",
352            origin_word(origin),
353            commit.get(..12).unwrap_or(commit),
354            at.replace(['-', ':'], "")
355        ),
356    }
357}
358
359impl EvidenceRunRecord {
360    /// The id of the run record `execution` belongs to: every execution of a recording
361    /// shares its origin, run, commit and `at`.
362    ///
363    /// ```
364    /// use majordomus_cli::evidence::{EvidenceRunRecord, Execution};
365    /// let e: Execution = serde_json::from_value(serde_json::json!({
366    ///     "test": "suite:01_a", "runner": "suite", "source": "test/cases/01_a.sh",
367    ///     "outcome": "pass", "seconds": 1, "commit": "a".repeat(40), "working_tree": "clean",
368    ///     "digest": "sha256:0", "at": "2026-09-26T12:00:00Z", "origin": "local",
369    ///     "command": "bash test/run.sh 01_a"
370    /// })).unwrap();
371    /// assert_eq!(EvidenceRunRecord::id_of(&e), "local:aaaaaaaaaaaa:20260926T120000Z");
372    /// ```
373    pub fn id_of(execution: &Execution) -> String {
374        run_id(
375            execution.origin,
376            execution.run.as_ref(),
377            &execution.commit,
378            &execution.at,
379        )
380    }
381}
382
383/// The origin's serde word.
384fn origin_word(origin: Origin) -> &'static str {
385    match origin {
386        Origin::Local => "local",
387        Origin::Ci => "ci",
388        Origin::Release => "release",
389    }
390}
391
392/// A judgement, weakened by the run records that ran the same test: the second source of
393/// the one monotone rule, [`crate::evidence::freshness::weakened_by`].
394///
395/// It holds no predicate of its own. Each execution of a record that ran `execution`'s test
396/// becomes one [`Supplementary`], placed by `contains(descendant, ancestor)`: after the
397/// evidence when the record's commit contains the evidence commit, and in the presented
398/// history when the presented commit contains the record's. The most recent record comes
399/// first. The result is the input state or `stale`, never anything stronger.
400///
401/// ```
402/// use majordomus_cli::evidence::{weaken_by_records, EvidenceRunRecord, Execution, Judgement, ProofState};
403/// use majordomus_cli::git::Containment;
404/// let exec = |outcome: &str, commit: &str| -> Execution { serde_json::from_value(serde_json::json!({
405///     "test": "suite:01_a", "runner": "suite", "source": "test/cases/01_a.sh",
406///     "outcome": outcome, "seconds": 1, "commit": commit, "working_tree": "clean",
407///     "digest": "sha256:0", "at": "2026-09-26T12:00:00Z", "origin": "ci",
408///     "command": "bash test/run.sh 01_a"
409/// })).unwrap() };
410/// let (e, r) = ("e".repeat(40), "f".repeat(40));
411/// let evidence = exec("pass", &e);
412/// let record: EvidenceRunRecord = serde_json::from_value(serde_json::json!({
413///     "schema": 1, "id": "ci:x", "origin": "ci", "commit": r, "working_tree": "clean",
414///     "recorded_at": "2026-09-26T12:00:00Z", "ledger": "repo",
415///     "executions": [exec("fail", &r)]
416/// })).unwrap();
417/// let proven = Judgement { state: ProofState::Proven, changed: vec![], detail: None };
418/// let j = weaken_by_records(proven, Some(&evidence), &[record], Some(&r), |_, _| Containment::Contains);
419/// assert_eq!(j.state, ProofState::Stale);
420/// ```
421pub fn weaken_by_records(
422    judgement: Judgement,
423    execution: Option<&Execution>,
424    records: &[EvidenceRunRecord],
425    presented_commit: Option<&str>,
426    contains: impl Fn(&str, &str) -> Containment,
427) -> Judgement {
428    let (Some(execution), Some(presented)) = (execution, presented_commit) else {
429        return judgement;
430    };
431    // newest first: the canonical order, which is oldest first, read backwards
432    let mut ordered: Vec<&EvidenceRunRecord> = records.iter().collect();
433    crate::order::canonical(&mut ordered);
434    ordered.reverse();
435    let named: Vec<String> = ordered
436        .iter()
437        .map(|r| format!("the {} run {}", origin_word(r.origin), r.id))
438        .collect();
439    let mut supplementaries = Vec::new();
440    for (r, name) in ordered.iter().zip(&named) {
441        for x in r.executions.iter().filter(|x| x.test == execution.test) {
442            supplementaries.push(Supplementary {
443                execution: x,
444                named: name,
445                after_evidence: contains(&r.commit, &execution.commit),
446                in_presented: contains(presented, &r.commit),
447            });
448        }
449    }
450    weakened_by(judgement, &supplementaries)
451}
452
453/// [`weaken_by_records`] against a checkout: the presented commit is the one `presented`
454/// names, or HEAD for the working tree (none when HEAD is unborn), and containment is
455/// [`crate::git::contains`] in `root`.
456///
457/// ```
458/// use majordomus_cli::evidence::{weakened_by_records, Judgement, Presented, ProofState};
459/// let dir = tempfile::tempdir().unwrap();
460/// let proven = Judgement { state: ProofState::Proven, changed: vec![], detail: None };
461/// let j = weakened_by_records(proven.clone(), None, &[], dir.path(), &Presented::WorkingTree);
462/// assert_eq!(j, proven);
463/// ```
464pub fn weakened_by_records(
465    judgement: Judgement,
466    execution: Option<&Execution>,
467    records: &[EvidenceRunRecord],
468    root: &Path,
469    presented: &Presented,
470) -> Judgement {
471    let presented_commit = match presented {
472        Presented::Commit { commit, .. } => Some(commit.clone()),
473        Presented::WorkingTree => match crate::git::inspect(root) {
474            crate::git::GitState::Available(i) => i.head,
475            crate::git::GitState::Unavailable { .. } => None,
476        },
477    };
478    weaken_by_records(
479        judgement,
480        execution,
481        records,
482        presented_commit.as_deref(),
483        |d, a| crate::git::contains(root, d, a),
484    )
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    /// Each producer's word reads back as that producer, the serde word is the same word,
492    /// and a word that names no report reads as nothing rather than as a guess.
493    #[test]
494    fn a_producer_word_round_trips_and_nothing_else_parses() {
495        for p in [
496            EvidenceProducer::Suite,
497            EvidenceProducer::Crate,
498            EvidenceProducer::Coverage,
499        ] {
500            assert_eq!(EvidenceProducer::parse(p.as_str()), Some(p));
501            assert_eq!(serde_json::to_value(p).unwrap(), p.as_str());
502            let back: EvidenceProducer =
503                serde_json::from_value(serde_json::json!(p.as_str())).unwrap();
504            assert_eq!(back, p);
505        }
506        for word in [
507            "",
508            "Suite",
509            "CRATE",
510            " coverage",
511            "cargo",
512            "unittests",
513            "doc-tests",
514        ] {
515            assert_eq!(EvidenceProducer::parse(word), None, "{word:?}");
516        }
517        assert!(serde_json::from_value::<EvidenceProducer>(serde_json::json!("cargo")).is_err());
518    }
519
520    /// A dropped entry is a document with its producer's word, what it was and why, and it
521    /// reads back as the same entry.
522    #[test]
523    fn a_dropped_entry_serialises_with_its_producer_word() {
524        let d: EvidenceDropped = serde_json::from_value(serde_json::json!({
525            "producer": "crate",
526            "what": "unittests src/lib.rs",
527            "reason": "the crate's own unit tests",
528        }))
529        .unwrap();
530        let v = serde_json::to_value(&d).unwrap();
531        assert_eq!(v["producer"], "crate");
532        assert_eq!(v["what"], "unittests src/lib.rs");
533        assert_eq!(v["reason"], "the crate's own unit tests");
534        assert_eq!(
535            v.as_object().unwrap().len(),
536            3,
537            "a field nobody declared: {v}"
538        );
539        let back: EvidenceDropped = serde_json::from_value(v).unwrap();
540        assert_eq!(back, d);
541    }
542
543    use crate::evidence::ProofState;
544
545    const E: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
546    const R: &str = "ffffffffffffffffffffffffffffffffffffffff";
547    const P: &str = "dddddddddddddddddddddddddddddddddddddddd";
548
549    fn exec(test: &str, outcome: &str, commit: &str, tree: &str, origin: &str) -> Execution {
550        let name = test
551            .trim_start_matches("suite:")
552            .trim_start_matches("crate:");
553        let (runner, source) = if test.starts_with("crate:") {
554            ("crate", format!("apps/majordomus-cli/tests/{name}.rs"))
555        } else {
556            ("suite", format!("test/cases/{name}.sh"))
557        };
558        serde_json::from_value(serde_json::json!({
559            "test": test, "runner": runner, "source": source, "outcome": outcome, "seconds": 1,
560            "commit": commit, "working_tree": tree, "digest": "sha256:0",
561            "at": "2026-09-26T12:00:00Z", "origin": origin, "command": "run it"
562        }))
563        .unwrap()
564    }
565
566    fn record_of(
567        id: &str,
568        at: &str,
569        commit: &str,
570        executions: Vec<Execution>,
571    ) -> EvidenceRunRecord {
572        serde_json::from_value(serde_json::json!({
573            "schema": 1, "id": id, "origin": "ci", "commit": commit, "working_tree": "clean",
574            "recorded_at": at, "ledger": "repo", "executions": executions
575        }))
576        .unwrap()
577    }
578
579    fn judged(state: ProofState) -> Judgement {
580        Judgement {
581            state,
582            changed: vec![],
583            detail: None,
584        }
585    }
586
587    /// Containment in a line of history E < R < P: a later commit contains every earlier one.
588    fn linear(d: &str, a: &str) -> Containment {
589        let pos = |c: &str| [E, R, P].iter().position(|x| *x == c);
590        match (pos(d), pos(a)) {
591            (Some(d), Some(a)) if d >= a => Containment::Contains,
592            (Some(_), Some(_)) => Containment::DoesNotContain,
593            _ => Containment::CommitUnknown,
594        }
595    }
596
597    fn weaken(state: ProofState, records: &[EvidenceRunRecord]) -> Judgement {
598        let evidence = exec("suite:01_a", "pass", E, "clean", "local");
599        weaken_by_records(judged(state), Some(&evidence), records, Some(P), linear)
600    }
601
602    #[test]
603    fn the_run_id_names_the_ci_run_or_the_local_commit_and_time() {
604        let run: RunRef = serde_json::from_value(serde_json::json!({
605            "provider": "github_actions", "id": "77", "attempt": 3, "workflow": "w",
606            "job": "j", "url": "u"
607        }))
608        .unwrap();
609        assert_eq!(
610            run_id(Origin::Ci, Some(&run), E, "x"),
611            "ci:github_actions:77:3"
612        );
613        assert_eq!(
614            run_id(
615                Origin::Local,
616                None,
617                "0123456789abcdef",
618                "2026-09-26T12:00:00Z"
619            ),
620            "local:0123456789ab:20260926T120000Z"
621        );
622        assert_eq!(
623            run_id(Origin::Release, None, "abc", "2026-01-02T03:04:05Z"),
624            "release:abc:20260102T030405Z"
625        );
626    }
627
628    #[test]
629    fn every_execution_maps_back_to_its_run_record_id() {
630        let dir = tempfile::tempdir().unwrap();
631        let git = |args: &[&str]| {
632            let out = std::process::Command::new("git")
633                .arg("-C")
634                .arg(dir.path())
635                .args(args)
636                .output();
637            assert!(out.unwrap().status.success());
638        };
639        git(&["init", "-q"]);
640        git(&["config", "user.email", "t@example.com"]);
641        git(&["config", "user.name", "t"]);
642        for f in ["test/cases/01_a.sh", "test/cases/02_b.sh"] {
643            std::fs::create_dir_all(dir.path().join("test/cases")).unwrap();
644            std::fs::write(dir.path().join(f), "x").unwrap();
645        }
646        git(&["add", "-A"]);
647        git(&["commit", "-qm", "init"]);
648        let reports = tempfile::tempdir().unwrap();
649        let tsv = reports.path().join("r.tsv");
650        std::fs::write(&tsv, "01_a\tok\t1\tparallel\n02_b\tFAIL\t2\tparallel\n").unwrap();
651        let req = crate::evidence::RecordRequest {
652            suite: Some(tsv),
653            ..crate::evidence::RecordRequest::new(Origin::Local)
654        };
655        let got = crate::evidence::record(dir.path(), &req)
656            .unwrap()
657            .run_record;
658        assert_eq!(got.executions.len(), 2);
659        for e in &got.executions {
660            assert_eq!(EvidenceRunRecord::id_of(e), got.id);
661        }
662    }
663
664    #[test]
665    fn totals_count_outcomes_and_runners() {
666        let t = EvidenceRunTotals::of(&[
667            exec("suite:01_a", "pass", E, "clean", "ci"),
668            exec("suite:02_b", "fail", E, "clean", "ci"),
669            exec("crate:why", "pass", E, "clean", "ci"),
670        ]);
671        assert_eq!(t.executions, 3);
672        assert_eq!(
673            t.outcomes,
674            BTreeMap::from([("fail".into(), 1), ("pass".into(), 2)])
675        );
676        assert_eq!(
677            t.runners,
678            BTreeMap::from([("crate".into(), 1), ("suite".into(), 2)])
679        );
680        assert_eq!(
681            serde_json::to_value(EvidenceRunTotals::of(&[])).unwrap()["outcomes"],
682            serde_json::json!({})
683        );
684    }
685
686    #[test]
687    fn a_failing_clean_record_between_e_and_p_caps_proven_at_stale() {
688        let r = record_of(
689            "ci:x:1:1",
690            "2026-09-27T00:00:00Z",
691            R,
692            vec![exec("suite:01_a", "fail", R, "clean", "ci")],
693        );
694        let j = weaken(ProofState::Proven, &[r]);
695        assert_eq!(j.state, ProofState::Stale);
696        assert!(j
697            .detail
698            .unwrap()
699            .starts_with("the ci run ci:x:1:1 recorded on ffffffffffff failed"));
700    }
701
702    /// The newest recording is the one named, whatever order the records arrived in; two
703    /// recorded in the same second are told apart by their ids in the canonical order, so
704    /// `ci:x:1:10` is later than `ci:x:1:9` and not, as a byte comparison has it, earlier.
705    #[test]
706    fn the_newest_failing_record_is_the_one_named() {
707        let failing = |id: &str, at: &str| {
708            record_of(
709                id,
710                at,
711                R,
712                vec![exec("suite:01_a", "fail", R, "clean", "ci")],
713            )
714        };
715        let named = |records: &[EvidenceRunRecord]| {
716            let detail = weaken(ProofState::Proven, records).detail.unwrap();
717            detail.split(" recorded on ").next().unwrap().to_string()
718        };
719        let older = failing("ci:x:1:1", "2026-09-27T00:00:00Z");
720        let newer = failing("ci:x:2:1", "2026-09-28T00:00:00Z");
721        assert_eq!(
722            named(&[older.clone(), newer.clone()]),
723            "the ci run ci:x:2:1"
724        );
725        assert_eq!(named(&[newer, older]), "the ci run ci:x:2:1");
726        let nine = failing("ci:x:1:9", "2026-09-27T00:00:00Z");
727        let ten = failing("ci:x:1:10", "2026-09-27T00:00:00Z");
728        assert_eq!(named(&[ten.clone(), nine.clone()]), "the ci run ci:x:1:10");
729        assert_eq!(named(&[nine, ten]), "the ci run ci:x:1:10");
730    }
731
732    #[test]
733    fn inputs_unchanged_is_capped_too() {
734        let r = record_of(
735            "ci:x:1:1",
736            "t",
737            R,
738            vec![exec("suite:01_a", "error", R, "clean", "ci")],
739        );
740        assert_eq!(
741            weaken(ProofState::InputsUnchanged, &[r]).state,
742            ProofState::Stale
743        );
744    }
745
746    #[test]
747    fn a_record_the_presented_revision_does_not_contain_weakens_nothing() {
748        let other = "cccccccccccccccccccccccccccccccccccccccc";
749        let r = record_of(
750            "ci:x:1:1",
751            "t",
752            other,
753            vec![exec("suite:01_a", "fail", other, "clean", "ci")],
754        );
755        let contains = |d: &str, a: &str| {
756            if d == P && a == other {
757                Containment::DoesNotContain
758            } else {
759                Containment::Contains
760            }
761        };
762        let evidence = exec("suite:01_a", "pass", E, "clean", "local");
763        let j = weaken_by_records(
764            judged(ProofState::Proven),
765            Some(&evidence),
766            &[r],
767            Some(P),
768            contains,
769        );
770        assert_eq!(j, judged(ProofState::Proven));
771    }
772
773    #[test]
774    fn a_record_that_does_not_contain_e_weakens_nothing() {
775        // a record at E's parent: it ran before the evidence
776        let before = "0000000000000000000000000000000000000000";
777        let r = record_of(
778            "ci:x:1:1",
779            "t",
780            before,
781            vec![exec("suite:01_a", "fail", before, "clean", "ci")],
782        );
783        let contains = |d: &str, a: &str| {
784            if d == before && a == E {
785                Containment::DoesNotContain
786            } else {
787                Containment::Contains
788            }
789        };
790        let evidence = exec("suite:01_a", "pass", E, "clean", "local");
791        let j = weaken_by_records(
792            judged(ProofState::Proven),
793            Some(&evidence),
794            &[r],
795            Some(P),
796            contains,
797        );
798        assert_eq!(j, judged(ProofState::Proven));
799    }
800
801    #[test]
802    fn a_dirty_record_weakens_nothing() {
803        for tree in ["dirty", "unknown"] {
804            let r = record_of(
805                "ci:x:1:1",
806                "t",
807                R,
808                vec![exec("suite:01_a", "fail", R, tree, "ci")],
809            );
810            assert_eq!(
811                weaken(ProofState::Proven, &[r]),
812                judged(ProofState::Proven),
813                "{tree}"
814            );
815        }
816    }
817
818    #[test]
819    fn a_passing_or_skipped_record_weakens_nothing() {
820        for outcome in ["pass", "skip"] {
821            let r = record_of(
822                "ci:x:1:1",
823                "t",
824                R,
825                vec![exec("suite:01_a", outcome, R, "clean", "ci")],
826            );
827            assert_eq!(
828                weaken(ProofState::Proven, &[r]),
829                judged(ProofState::Proven),
830                "{outcome}"
831            );
832        }
833        // and a failing record of another test says nothing about this one
834        let r = record_of(
835            "ci:x:1:1",
836            "t",
837            R,
838            vec![exec("suite:02_b", "fail", R, "clean", "ci")],
839        );
840        assert_eq!(weaken(ProofState::Proven, &[r]), judged(ProofState::Proven));
841    }
842
843    #[test]
844    fn nothing_to_weaken_without_evidence_or_a_presented_commit() {
845        let r = record_of(
846            "ci:x:1:1",
847            "t",
848            R,
849            vec![exec("suite:01_a", "fail", R, "clean", "ci")],
850        );
851        let evidence = exec("suite:01_a", "pass", E, "clean", "local");
852        let proven = judged(ProofState::Proven);
853        assert_eq!(
854            weaken_by_records(
855                proven.clone(),
856                None,
857                std::slice::from_ref(&r),
858                Some(P),
859                linear
860            ),
861            proven
862        );
863        assert_eq!(
864            weaken_by_records(proven.clone(), Some(&evidence), &[r], None, linear),
865            proven
866        );
867    }
868
869    #[test]
870    fn weakening_never_strengthens() {
871        let states = [
872            ProofState::Proven,
873            ProofState::InputsUnchanged,
874            ProofState::Stale,
875            ProofState::Failing,
876            ProofState::NotRun,
877            ProofState::Unrunnable,
878            ProofState::NoTest,
879        ];
880        for s in states {
881            for outcome in ["pass", "fail", "skip", "timeout", "error"] {
882                let r = record_of(
883                    "ci:x:1:1",
884                    "t",
885                    R,
886                    vec![exec("suite:01_a", outcome, R, "clean", "ci")],
887                );
888                let got = weaken(s, &[r]).state;
889                assert!(
890                    got == s || (got == ProofState::Stale && s < ProofState::Stale),
891                    "{s:?} {outcome} -> {got:?}"
892                );
893            }
894        }
895    }
896
897    #[test]
898    fn the_most_recent_counter_record_is_named() {
899        let old = record_of(
900            "ci:old:1:1",
901            "2026-09-26T00:00:00Z",
902            R,
903            vec![exec("suite:01_a", "fail", R, "clean", "ci")],
904        );
905        let new = record_of(
906            "ci:new:1:1",
907            "2026-09-27T00:00:00Z",
908            R,
909            vec![exec("suite:01_a", "timeout", R, "clean", "ci")],
910        );
911        let j = weaken(ProofState::Proven, &[old, new]);
912        let detail = j.detail.unwrap();
913        assert!(
914            detail.starts_with("the ci run ci:new:1:1") && detail.contains("timed out"),
915            "{detail}"
916        );
917    }
918
919    #[test]
920    fn weakened_by_records_resolves_head_for_the_working_tree() {
921        let dir = tempfile::tempdir().unwrap();
922        let git = |args: &[&str]| {
923            let out = std::process::Command::new("git")
924                .arg("-C")
925                .arg(dir.path())
926                .args(args)
927                .output()
928                .unwrap();
929            assert!(out.status.success(), "git {args:?}");
930            String::from_utf8(out.stdout).unwrap().trim().to_string()
931        };
932        git(&["init", "-q"]);
933        git(&["config", "user.email", "t@example.com"]);
934        git(&["config", "user.name", "t"]);
935        let proven = judged(ProofState::Proven);
936        let evidence_at = |c: &str| exec("suite:01_a", "pass", c, "clean", "local");
937        // unborn HEAD: no presented commit, nothing to weaken
938        let unborn = weakened_by_records(
939            proven.clone(),
940            Some(&evidence_at(E)),
941            &[],
942            dir.path(),
943            &Presented::WorkingTree,
944        );
945        assert_eq!(unborn, proven);
946        git(&["commit", "-q", "--allow-empty", "-m", "e"]);
947        let e = git(&["rev-parse", "HEAD"]);
948        git(&["commit", "-q", "--allow-empty", "-m", "r"]);
949        let r = git(&["rev-parse", "HEAD"]);
950        let rec = record_of(
951            "ci:x:1:1",
952            "t",
953            &r,
954            vec![exec("suite:01_a", "fail", &r, "clean", "ci")],
955        );
956        let j = weakened_by_records(
957            proven.clone(),
958            Some(&evidence_at(&e)),
959            std::slice::from_ref(&rec),
960            dir.path(),
961            &Presented::WorkingTree,
962        );
963        assert_eq!(
964            j.state,
965            ProofState::Stale,
966            "HEAD is r, which contains the record"
967        );
968        // a record at a commit HEAD does not contain weakens nothing
969        git(&["checkout", "-q", &e]);
970        let j = weakened_by_records(
971            proven.clone(),
972            Some(&evidence_at(&e)),
973            &[rec],
974            dir.path(),
975            &Presented::WorkingTree,
976        );
977        assert_eq!(j, proven);
978    }
979
980    /// A presented commit is taken as given, and a working tree outside any git work tree
981    /// presents no commit: with no record to weigh, either leaves the judgement as it was.
982    #[test]
983    fn weakened_by_records_takes_a_presented_commit_and_no_head_outside_git() {
984        let dir = tempfile::tempdir().unwrap();
985        let proven = judged(ProofState::Proven);
986        let evidence = exec("suite:01_a", "pass", E, "clean", "local");
987        for presented in [
988            Presented::Commit {
989                commit: P.to_string(),
990                tree: TreeState::Clean,
991            },
992            Presented::WorkingTree,
993        ] {
994            let j =
995                weakened_by_records(proven.clone(), Some(&evidence), &[], dir.path(), &presented);
996            assert_eq!(j, proven, "{presented:?}");
997        }
998    }
999}