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}