Skip to main content

majordomus_cli/evidence/
record.rs

1//! The recorder: turning a run that happened into evidence that lasts.
2//!
3//! The runs already write their results down. `test/run.sh` writes a TSV row per case when
4//! `MJ_TEST_REPORT` is set — name, result, seconds, phase — and CI already sets it.
5//! `cargo test` prints a `Running <binary>` line and a `test result:` line per test binary.
6//! Neither is durable, neither carries a commit, and neither is joined to anything.
7//!
8//! This module reads what those runs wrote, carries into each result what the run measured
9//! about its own checkout (which commit, which tree) with the digest, the time and the
10//! origin, and merges it into the ledger.
11//!
12//! # It records, it does not decide
13//!
14//! A case that failed is a case the runner said failed. Nothing here re-judges a result,
15//! and nothing here can produce one: a test id that appears in no report is simply not
16//! recorded, and the report will say `not run` for it, which is true.
17//!
18//! # A crate binary states only what ran
19//!
20//! "Records what the runner said" needs care with `cargo test`, whose result line starts
21//! with `ok` for a binary that ran nothing at all: every test ignored, or every test
22//! filtered out of a run that named one. So [`read_crate_output`] reads the counts, not
23//! only the word. A binary that ran no test, or only a filtered subset, is a skip; one with
24//! a failed test is a failure whatever its word says; one whose result line carries no
25//! count, or that printed no result line at all, is an error, because the harness could not
26//! say what ran. Colour codes are stripped before any line is matched, so a coloured run and
27//! a plain one read the same. What the output held that no claim can name yet, the crate's
28//! own unit-test binary and its doctests, is listed in [`RecordOutcome::dropped`] rather
29//! than ignored.
30//!
31//! # The run measures; the recorder carries
32//!
33//! The recorder does not measure its own checkout. A run measures the checkout it left with
34//! `majordomus evidence stamp` ([`super::provenance::stamp`]): the commit, and the tree with
35//! the evidence ledger ignored and the run's own untracked outputs excluded. The recorder
36//! carries that measurement into every execution of the report it names, and refuses one of
37//! another commit or of other bytes. A report recorded without a measurement carries the
38//! tree `unknown`: the recorder does not vouch for a tree it did not see, and such a row
39//! never reads `proven`. When the measured tree was clean, the digest is taken from the
40//! commit, which holds the bytes the run executed.
41//!
42//! A stamp measures the checkout when it is taken, after the report was written, and not
43//! the tree while the run executed: a run on a dirty tree that is cleaned before the stamp
44//! reads clean.
45//!
46//! # A run, recorded
47//!
48//! A repository with one case, a report the runner wrote, and the execution that comes out
49//! of joining the two:
50//!
51//! ```
52//! use majordomus_cli::evidence::{
53//!     record, stamp, EvidenceProducer, Ledger, Origin, Outcome, RecordRequest, StampRequest,
54//! };
55//! use std::process::Command;
56//!
57//! let root = tempfile::tempdir().unwrap();
58//! let git = |args: &[&str]| {
59//!     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
60//! };
61//! git(&["init", "-q"]);
62//! git(&["config", "user.email", "t@example.com"]);
63//! git(&["config", "user.name", "t"]);
64//! std::fs::create_dir_all(root.path().join("test/cases")).unwrap();
65//! std::fs::write(root.path().join("test/cases/07_scope.sh"), "echo scope\n").unwrap();
66//! git(&["add", "-A"]);
67//! git(&["commit", "-qm", "init"]);
68//!
69//! // what `test/run.sh` already writes when `MJ_TEST_REPORT` is set; kept outside the
70//! // work tree, so the recorded tree state describes the run and not the report
71//! let reports = tempfile::tempdir().unwrap();
72//! let tsv = reports.path().join("run.tsv");
73//! std::fs::write(&tsv, "07_scope\tok\t12\tparallel\n").unwrap();
74//!
75//! // the run measures its own checkout, naming its report
76//! let stamp_req = StampRequest {
77//!     producer: Some(EvidenceProducer::Suite),
78//!     report: Some(tsv.clone()),
79//!     ..StampRequest::default()
80//! };
81//! let measured = stamp(root.path(), &stamp_req).unwrap();
82//!
83//! let mut request = RecordRequest { suite: Some(tsv), ..RecordRequest::new(Origin::Ci) };
84//! request.provenance.insert(EvidenceProducer::Suite, measured);
85//! let outcome = record(root.path(), &request).unwrap();
86//! assert_eq!(outcome.recorded, 1);
87//! assert_eq!(outcome.passed, 1);
88//! assert_eq!(outcome.commit.len(), 40);
89//! assert_eq!(outcome.working_tree, "clean", "the tree the run's own measurement stated");
90//!
91//! // and it is in the ledger, stamped, with the command that produces it again
92//! let ledger = Ledger::load(root.path()).unwrap();
93//! let execution = ledger.latest("suite:07_scope").unwrap();
94//! assert_eq!(execution.outcome, Outcome::Pass);
95//! assert_eq!(execution.seconds, 12);
96//! assert_eq!(execution.origin, Origin::Ci);
97//! assert_eq!(execution.command, "bash test/run.sh 07_scope");
98//! assert_eq!(execution.digest_matches(root.path()), Some(true));
99//! ```
100
101//! # Example
102//!
103//! The recorder reads what a run already wrote and stamps it with the provenance the run
104//! did not carry. Parsing is separable from recording, which is what makes it testable.
105//!
106//! ```
107//! use majordomus_cli::evidence::{parse_crate_binaries, Outcome};
108//! let got = parse_crate_binaries("     Running tests/product.rs (target/debug/deps/product-1)\ntest result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s\n");
109//! assert_eq!(got.len(), 1);
110//! assert_eq!(got[0].1, Outcome::Pass);
111//! ```
112
113use std::collections::BTreeMap;
114use std::path::Path;
115
116use super::coverage::{CommitId, EvidenceCoverage, EvidenceCoverageFloors};
117use super::freshness::TreeState;
118use super::ledger::{LedgerTarget, LEDGER_PATH, LOCAL_LEDGER_PATH};
119use super::provenance::EvidenceProvenance;
120use super::run::{
121    run_id, EvidenceAbsent, EvidenceDropped, EvidenceProducer, EvidenceRunRecord,
122    EvidenceRunTotals, RUN_RECORD_SCHEMA,
123};
124use super::{digest_of, Execution, Ledger, Origin, Outcome, Runner, TestId};
125use crate::error::{Error, Result};
126
127/// Why the crate's own unit-test binary is listed rather than recorded.
128const UNIT_TESTS: &str =
129    "the crate's own unit tests, which no claim can name until a runner records them one by one";
130/// Why the crate's doctests are listed rather than recorded.
131const DOC_TESTS: &str = "doctests, which no claim can name until a runner records them one by one";
132/// Why a binary that is not under `tests/` is listed rather than recorded.
133const NOT_AN_INTEGRATION_BINARY: &str = "not an integration test binary under tests/";
134/// Why a result line with no binary before it is listed rather than recorded.
135const UNATTRIBUTED: &str = "no Running line named the binary it belongs to";
136
137/// What to record, and where from.
138///
139/// Both sources are optional and independent — a run of the suite, a run of the crate, or
140/// one invocation recording both — but a request that names neither is refused rather than
141/// recording an empty run as if nothing had failed.
142///
143/// ```
144/// use majordomus_cli::evidence::{record, Origin, RecordRequest};
145///
146/// let nothing = RecordRequest::default();
147/// let root = tempfile::tempdir().unwrap();
148/// let refused = record(root.path(), &nothing).unwrap_err().to_string();
149/// assert!(refused.contains("nothing to record"), "{refused}");
150///
151/// let suite_run =
152///     RecordRequest { suite: Some("tmp/run.tsv".into()), ..RecordRequest::new(Origin::Ci) };
153/// assert_eq!(suite_run.origin, Origin::Ci);
154/// assert_eq!(suite_run.suite.as_deref(), Some(std::path::Path::new("tmp/run.tsv")));
155/// ```
156#[derive(Debug, Clone)]
157/// # Example
158///
159/// ```
160/// use majordomus_cli::evidence::{Origin, RecordRequest};
161/// let r = RecordRequest::new(Origin::Ci);
162/// assert_eq!(r.origin, Origin::Ci);
163/// ```
164pub struct RecordRequest {
165    /// The runner's TSV report, when a suite run is being recorded.
166    pub suite: Option<std::path::PathBuf>,
167    /// `cargo test`'s output, when a crate run is being recorded.
168    pub crate_output: Option<std::path::PathBuf>,
169    /// Where the run happened.
170    pub origin: Origin,
171    /// The continuous-integration run to stamp every execution with, when there is one.
172    pub run: Option<super::RunRef>,
173    /// What each report's run measured about its checkout, by producer: values, not paths,
174    /// so a runner can pass the measurement it just took.
175    pub provenance: BTreeMap<EvidenceProducer, EvidenceProvenance>,
176    /// A coverage summary (`scripts/rust-coverage --summary-json`), when one is recorded.
177    pub coverage: Option<std::path::PathBuf>,
178    /// The ledger the executions are merged into.
179    pub ledger: LedgerTarget,
180    /// Where to write the run record as well, when asked.
181    pub run_record: Option<std::path::PathBuf>,
182}
183
184impl Default for RecordRequest {
185    /// A local recording of nothing into the tracked ledger.
186    fn default() -> RecordRequest {
187        RecordRequest::new(Origin::Local)
188    }
189}
190
191impl RecordRequest {
192    /// A recording of nothing yet, from `origin`, into the tracked ledger: the base every
193    /// request is built on, so that a field added later breaks no caller.
194    ///
195    /// ```
196    /// use majordomus_cli::evidence::{LedgerTarget, Origin, RecordRequest};
197    /// let r = RecordRequest { suite: Some("run.tsv".into()), ..RecordRequest::new(Origin::Ci) };
198    /// assert_eq!((r.origin, r.ledger), (Origin::Ci, LedgerTarget::Repo));
199    /// assert!(r.provenance.is_empty() && r.coverage.is_none() && r.run_record.is_none());
200    /// ```
201    pub fn new(origin: Origin) -> RecordRequest {
202        RecordRequest {
203            suite: None,
204            crate_output: None,
205            origin,
206            run: None,
207            provenance: BTreeMap::new(),
208            coverage: None,
209            ledger: LedgerTarget::Repo,
210            run_record: None,
211        }
212    }
213}
214
215/// What a recording did: how much of the run reached the ledger, how much of it passed,
216/// the provenance every execution was stamped with, and what the run named that this
217/// repository does not have.
218///
219/// The last is the one worth reading. A report naming a test no runner here owns is
220/// counted in [`RecordOutcome::unknown`] and recorded for nobody, because an execution of
221/// a test that does not exist is evidence for nothing — and dropping it silently would
222/// hide a runner and a matrix that have drifted apart.
223///
224/// ```
225/// # use std::process::Command;
226/// # let root = tempfile::tempdir().unwrap();
227/// # let git = |args: &[&str]| {
228/// #     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
229/// # };
230/// # git(&["init", "-q"]);
231/// # git(&["config", "user.email", "t@example.com"]);
232/// # git(&["config", "user.name", "t"]);
233/// # std::fs::create_dir_all(root.path().join("test/cases")).unwrap();
234/// # std::fs::write(root.path().join("test/cases/07_scope.sh"), "echo scope\n").unwrap();
235/// # std::fs::write(root.path().join("test/cases/08_other.sh"), "echo other\n").unwrap();
236/// # git(&["add", "-A"]);
237/// # git(&["commit", "-qm", "init"]);
238/// use majordomus_cli::evidence::{record, Ledger, Origin, RecordOutcome, RecordRequest};
239///
240/// let reports = tempfile::tempdir().unwrap();
241/// let tsv = reports.path().join("run.tsv");
242/// std::fs::write(&tsv, "07_scope\tok\t1\tparallel\n99_ghost\tok\t1\tparallel\n").unwrap();
243///
244/// let outcome: RecordOutcome = record(
245///     root.path(),
246///     &RecordRequest { suite: Some(tsv), crate_output: None, ..RecordRequest::new(Origin::Local) },
247/// )
248/// .unwrap();
249/// assert_eq!(outcome.recorded, 1, "only the case this repository actually has");
250/// assert_eq!(outcome.passed, 1);
251/// assert_eq!(outcome.working_tree, "unknown", "no measurement came with the report");
252/// assert_eq!(outcome.run_record.unknown, outcome.unknown);
253/// assert_eq!(outcome.unknown, ["suite:99_ghost"], "named here, recorded for nobody");
254/// assert!(Ledger::load(root.path()).unwrap().latest("suite:99_ghost").is_none());
255/// ```
256#[derive(Debug, Clone, PartialEq)]
257/// # Example
258///
259/// A crate run's recording: the integration binary a claim can name is recorded, and the
260/// doctests, which ran and which no claim can name yet, are listed in
261/// [`RecordOutcome::dropped`] rather than ignored.
262///
263/// ```
264/// # use std::process::Command;
265/// # let root = tempfile::tempdir().unwrap();
266/// # let git = |args: &[&str]| {
267/// #     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
268/// # };
269/// # git(&["init", "-q"]);
270/// # git(&["config", "user.email", "t@example.com"]);
271/// # git(&["config", "user.name", "t"]);
272/// # std::fs::create_dir_all(root.path().join("apps/majordomus-cli/tests")).unwrap();
273/// # std::fs::write(root.path().join("apps/majordomus-cli/tests/why.rs"), "// why\n").unwrap();
274/// # git(&["add", "-A"]);
275/// # git(&["commit", "-qm", "init"]);
276/// use majordomus_cli::evidence::{record, EvidenceProducer, Origin, RecordOutcome, RecordRequest};
277///
278/// let reports = tempfile::tempdir().unwrap();
279/// let log = reports.path().join("crate.log");
280/// std::fs::write(
281///     &log,
282///     "     Running tests/why.rs (target/debug/deps/why-1)\n\
283///      test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
284///      finished in 0.10s\n\
285///         Doc-tests majordomus_cli\n\
286///      test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
287///      finished in 1.00s\n",
288/// )
289/// .unwrap();
290///
291/// let request =
292///     RecordRequest { suite: None, crate_output: Some(log), ..RecordRequest::new(Origin::Local) };
293/// let outcome: RecordOutcome = record(root.path(), &request).unwrap();
294/// assert_eq!((outcome.recorded, outcome.passed), (1, 1), "the binary under tests/");
295/// assert_eq!(outcome.dropped.len(), 1, "the doctests, listed");
296/// assert_eq!(outcome.dropped[0].producer, EvidenceProducer::Crate);
297/// assert_eq!(outcome.dropped[0].what, "doc-tests majordomus_cli");
298/// ```
299pub struct RecordOutcome {
300    /// How many executions were written.
301    pub recorded: usize,
302    /// How many of them passed.
303    pub passed: usize,
304    /// The commit they were recorded against.
305    pub commit: String,
306    /// The weakest tree the reports' own measurements stated: `clean`, `dirty`, or
307    /// `unknown` when a report came without one. Read from [`RecordOutcome::run_record`].
308    pub working_tree: String,
309    /// Reports the run named that no runner in this repository owns; recorded for nobody
310    /// and named here rather than dropped.
311    pub unknown: Vec<String>,
312    /// What the reports held that no claim can name yet, in the order the reports held it:
313    /// the crate's own unit-test binary, its doctests, a binary outside `tests/`, a result
314    /// line no binary owned. Listed rather than ignored, so a run is never silently smaller
315    /// than it was. The same list as the run record's.
316    pub dropped: Vec<EvidenceDropped>,
317    /// The recording as a typed run.
318    pub run_record: EvidenceRunRecord,
319}
320
321/// The instant, RFC 3339, UTC, to whole seconds.
322///
323/// Written out rather than taken from a date crate: the crate has no time dependency, and
324/// one timestamp does not justify one. This is the civil-date algorithm, which is exact.
325pub(crate) fn now_rfc3339() -> String {
326    let secs = std::time::SystemTime::now()
327        .duration_since(std::time::UNIX_EPOCH)
328        .map(|d| d.as_secs() as i64)
329        .unwrap_or(0);
330    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
331    let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
332    // days since 1970-01-01 to a civil date (Howard Hinnant's civil_from_days)
333    let z = days + 719_468;
334    let era = z.div_euclid(146_097);
335    let doe = z.rem_euclid(146_097);
336    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
337    let y = yoe + era * 400;
338    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
339    let mp = (5 * doy + 2) / 153;
340    let d = doy - (153 * mp + 2) / 5 + 1;
341    let m = if mp < 10 { mp + 3 } else { mp - 9 };
342    let y = if m <= 2 { y + 1 } else { y };
343    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
344}
345
346/// One integration test binary of a `cargo test` run, as its result line stated it.
347///
348/// `name` is the binary's source under `tests/` without the extension, never the hashed
349/// file cargo built, which changes between builds. The counts are the result line's own
350/// (a count the line did not carry is 0), and `outcome` is decided from them by the rule
351/// [`read_crate_output`] states: a binary that ran nothing is not a pass.
352///
353/// ```
354/// use majordomus_cli::evidence::{read_crate_output, CrateBinary, Outcome};
355///
356/// let read = read_crate_output(
357///     "     Running tests/why.rs (target/debug/deps/why-1)\n\
358///      test result: ok. 1 passed; 0 failed; 2 ignored; 0 measured; 4 filtered out; \
359///      finished in 0.60s\n",
360/// );
361/// let why: &CrateBinary = &read.binaries[0];
362/// assert_eq!(why.name, "why");
363/// assert_eq!((why.passed, why.failed, why.ignored, why.filtered_out), (1, 0, 2, 4));
364/// assert_eq!(why.seconds, 1, "0.60s rounds to the nearest second");
365/// assert_eq!(why.outcome, Outcome::Skip, "a filtered subset is not the binary's proof");
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct CrateBinary {
369    /// The binary's name: `tests/<name>.rs` without the directory and the extension.
370    pub name: String,
371    /// What the binary's result line, read by its counts, says happened.
372    pub outcome: Outcome,
373    /// `finished in` rounded to whole seconds; 0 when the line carried none.
374    pub seconds: u64,
375    /// Tests that ran and passed.
376    pub passed: u64,
377    /// Tests that ran and failed.
378    pub failed: u64,
379    /// Tests that were compiled in and not run.
380    pub ignored: u64,
381    /// Tests a name filter left out of this run.
382    pub filtered_out: u64,
383}
384
385/// What `cargo test`'s output held: the integration binaries a claim can name, and what it
386/// cannot, listed in [`CrateRead::dropped`] in the order the output held it.
387///
388/// ```
389/// use majordomus_cli::evidence::{read_crate_output, CrateRead};
390///
391/// let read: CrateRead = read_crate_output(
392///     "   Doc-tests majordomus_cli\n\
393///      test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
394///      finished in 2.00s\n",
395/// );
396/// assert!(read.binaries.is_empty(), "no claim can name a doctest yet");
397/// assert_eq!(read.dropped[0].what, "doc-tests majordomus_cli");
398/// ```
399#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct CrateRead {
401    /// Each binary under `tests/` that the output named, in order, with its outcome.
402    pub binaries: Vec<CrateBinary>,
403    /// What the output held that no claim can name yet, with the reason for each.
404    pub dropped: Vec<EvidenceDropped>,
405}
406
407/// The entry a `Running` or `Doc-tests` line opened, waiting for its result line.
408enum Pending {
409    /// An integration binary under `tests/`: a claim can name it.
410    Binary(String),
411    /// Anything else: listed, with why, when it closes.
412    Dropped(String, &'static str),
413}
414
415impl CrateRead {
416    /// Close the entry that was waiting for a result line and got none before the next
417    /// `Running`, the next `Doc-tests` or the end of the output. A binary that started and
418    /// never reported is an error, because the harness could not say what ran; anything
419    /// else is listed as it is.
420    fn close(&mut self, pending: Option<Pending>) {
421        match pending {
422            None => {}
423            Some(Pending::Binary(name)) => self.binaries.push(CrateBinary {
424                name,
425                outcome: Outcome::Error,
426                seconds: 0,
427                passed: 0,
428                failed: 0,
429                ignored: 0,
430                filtered_out: 0,
431            }),
432            Some(Pending::Dropped(what, reason)) => self.dropped.push(dropped(what, reason)),
433        }
434    }
435}
436
437/// A crate entry listed rather than recorded.
438fn dropped(what: impl Into<String>, reason: &str) -> EvidenceDropped {
439    EvidenceDropped {
440        producer: EvidenceProducer::Crate,
441        what: what.into(),
442        reason: reason.to_string(),
443    }
444}
445
446/// Read `cargo test`'s output: each `Running tests/<name>.rs` line joined to the
447/// `test result:` line that follows it, judged by its counts, and everything else it ran
448/// listed rather than ignored.
449///
450/// `cargo test`'s machine format is still unstable, so this reads what every cargo prints.
451/// The `Running` line names the binary the following result belongs to; without it a run of
452/// forty binaries is forty anonymous result lines, which is how a per-test-binary outcome
453/// becomes an untraceable total. Escape sequences are stripped from every line before it is
454/// matched, so a coloured run reads as a plain one.
455///
456/// A result line is judged by the first of these that holds:
457///
458/// | The result line | Outcome |
459/// |---|---|
460/// | a failed count above zero | fail |
461/// | the word `FAILED` | fail |
462/// | the word `ok` and no `passed` count | error |
463/// | the word `ok` and nothing passed (every test ignored, say) | skip |
464/// | the word `ok` and a filtered-out count above zero (a subset) | skip |
465/// | the word `ok` | pass |
466/// | any other word | error |
467///
468/// A binary whose `Running` line is followed by no result line is an error. The crate's own
469/// unit-test binary (`Running unittests <path>`), its doctests (`Doc-tests <crate>`), a
470/// binary outside `tests/` and a result line with no `Running` line before it are listed in
471/// [`CrateRead::dropped`] with the reason, and never credited to a neighbour.
472///
473/// ```
474/// use majordomus_cli::evidence::{read_crate_output, Outcome};
475///
476/// let read = read_crate_output(
477///     "     Running tests/all_ignored.rs (target/debug/deps/all_ignored-1)\n\
478///      test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; \
479///      finished in 0.00s\n\
480///           Running tests/broken.rs (target/debug/deps/broken-1)\n\
481///      test result: ok. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; \
482///      finished in 0.20s\n\
483///           Running tests/silent.rs (target/debug/deps/silent-1)\n",
484/// );
485/// let outcomes: Vec<(&str, Outcome)> =
486///     read.binaries.iter().map(|b| (b.name.as_str(), b.outcome)).collect();
487/// assert_eq!(
488///     outcomes,
489///     [("all_ignored", Outcome::Skip), ("broken", Outcome::Fail), ("silent", Outcome::Error)]
490/// );
491/// ```
492pub fn read_crate_output(text: &str) -> CrateRead {
493    let mut read = CrateRead::default();
494    let mut pending: Option<Pending> = None;
495    for line in text.lines() {
496        let plain = strip_ansi(line);
497        let t = plain.trim();
498        if let Some(rest) = t.strip_prefix("Running ") {
499            read.close(pending.take());
500            // `tests/why.rs (target/debug/deps/why-1a2b)` — the source path is the name,
501            // never the hashed binary, which changes between builds
502            let mut words = rest.split_whitespace();
503            let first = words.next().unwrap_or("");
504            pending = Some(if first == "unittests" {
505                let path = words.next().unwrap_or("");
506                Pending::Dropped(format!("unittests {path}"), UNIT_TESTS)
507            } else if let Some(name) = first
508                .strip_prefix("tests/")
509                .and_then(|p| p.strip_suffix(".rs"))
510            {
511                Pending::Binary(name.to_string())
512            } else {
513                Pending::Dropped(first.to_string(), NOT_AN_INTEGRATION_BINARY)
514            });
515            continue;
516        }
517        if let Some(rest) = t.strip_prefix("Doc-tests ") {
518            read.close(pending.take());
519            let krate = rest.split_whitespace().next().unwrap_or("");
520            pending = Some(Pending::Dropped(format!("doc-tests {krate}"), DOC_TESTS));
521            continue;
522        }
523        if let Some(rest) = t.strip_prefix("test result: ") {
524            match pending.take() {
525                None => read.dropped.push(dropped("a result line", UNATTRIBUTED)),
526                Some(Pending::Dropped(what, reason)) => read.dropped.push(dropped(what, reason)),
527                Some(Pending::Binary(name)) => read.binaries.push(judge(name, rest)),
528            }
529        }
530    }
531    read.close(pending.take());
532    read
533}
534
535/// A binary's result line, `<word>. <counts>; finished in <S>s`, judged by the table of
536/// [`read_crate_output`].
537fn judge(name: String, rest: &str) -> CrateBinary {
538    let (word, counts) = rest.split_once('.').unwrap_or((rest, ""));
539    let word = word.trim();
540    let (mut passed, mut failed, mut ignored, mut filtered_out) = (None, 0, 0, 0);
541    let mut seconds = 0;
542    for part in counts.split(';').map(str::trim) {
543        if let Some(s) = part.strip_prefix("finished in ") {
544            seconds = s
545                .trim_end_matches('s')
546                .trim()
547                .parse::<f64>()
548                .map(|f| f.round() as u64)
549                .unwrap_or(0);
550            continue;
551        }
552        let Some((n, label)) = part.split_once(' ') else {
553            continue;
554        };
555        let Ok(n) = n.parse::<u64>() else { continue };
556        match label.trim() {
557            "passed" => passed = Some(n),
558            "failed" => failed = n,
559            "ignored" => ignored = n,
560            "filtered out" => filtered_out = n,
561            _ => {}
562        }
563    }
564    let outcome = if failed > 0 || word == "FAILED" {
565        Outcome::Fail
566    } else if word == "ok" {
567        match passed {
568            // the line said `ok` and not what ran: nothing here can say it passed
569            None => Outcome::Error,
570            // it ran nothing, or only a subset a filter chose: a skip, not the proof
571            Some(0) => Outcome::Skip,
572            Some(_) if filtered_out > 0 => Outcome::Skip,
573            Some(_) => Outcome::Pass,
574        }
575    } else {
576        Outcome::Error
577    };
578    CrateBinary {
579        name,
580        outcome,
581        seconds,
582        passed: passed.unwrap_or(0),
583        failed,
584        ignored,
585        filtered_out,
586    }
587}
588
589/// A line with its terminal escape sequences removed: `ESC [` up to its final byte (`@`
590/// to `~`), `ESC ]` up to BEL or `ESC \`, `ESC` with intermediate bytes (space to `/`) up to
591/// the final byte after them, and any other `ESC` with the character after it.
592/// `cargo test` colours its output when it thinks a person is watching, and a coloured
593/// `Running` or `ok` is the same line as a plain one. The test harness resets its colour
594/// through the terminal's own `sgr0`, which on an xterm is `ESC ( B ESC [ m`: the `( B`
595/// selects the character set, and reading it as a two-character pair would leave its `B`
596/// behind and turn `ok` into `okB`.
597fn strip_ansi(line: &str) -> String {
598    const ESC: char = '\u{1b}';
599    let mut out = String::with_capacity(line.len());
600    let mut chars = line.chars().peekable();
601    while let Some(c) = chars.next() {
602        if c != ESC {
603            out.push(c);
604            continue;
605        }
606        match chars.next() {
607            Some('[') => {
608                for c in chars.by_ref() {
609                    if ('@'..='~').contains(&c) {
610                        break;
611                    }
612                }
613            }
614            Some(']') => {
615                let mut after_esc = false;
616                for c in chars.by_ref() {
617                    if c == '\u{7}' || (after_esc && c == '\\') {
618                        break;
619                    }
620                    after_esc = c == ESC;
621                }
622            }
623            // intermediate bytes, then the one final byte that ends the sequence
624            Some(' '..='/') => {
625                while chars.next_if(|c| (' '..='/').contains(c)).is_some() {}
626                chars.next();
627            }
628            Some(_) | None => {}
629        }
630    }
631    out
632}
633
634/// One `Running tests/<name>.rs` / `test result:` pair per test binary, joined: the
635/// binaries of [`read_crate_output`] as `(name, outcome, seconds)`, without what it listed.
636///
637/// ```
638/// use majordomus_cli::evidence::parse_crate_binaries;
639/// let out = "   Running tests/why.rs (target/debug/deps/why-1a2b)\n\
640///             test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
641///             finished in 0.41s\n\
642///                Running tests/product.rs (target/debug/deps/product-3c4d)\n\
643///             test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured\n";
644/// let got = parse_crate_binaries(out);
645/// assert_eq!(got.len(), 2);
646/// assert_eq!(got[0].0, "why");
647/// assert!(got[0].1.proves());
648/// assert_eq!(got[1].0, "product");
649/// assert!(!got[1].1.proves());
650/// ```
651/// ```
652/// use majordomus_cli::evidence::parse_crate_binaries;
653/// assert!(parse_crate_binaries("nothing to see here").is_empty());
654/// ```
655/// The outputs of several lanes are joined end to end, so a binary whose run was cut short
656/// must not take the next result line, a doctest run's least of all: it stands as an error
657/// that ran nothing, and the doctest result belongs to no binary.
658/// ```
659/// use majordomus_cli::evidence::{parse_crate_binaries, Outcome};
660/// let joined = "     Running tests/cut.rs (target/debug/deps/cut-1a2b)\n\
661///                  Doc-tests majordomus_cli\n\
662///               test result: ok. 900 passed; 0 failed; finished in 600.00s\n";
663/// assert_eq!(parse_crate_binaries(joined), [("cut".to_string(), Outcome::Error, 0)]);
664/// ```
665pub fn parse_crate_binaries(text: &str) -> Vec<(String, Outcome, u64)> {
666    read_crate_output(text)
667        .binaries
668        .into_iter()
669        .map(|b| (b.name, b.outcome, b.seconds))
670        .collect()
671}
672
673/// The runner's TSV: `name <TAB> result <TAB> seconds <TAB> phase`, one case per line.
674///
675/// A malformed line is refused rather than skipped, for the same reason the renderer
676/// refuses one: a recording that silently dropped a case would leave that case's claim
677/// reporting `not run` after a run that did run it, which is a lie in the safe direction
678/// and still a lie.
679fn parse_suite(text: &str) -> Result<Vec<(String, Outcome, u64)>> {
680    let mut out = Vec::new();
681    for (n, line) in text.lines().enumerate() {
682        if line.trim().is_empty() {
683            continue;
684        }
685        let f: Vec<&str> = line.split('\t').collect();
686        if f.len() < 4 {
687            return Err(Error::InvalidSurface {
688                surface: "evidence".into(),
689                reason: format!(
690                    "line {} of the run report has {} field(s); it is \
691                     name<TAB>result<TAB>seconds<TAB>phase",
692                    n + 1,
693                    f.len()
694                ),
695            });
696        }
697        out.push((
698            f[0].to_string(),
699            Outcome::parse(f[1]),
700            f[2].parse().unwrap_or(0),
701        ));
702    }
703    Ok(out)
704}
705
706/// Read the runs a request names, stamp each result with provenance, and merge into the
707/// ledger of `root`.
708///
709/// It decides nothing: a case that failed is a case the runner said failed. Every refusal
710/// comes before any write: a request naming no report, a tree with no commit, a
711/// measurement of a report not given, of another producer, of another commit or of other
712/// bytes, a local or release measurement that names no report, a CI report without a
713/// measurement, a malformed report (refused rather than partly read), a coverage summary
714/// of another schema, and a run-record path that is a directory or a ledger.
715///
716/// Each execution carries its report's measured tree, or `unknown` without one, and the
717/// recording is returned as an [`EvidenceRunRecord`] in [`RecordOutcome::run_record`].
718///
719/// Because it merges, a later partial run updates only what it ran:
720///
721/// ```
722/// # use std::process::Command;
723/// # let root = tempfile::tempdir().unwrap();
724/// # let git = |args: &[&str]| {
725/// #     Command::new("git").arg("-C").arg(root.path()).args(args).output().unwrap()
726/// # };
727/// # git(&["init", "-q"]);
728/// # git(&["config", "user.email", "t@example.com"]);
729/// # git(&["config", "user.name", "t"]);
730/// # std::fs::create_dir_all(root.path().join("test/cases")).unwrap();
731/// # std::fs::write(root.path().join("test/cases/07_scope.sh"), "echo scope\n").unwrap();
732/// # std::fs::write(root.path().join("test/cases/08_other.sh"), "echo other\n").unwrap();
733/// # git(&["add", "-A"]);
734/// # git(&["commit", "-qm", "init"]);
735/// use majordomus_cli::evidence::{record, Ledger, Origin, Outcome, RecordRequest};
736///
737/// let reports = tempfile::tempdir().unwrap();
738/// let suite = |name: &str, rows: &str| {
739///     let p = reports.path().join(name);
740///     std::fs::write(&p, rows).unwrap();
741///     RecordRequest { suite: Some(p), crate_output: None, ..RecordRequest::new(Origin::Local) }
742/// };
743///
744/// let both = suite("all.tsv", "07_scope\tok\t1\tparallel\n08_other\tok\t2\tparallel\n");
745/// assert_eq!(record(root.path(), &both).unwrap().recorded, 2);
746///
747/// let again = suite("one.tsv", "07_scope\tFAIL\t9\tserial\n");
748/// assert_eq!(record(root.path(), &again).unwrap().recorded, 1);
749///
750/// let ledger = Ledger::load(root.path()).unwrap();
751/// assert_eq!(ledger.latest("suite:07_scope").unwrap().outcome, Outcome::Fail);
752/// assert_eq!(
753///     ledger.latest("suite:08_other").unwrap().outcome,
754///     Outcome::Pass,
755///     "the second run must not erase evidence it never measured",
756/// );
757///
758/// // a malformed line is refused, not skipped: an under-recorded run would report
759/// // `not run` for a test that ran
760/// let short = suite("bad.tsv", "07_scope\tok\t12\n");
761/// let refused = record(root.path(), &short).unwrap_err().to_string();
762/// assert!(refused.contains("field(s)"), "{refused}");
763/// ```
764pub fn record(root: &Path, req: &RecordRequest) -> Result<RecordOutcome> {
765    use EvidenceProducer as P;
766    if req.suite.is_none() && req.crate_output.is_none() && req.coverage.is_none() {
767        return Err(refused(
768            "nothing to record: give --suite, --crate-output, --coverage or several",
769        ));
770    }
771
772    // the commit as a full id, once: every row and the coverage record carry this one
773    let commit_id = match crate::git::inspect(root) {
774        crate::git::GitState::Available(i) => i.head,
775        crate::git::GitState::Unavailable { .. } => None,
776    }
777    .and_then(|h| CommitId::parse(&h).ok())
778    .ok_or_else(|| {
779        refused(
780            "this is not a git work tree with a commit, so a run recorded here would carry no \
781             provenance and prove nothing",
782        )
783    })?;
784    let commit = commit_id.as_str().to_string();
785
786    let report_of = |p: EvidenceProducer| match p {
787        P::Suite => req.suite.as_deref(),
788        P::Crate => req.crate_output.as_deref(),
789        P::Coverage => req.coverage.as_deref(),
790    };
791    for (&p, prov) in &req.provenance {
792        let w = p.as_str();
793        if report_of(p).is_none() {
794            return Err(refused(format!(
795                "a measurement of the {w} report was given, and no {w} report"
796            )));
797        }
798        if let Some(x) = prov.producer.filter(|x| *x != p) {
799            return Err(refused(format!(
800                "the {w} measurement measures the {} report, not the {w} one",
801                x.as_str()
802            )));
803        }
804        if CommitId::parse(&prov.commit).is_err() || prov.commit != commit {
805            return Err(refused(format!(
806                "the {w} report was measured at {}, and this checkout is at {}: record it in a \
807                 checkout of the commit it ran",
808                short12(&prov.commit),
809                short12(&commit)
810            )));
811        }
812        if req.origin != Origin::Ci && prov.report.is_none() {
813            return Err(refused(format!(
814                "the {w} measurement names no report, so it cannot say which run it measured: \
815                 stamp with --report <file>"
816            )));
817        }
818    }
819    if req.origin == Origin::Ci {
820        for p in [P::Suite, P::Crate] {
821            if report_of(p).is_some() && !req.provenance.contains_key(&p) {
822                return Err(refused(format!(
823                    "a CI report is recorded only with the measurement its own run made: give \
824                     --provenance {}=<file> from `majordomus evidence stamp`",
825                    p.as_str()
826                )));
827            }
828        }
829    }
830    // the bytes a report holds, refused when its measurement describes other bytes
831    let read_report = |p: EvidenceProducer, path: &Path| -> Result<Vec<u8>> {
832        let bytes = std::fs::read(path).map_err(Error::Transport)?;
833        if let Some(artifact) = req.provenance.get(&p).and_then(|m| m.report.as_ref()) {
834            let actual = digest_of(&bytes);
835            if artifact.digest != actual {
836                return Err(refused(format!(
837                    "the measurement describes another report ({} ≠ {})",
838                    short12(artifact.digest.trim_start_matches("sha256:")),
839                    short12(actual.trim_start_matches("sha256:"))
840                )));
841            }
842        }
843        Ok(bytes)
844    };
845    let tree_of = |p: EvidenceProducer| {
846        req.provenance
847            .get(&p)
848            .map_or(TreeState::Unknown, |m| m.working_tree)
849    };
850
851    let mut results: Vec<(TestId, Outcome, u64)> = Vec::new();
852    let mut unknown = Vec::new();
853    let mut absent = Vec::new();
854
855    if let Some(p) = &req.suite {
856        let bytes = read_report(P::Suite, p)?;
857        let text = String::from_utf8_lossy(&bytes);
858        let before = results.len();
859        for (name, outcome, seconds) in parse_suite(&text)? {
860            // the runner names a case by its stem, which is exactly the suite test id
861            let id = TestId {
862                runner: Runner::Suite,
863                name: name.clone(),
864            };
865            if root.join(id.source()).exists() {
866                results.push((id, outcome, seconds));
867            } else {
868                unknown.push(format!("suite:{name}"));
869            }
870        }
871        if results.len() == before {
872            absent.push(absence(
873                P::Suite,
874                "the report named no test this repository has",
875            ));
876        }
877    } else {
878        absent.push(absence(P::Suite, "no suite report was given"));
879    }
880
881    let mut dropped = Vec::new();
882    if let Some(p) = &req.crate_output {
883        let bytes = read_report(P::Crate, p)?;
884        let read = read_crate_output(&String::from_utf8_lossy(&bytes));
885        if read.binaries.is_empty() {
886            absent.push(absence(
887                P::Crate,
888                "the output named no integration test binary",
889            ));
890        }
891        for binary in read.binaries {
892            let id = TestId {
893                runner: Runner::Crate,
894                name: binary.name.clone(),
895            };
896            if root.join(id.source()).exists() {
897                results.push((id, binary.outcome, binary.seconds));
898            } else {
899                unknown.push(format!("crate:{}", binary.name));
900            }
901        }
902        // what the output held that no claim can name yet, in the order it held it
903        dropped = read.dropped;
904    } else {
905        absent.push(absence(P::Crate, "no crate report was given"));
906    }
907
908    let coverage = match &req.coverage {
909        Some(p) => {
910            let bytes = read_report(P::Coverage, p)?;
911            let floors = EvidenceCoverageFloors::read(root);
912            let text = String::from_utf8_lossy(&bytes);
913            let cov =
914                EvidenceCoverage::from_summary(&text, commit_id, tree_of(P::Coverage), floors)
915                    .map_err(|e| refused(format!("{}: {e}", p.display())))?;
916            Some(cov)
917        }
918        None => {
919            absent.push(absence(P::Coverage, "no coverage report was given"));
920            None
921        }
922    };
923
924    if let Some(out) = &req.run_record {
925        let is_ledger = [LEDGER_PATH, LOCAL_LEDGER_PATH].iter().any(|l| {
926            let ledger = root.join(l);
927            out == &ledger
928                || matches!(
929                    (std::fs::canonicalize(out), std::fs::canonicalize(&ledger)),
930                    (Ok(a), Ok(b)) if a == b
931                )
932        });
933        if out.as_os_str().is_empty() || out.is_dir() || is_ledger {
934            return Err(refused(format!(
935                "`{}` cannot hold a run record: it is empty, a directory or a ledger",
936                out.display()
937            )));
938        }
939    }
940
941    let at = now_rfc3339();
942    let mut executions = Vec::with_capacity(results.len());
943    let mut passed = 0;
944    for (id, outcome, seconds) in results {
945        if outcome.proves() {
946            passed += 1;
947        }
948        let producer = match id.runner {
949            Runner::Suite => P::Suite,
950            Runner::Crate => P::Crate,
951        };
952        let tree = tree_of(producer);
953        let source = id.source();
954        let digest = digest_ran(root, &commit, &source, tree);
955        executions.push(Execution {
956            test: id.as_string(),
957            runner: id.runner,
958            source,
959            outcome,
960            seconds,
961            commit: commit.clone(),
962            working_tree: tree.as_str().into(),
963            digest,
964            at: at.clone(),
965            origin: req.origin,
966            command: id.reproduce(),
967            run: req.run.clone(),
968        });
969    }
970
971    let mut ledger = Ledger::load_from(root, req.ledger)?;
972    let recorded = ledger.merge(executions.clone());
973    // A run that recorded nothing writes nothing. Saving here would create a ledger
974    // holding no executions out of a report that named only tests this repository does
975    // not have — a file that says "evidence was recorded" where none was. The malformed
976    // report above is refused before any write for the same reason, and the two paths
977    // must not disagree about what an empty recording leaves behind.
978    if recorded > 0 {
979        ledger.save_to(root, req.ledger)?;
980    }
981
982    crate::order::canonical_strings(&mut unknown);
983    unknown.dedup();
984    let given = [P::Suite, P::Crate, P::Coverage]
985        .into_iter()
986        .filter(|p| report_of(*p).is_some());
987    let working_tree = given.fold(TreeState::Clean, |t, p| t.weaker(tree_of(p)));
988    let run_record = EvidenceRunRecord {
989        schema: RUN_RECORD_SCHEMA,
990        id: run_id(req.origin, req.run.as_ref(), &commit, &at),
991        origin: req.origin,
992        commit: commit.clone(),
993        working_tree,
994        recorded_at: at,
995        ledger: req.ledger,
996        run: req.run.clone(),
997        provenance: req.provenance.values().cloned().collect(),
998        totals: EvidenceRunTotals::of(&executions),
999        absent,
1000        dropped,
1001        unknown,
1002        coverage,
1003        executions,
1004    };
1005
1006    if let Some(out) = &req.run_record {
1007        write_run_record(out, &run_record).map_err(|e| {
1008            refused(format!(
1009                "the executions were recorded into {}, and the run record could not be \
1010                 written to {}: {e}",
1011                req.ledger.path(),
1012                out.display()
1013            ))
1014        })?;
1015    }
1016
1017    Ok(RecordOutcome {
1018        recorded,
1019        passed,
1020        commit,
1021        working_tree: run_record.working_tree.as_str().to_string(),
1022        unknown: run_record.unknown.clone(),
1023        dropped: run_record.dropped.clone(),
1024        run_record,
1025    })
1026}
1027
1028fn refused(reason: impl Into<String>) -> Error {
1029    Error::InvalidSurface {
1030        surface: "evidence".into(),
1031        reason: reason.into(),
1032    }
1033}
1034
1035fn absence(producer: EvidenceProducer, reason: &str) -> EvidenceAbsent {
1036    EvidenceAbsent {
1037        producer,
1038        reason: reason.into(),
1039    }
1040}
1041
1042/// The first twelve characters of an id, or the whole of a shorter one.
1043fn short12(id: &str) -> &str {
1044    id.get(..12).unwrap_or(id)
1045}
1046
1047/// The digest of the bytes the run executed: the commit's blob when the run measured a
1048/// clean tree, else the working copy, else `sha256:unreadable`.
1049fn digest_ran(root: &Path, commit: &str, source: &str, tree: TreeState) -> String {
1050    if tree == TreeState::Clean {
1051        let blob = crate::git::read_only(root)
1052            .args(["cat-file", "blob", &format!("{commit}:{source}")])
1053            .output()
1054            .ok()
1055            .filter(|out| out.status.success());
1056        if let Some(out) = blob {
1057            return digest_of(&out.stdout);
1058        }
1059    }
1060    std::fs::read(root.join(source))
1061        .map(|b| digest_of(&b))
1062        .unwrap_or_else(|_| "sha256:unreadable".into())
1063}
1064
1065fn write_run_record(out: &Path, run_record: &EvidenceRunRecord) -> std::io::Result<()> {
1066    // a bare file name's parent is the empty path, which create_dir_all accepts as is
1067    out.parent().map_or(Ok(()), std::fs::create_dir_all)?;
1068    // plain data with string keys: serialising it cannot fail, so there is no error to carry
1069    let mut text = serde_json::to_string_pretty(run_record).unwrap_or_default();
1070    text.push('\n');
1071    std::fs::write(out, text)
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    fn git(dir: &Path, args: &[&str]) {
1079        let out = std::process::Command::new("git")
1080            .arg("-C")
1081            .arg(dir)
1082            .args(args)
1083            .output()
1084            .unwrap();
1085        assert!(out.status.success(), "git {args:?}");
1086    }
1087
1088    /// The measurement a suite run leaves at `root` now, naming its report.
1089    fn stamp_of(root: &Path, producer: EvidenceProducer, report: &Path) -> EvidenceProvenance {
1090        let req = super::super::provenance::StampRequest {
1091            producer: Some(producer),
1092            report: Some(report.to_path_buf()),
1093            ..Default::default()
1094        };
1095        super::super::provenance::stamp(root, &req).unwrap()
1096    }
1097
1098    /// A suite recording from `origin` that carries the measurement its run just took.
1099    fn measured(root: &Path, origin: Origin, tsv: &Path) -> RecordRequest {
1100        let mut req = RecordRequest {
1101            suite: Some(tsv.to_path_buf()),
1102            ..RecordRequest::new(origin)
1103        };
1104        req.provenance.insert(
1105            EvidenceProducer::Suite,
1106            stamp_of(root, EvidenceProducer::Suite, tsv),
1107        );
1108        req
1109    }
1110
1111    fn repo() -> tempfile::TempDir {
1112        let d = tempfile::tempdir().unwrap();
1113        git(d.path(), &["init", "-q"]);
1114        git(d.path(), &["config", "user.email", "t@example.com"]);
1115        git(d.path(), &["config", "user.name", "t"]);
1116        std::fs::create_dir_all(d.path().join("test/cases")).unwrap();
1117        std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo scope\n").unwrap();
1118        std::fs::write(d.path().join("test/cases/08_other.sh"), "echo other\n").unwrap();
1119        git(d.path(), &["add", "-A"]);
1120        git(d.path(), &["commit", "-qm", "init"]);
1121        d
1122    }
1123
1124    #[test]
1125    fn a_suite_report_becomes_executions_with_provenance() {
1126        let d = repo();
1127        // outside the repository: a report written inside it is an untracked file, and the
1128        // recorded `working_tree` would then say `dirty` about the report rather than about
1129        // anything the run measured
1130        let reports = tempfile::tempdir().unwrap();
1131        let tsv = reports.path().join("run.tsv");
1132        std::fs::write(
1133            &tsv,
1134            "07_scope\tok\t12\tparallel\n08_other\tFAIL\t3\texclusive\n",
1135        )
1136        .unwrap();
1137
1138        let got = record(d.path(), &measured(d.path(), Origin::Ci, &tsv)).unwrap();
1139        assert_eq!(got.recorded, 2);
1140        assert_eq!(got.passed, 1);
1141        assert_eq!(got.commit.len(), 40);
1142        assert_eq!(got.working_tree, "clean");
1143        assert!(got.unknown.is_empty());
1144
1145        let l = Ledger::load(d.path()).unwrap();
1146        let e = l.latest("suite:07_scope").unwrap();
1147        assert_eq!(e.outcome, Outcome::Pass);
1148        assert_eq!(e.seconds, 12);
1149        assert_eq!(e.origin, Origin::Ci);
1150        assert_eq!(e.command, "bash test/run.sh 07_scope");
1151        assert_eq!(e.source, "test/cases/07_scope.sh");
1152        assert_eq!(e.commit, got.commit);
1153        // the digest is over the test's own source, so a later edit is detectable
1154        assert_eq!(e.digest, digest_of(b"echo scope\n"));
1155        assert_eq!(e.digest_matches(d.path()), Some(true));
1156
1157        std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo changed\n").unwrap();
1158        assert_eq!(
1159            l.latest("suite:07_scope").unwrap().digest_matches(d.path()),
1160            Some(false),
1161            "an edited test must not still match the digest that was recorded"
1162        );
1163    }
1164
1165    /// The ledger is not part of what a run measures: a stamp taken when the only pending
1166    /// change is the previous recording's own row is `clean`. Any other pending change is
1167    /// `dirty`, because the commit the run is joined to is then not what ran.
1168    #[test]
1169    fn the_ledgers_own_row_does_not_make_the_tree_dirty() {
1170        let d = repo();
1171        let reports = tempfile::tempdir().unwrap();
1172        let tsv = reports.path().join("run.tsv");
1173        std::fs::write(&tsv, "07_scope\tok\t1\tparallel\n").unwrap();
1174
1175        // stamp, record: the ledger now has a pending row; stamp again, and it is still clean
1176        let first = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1177        assert_eq!(first.working_tree, "clean");
1178        let second = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1179        assert_eq!(
1180            second.working_tree, "clean",
1181            "the previous recording's own row made the next run read as measured on a dirty tree"
1182        );
1183        let row = |d: &Path| {
1184            Ledger::load(d)
1185                .unwrap()
1186                .latest("suite:07_scope")
1187                .unwrap()
1188                .clone()
1189        };
1190        assert_eq!(row(d.path()).working_tree, "clean");
1191
1192        // an edited unrelated file makes the stamp dirty, and the recorded row says so
1193        std::fs::write(d.path().join("test/cases/08_other.sh"), "echo edited\n").unwrap();
1194        let third = record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
1195        assert_eq!(third.working_tree, "dirty");
1196        assert_eq!(row(d.path()).working_tree, "dirty");
1197    }
1198
1199    /// A report naming a case this repository does not have is named, not recorded. An
1200    /// execution of a test that does not exist would be evidence for nothing, and silently
1201    /// dropping it would hide a runner and a matrix that have diverged.
1202    #[test]
1203    fn a_result_for_a_test_that_does_not_exist_is_reported_not_recorded() {
1204        let d = repo();
1205        let tsv = d.path().join("run.tsv");
1206        std::fs::write(
1207            &tsv,
1208            "07_scope\tok\t1\tparallel\n99_ghost\tok\t1\tparallel\n",
1209        )
1210        .unwrap();
1211        let got = record(
1212            d.path(),
1213            &RecordRequest {
1214                suite: Some(tsv),
1215                crate_output: None,
1216                ..RecordRequest::new(Origin::Local)
1217            },
1218        )
1219        .unwrap();
1220        assert_eq!(got.recorded, 1);
1221        assert_eq!(got.unknown, vec!["suite:99_ghost"]);
1222        assert!(Ledger::load(d.path())
1223            .unwrap()
1224            .latest("suite:99_ghost")
1225            .is_none());
1226    }
1227
1228    /// Recording twice replaces, and a second partial run does not erase the first run's
1229    /// evidence for tests it did not include.
1230    #[test]
1231    fn a_later_partial_run_updates_only_what_it_ran() {
1232        let d = repo();
1233        let all = d.path().join("all.tsv");
1234        std::fs::write(
1235            &all,
1236            "07_scope\tok\t1\tparallel\n08_other\tok\t2\tparallel\n",
1237        )
1238        .unwrap();
1239        record(
1240            d.path(),
1241            &RecordRequest {
1242                suite: Some(all),
1243                crate_output: None,
1244                ..RecordRequest::new(Origin::Local)
1245            },
1246        )
1247        .unwrap();
1248
1249        let one = d.path().join("one.tsv");
1250        std::fs::write(&one, "07_scope\tFAIL\t9\tserial\n").unwrap();
1251        record(
1252            d.path(),
1253            &RecordRequest {
1254                suite: Some(one),
1255                crate_output: None,
1256                ..RecordRequest::new(Origin::Local)
1257            },
1258        )
1259        .unwrap();
1260
1261        let l = Ledger::load(d.path()).unwrap();
1262        assert_eq!(l.executions.len(), 2);
1263        assert_eq!(l.latest("suite:07_scope").unwrap().outcome, Outcome::Fail);
1264        assert_eq!(l.latest("suite:07_scope").unwrap().seconds, 9);
1265        assert_eq!(
1266            l.latest("suite:08_other").unwrap().outcome,
1267            Outcome::Pass,
1268            "the second run erased evidence it never measured"
1269        );
1270    }
1271
1272    /// A malformed report is refused. Skipping the bad line would under-record a run and
1273    /// leave a claim reporting `not run` for a test that ran.
1274    #[test]
1275    fn a_malformed_report_is_refused_rather_than_partly_read() {
1276        let d = repo();
1277        let tsv = d.path().join("run.tsv");
1278        std::fs::write(&tsv, "07_scope\tok\t12\n").unwrap();
1279        let err = record(
1280            d.path(),
1281            &RecordRequest {
1282                suite: Some(tsv),
1283                crate_output: None,
1284                ..RecordRequest::new(Origin::Local)
1285            },
1286        )
1287        .unwrap_err()
1288        .to_string();
1289        assert!(err.contains("field(s)"), "{err}");
1290        assert!(
1291            !Ledger::present(d.path()),
1292            "a refused recording wrote a ledger"
1293        );
1294    }
1295
1296    /// A report whose every result names a test this repository does not have records
1297    /// nothing, and must leave nothing behind. A ledger holding no executions is a file
1298    /// that says evidence was recorded where none was, and the malformed-report path
1299    /// already refuses before any write — the two must not disagree.
1300    #[test]
1301    fn a_recording_that_recorded_nothing_writes_no_ledger() {
1302        let d = repo();
1303        let reports = tempfile::tempdir().unwrap();
1304        let tsv = reports.path().join("run.tsv");
1305        std::fs::write(
1306            &tsv,
1307            "99_ghost\tok\t1\tparallel\n98_gone\tok\t1\tparallel\n",
1308        )
1309        .unwrap();
1310        let got = record(
1311            d.path(),
1312            &RecordRequest {
1313                suite: Some(tsv),
1314                crate_output: None,
1315                ..RecordRequest::new(Origin::Local)
1316            },
1317        )
1318        .unwrap();
1319        assert_eq!(got.recorded, 0);
1320        assert_eq!(got.unknown, vec!["suite:98_gone", "suite:99_ghost"]);
1321        assert!(
1322            !Ledger::present(d.path()),
1323            "a recording that recorded nothing created a ledger"
1324        );
1325    }
1326
1327    #[test]
1328    fn recording_nothing_is_refused() {
1329        let d = repo();
1330        assert!(record(
1331            d.path(),
1332            &RecordRequest {
1333                suite: None,
1334                crate_output: None,
1335                ..RecordRequest::new(Origin::Local)
1336            }
1337        )
1338        .is_err());
1339    }
1340
1341    /// A tree that is not a git work tree cannot produce evidence: there is no commit to
1342    /// stamp, and an execution with no commit is the anonymous green the whole subsystem
1343    /// exists to refuse.
1344    #[test]
1345    fn a_run_with_no_commit_to_name_is_refused() {
1346        let d = tempfile::tempdir().unwrap();
1347        std::fs::create_dir_all(d.path().join("test/cases")).unwrap();
1348        std::fs::write(d.path().join("test/cases/07_scope.sh"), "x").unwrap();
1349        let tsv = d.path().join("run.tsv");
1350        std::fs::write(&tsv, "07_scope\tok\t1\tparallel\n").unwrap();
1351        let err = record(
1352            d.path(),
1353            &RecordRequest {
1354                suite: Some(tsv),
1355                crate_output: None,
1356                ..RecordRequest::new(Origin::Local)
1357            },
1358        )
1359        .unwrap_err()
1360        .to_string();
1361        assert!(err.contains("provenance"), "{err}");
1362    }
1363
1364    #[test]
1365    fn the_timestamp_is_rfc3339_utc() {
1366        let t = now_rfc3339();
1367        assert_eq!(t.len(), 20, "{t}");
1368        assert!(t.ends_with('Z'), "{t}");
1369        assert_eq!(&t[4..5], "-");
1370        assert_eq!(&t[10..11], "T");
1371        // the civil-date arithmetic must land in this decade, not in 1970 or 33658
1372        let year: i32 = t[..4].parse().unwrap();
1373        assert!((2020..2100).contains(&year), "{t}");
1374    }
1375
1376    /// A `test result:` line with no `Running` line before it belongs to no binary and is
1377    /// dropped rather than attributed to the previous one.
1378    #[test]
1379    fn an_unattributed_crate_result_is_not_credited_to_the_last_binary() {
1380        let got = parse_crate_binaries(
1381            "test result: ok. 1 passed; 0 failed\n\
1382                Running tests/why.rs (target/debug/deps/why-1)\n\
1383             test result: ok. 2 passed; 0 failed; finished in 1.60s\n\
1384             test result: FAILED. 0 passed; 1 failed\n",
1385        );
1386        assert_eq!(got.len(), 1);
1387        assert_eq!(got[0].0, "why");
1388        assert_eq!(got[0].2, 2, "1.60s rounds to 2");
1389    }
1390
1391    /// A unit-test binary of the crate itself (`Running unittests src/lib.rs`) is not an
1392    /// integration test a claim can name, and must not be recorded as one.
1393    #[test]
1394    fn the_crates_own_unit_test_binary_is_not_an_integration_test() {
1395        let got = parse_crate_binaries(
1396            "   Running unittests src/lib.rs (target/debug/deps/majordomus_cli-9)\n\
1397             test result: ok. 400 passed; 0 failed\n",
1398        );
1399        assert!(got.is_empty(), "{got:?}");
1400    }
1401
1402    /// The one binary of a crate output, read: a helper for the tests below, which each
1403    /// state one row of the rule.
1404    fn only(text: &str) -> CrateBinary {
1405        let read = read_crate_output(text);
1406        assert_eq!(read.binaries.len(), 1, "{read:?}");
1407        read.binaries.into_iter().next().unwrap()
1408    }
1409
1410    /// `ok` over a binary that ran nothing is not a pass: every test ignored, or no test at
1411    /// all, is a skip. A pass would prove a claim with a run that measured nothing.
1412    #[test]
1413    fn a_binary_that_ran_no_test_is_a_skip() {
1414        let all_ignored = only(
1415            "     Running tests/beta.rs (target/debug/deps/beta-1)\n\
1416             test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; \
1417             finished in 0.00s\n",
1418        );
1419        assert_eq!(all_ignored.outcome, Outcome::Skip);
1420        assert_eq!((all_ignored.passed, all_ignored.ignored), (0, 2));
1421
1422        let empty = only(
1423            "     Running tests/empty.rs (target/debug/deps/empty-1)\n\
1424             test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1425             finished in 0.00s\n",
1426        );
1427        assert_eq!(empty.outcome, Outcome::Skip);
1428    }
1429
1430    /// A run that a name filter narrowed to a subset ran some of the binary's tests and not
1431    /// the rest: it is not the binary's proof, so it is a skip, however many passed.
1432    #[test]
1433    fn a_filtered_subset_is_a_skip() {
1434        let subset = only(
1435            "     Running tests/gamma.rs (target/debug/deps/gamma-1)\n\
1436             test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; \
1437             finished in 0.00s\n",
1438        );
1439        assert_eq!(subset.outcome, Outcome::Skip);
1440        assert_eq!((subset.passed, subset.filtered_out), (1, 4));
1441
1442        let whole = only(
1443            "     Running tests/gamma.rs (target/debug/deps/gamma-1)\n\
1444             test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1445             finished in 0.00s\n",
1446        );
1447        assert_eq!(whole.outcome, Outcome::Pass, "the same binary, run whole");
1448    }
1449
1450    /// A failed count is a failure whatever word the line starts with, and `FAILED` is a
1451    /// failure whatever the counts say.
1452    #[test]
1453    fn a_failed_count_is_a_failure_whatever_the_word() {
1454        for line in [
1455            "test result: ok. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out",
1456            "test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out",
1457            "test result: FAILED. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out",
1458            "test result: interrupted. 0 passed; 1 failed",
1459        ] {
1460            let b = only(&format!(
1461                "     Running tests/delta.rs (target/debug/deps/delta-1)\n{line}\n"
1462            ));
1463            assert_eq!(b.outcome, Outcome::Fail, "{line}");
1464        }
1465    }
1466
1467    /// The lines cargo and the harness print between a `Running` line and its result, the
1468    /// per-test lines among them, close nothing: the result that follows is still the
1469    /// binary's. A part of the result line that is not a count is passed over, and the counts
1470    /// after it are still read.
1471    #[test]
1472    fn the_lines_between_a_binary_and_its_result_are_read_past() {
1473        let b = only(
1474            "     Running tests/alpha.rs (target/debug/deps/alpha-1)\n\
1475             \n\
1476             running 3 tests\n\
1477             test alpha_holds ... ok\n\
1478             test alpha_is_fast ... ok\n\
1479             test alpha_later ... ignored\n\
1480             \n\
1481             test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; \
1482             finished in 0.40s\n\
1483             \n",
1484        );
1485        assert_eq!((b.name.as_str(), b.outcome), ("alpha", Outcome::Pass));
1486        assert_eq!((b.passed, b.ignored), (2, 1));
1487
1488        let noted = only(
1489            "     Running tests/alpha.rs (target/debug/deps/alpha-1)\n\
1490             test result: ok. a note; 3 passed; 0 failed; 0 ignored; 0 measured; \
1491             0 filtered out; finished in 1.40s\n",
1492        );
1493        assert_eq!(noted.outcome, Outcome::Pass, "the counts after the note");
1494        assert_eq!((noted.passed, noted.seconds), (3, 1));
1495    }
1496
1497    /// A crate binary the output names and this repository does not have is named, not
1498    /// recorded, exactly as a suite case would be: its execution would prove nothing here.
1499    #[test]
1500    fn a_crate_binary_this_repository_does_not_have_is_named_not_recorded() {
1501        let d = repo();
1502        let reports = tempfile::tempdir().unwrap();
1503        let log = reports.path().join("crate.log");
1504        std::fs::write(
1505            &log,
1506            "     Running tests/ghost.rs (target/debug/deps/ghost-1)\n\
1507             test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n",
1508        )
1509        .unwrap();
1510        let got = record(
1511            d.path(),
1512            &RecordRequest {
1513                suite: None,
1514                crate_output: Some(log),
1515                ..RecordRequest::new(Origin::Local)
1516            },
1517        )
1518        .unwrap();
1519        assert_eq!(got.recorded, 0);
1520        assert_eq!(got.unknown, ["crate:ghost"]);
1521        assert!(
1522            !d.path().join(crate::evidence::LEDGER_PATH).exists(),
1523            "a recording of nothing this repository has writes no ledger"
1524        );
1525    }
1526
1527    /// A result line that says `ok` and no count says nothing about what ran, and a word
1528    /// nobody knows is not a verdict: both are errors, never a pass.
1529    #[test]
1530    fn a_result_line_without_counts_is_an_error() {
1531        for line in [
1532            "test result: ok.",
1533            "test result: ok",
1534            "test result: ok. finished in 0.10s",
1535            "test result: okay. 3 passed; 0 failed",
1536            "test result: . 3 passed",
1537        ] {
1538            let b = only(&format!(
1539                "     Running tests/alpha.rs (target/debug/deps/alpha-1)\n{line}\n"
1540            ));
1541            assert_eq!(b.outcome, Outcome::Error, "{line}");
1542        }
1543    }
1544
1545    /// A binary that started and never printed a result line, before the next binary, before
1546    /// the doctests or at the end of the output, is an error: the harness could not run it.
1547    #[test]
1548    fn a_binary_that_printed_no_result_is_an_error() {
1549        let read = read_crate_output(
1550            "     Running tests/eps.rs (target/debug/deps/eps-1)\n\
1551                  Running tests/zeta.rs (target/debug/deps/zeta-1)\n\
1552             test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; \
1553             finished in 0.00s\n\
1554                  Running tests/eta.rs (target/debug/deps/eta-1)\n\
1555                Doc-tests majordomus_cli\n\
1556                  Running tests/theta.rs (target/debug/deps/theta-1)\n",
1557        );
1558        let got: Vec<(&str, Outcome, u64)> = read
1559            .binaries
1560            .iter()
1561            .map(|b| (b.name.as_str(), b.outcome, b.seconds))
1562            .collect();
1563        assert_eq!(
1564            got,
1565            [
1566                ("eps", Outcome::Error, 0),
1567                ("zeta", Outcome::Pass, 0),
1568                ("eta", Outcome::Error, 0),
1569                ("theta", Outcome::Error, 0),
1570            ]
1571        );
1572        // the doctests got no result line either, and are listed as they are
1573        assert_eq!(read.dropped.len(), 1, "{:?}", read.dropped);
1574        assert_eq!(read.dropped[0].what, "doc-tests majordomus_cli");
1575    }
1576
1577    /// Colour codes are stripped before a line is matched: a coloured run reads exactly as
1578    /// the plain one. Each escape form is stripped on its own as well.
1579    #[test]
1580    fn colour_codes_are_stripped_before_matching() {
1581        let e = '\u{1b}';
1582        let coloured = format!(
1583            "{e}[1m{e}[92m     Running{e}[0m tests/alpha.rs (target/debug/deps/alpha-1)\n\
1584             test result: {e}[32mok{e}(B{e}[m. 3 passed; 0 failed; 0 ignored; 0 measured; \
1585             0 filtered out; finished in 1.40s\n\
1586             {e}[1m{e}[92m     Running{e}[0m tests/delta.rs (target/debug/deps/delta-1)\n\
1587             test result: {e}[31mFAILED{e}[0m. 2 passed; 1 failed; 0 ignored; 0 measured; \
1588             0 filtered out; finished in 0.20s\n\
1589             {e}[1m{e}[92m   Doc-tests{e}[0m majordomus_cli\n\
1590             test result: ok. 5 passed; 0 failed\n"
1591        );
1592        let plain = strip_ansi(&coloured);
1593        assert!(!plain.contains(e), "{plain:?}");
1594        assert_eq!(read_crate_output(&coloured), read_crate_output(&plain));
1595        let read = read_crate_output(&coloured);
1596        let got: Vec<(&str, Outcome, u64)> = read
1597            .binaries
1598            .iter()
1599            .map(|b| (b.name.as_str(), b.outcome, b.seconds))
1600            .collect();
1601        assert_eq!(
1602            got,
1603            [("alpha", Outcome::Pass, 1), ("delta", Outcome::Fail, 0)]
1604        );
1605        assert_eq!(read.dropped[0].what, "doc-tests majordomus_cli");
1606
1607        // each form alone: CSI up to its final byte, OSC up to BEL or ESC \, intermediate
1608        // bytes up to their final byte, and a pair
1609        assert_eq!(strip_ansi(&format!("a{e}[1;32mb{e}[0mc")), "abc");
1610        assert_eq!(
1611            strip_ansi(&format!("ok{e}(B{e}[m.")),
1612            "ok.",
1613            "the harness's own reset on an xterm, whose `B` is part of the escape"
1614        );
1615        assert_eq!(strip_ansi(&format!("a{e}#8b{e})0c{e} Fd")), "abcd");
1616        assert_eq!(
1617            strip_ansi(&format!("a{e}$(Bb")),
1618            "ab",
1619            "every intermediate byte, then the final one"
1620        );
1621        assert_eq!(strip_ansi(&format!("a{e}[?25lb")), "ab");
1622        assert_eq!(strip_ansi(&format!("a{e}]0;title\u{7}b")), "ab");
1623        assert_eq!(strip_ansi(&format!("a{e}]8;;http://x{e}\\b")), "ab");
1624        assert_eq!(strip_ansi(&format!("a{e}7b{e}8c")), "abc");
1625        assert_eq!(
1626            strip_ansi(&format!("a{e}")),
1627            "a",
1628            "a trailing ESC is dropped"
1629        );
1630        assert_eq!(strip_ansi("plain [brackets] stay"), "plain [brackets] stay");
1631    }
1632
1633    /// The crate's own unit-test binary and its doctests ran, and no claim can name one of
1634    /// their tests: they are listed as dropped, with the report and the reason, in the
1635    /// order the output held them, and the recording carries the same list.
1636    #[test]
1637    fn unit_and_doc_tests_are_listed_as_dropped() {
1638        let d = repo();
1639        std::fs::create_dir_all(d.path().join("apps/majordomus-cli/tests")).unwrap();
1640        std::fs::write(
1641            d.path().join("apps/majordomus-cli/tests/why.rs"),
1642            "// why\n",
1643        )
1644        .unwrap();
1645        git(d.path(), &["add", "-A"]);
1646        git(d.path(), &["commit", "-qm", "why"]);
1647        let out = "     Running unittests src/lib.rs (target/debug/deps/majordomus_cli-1)\n\
1648                   test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1649                        Running tests/why.rs (target/debug/deps/why-1)\n\
1650                   test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1651                        Running benches/speed.rs (target/debug/deps/speed-1)\n\
1652                   test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1653                      Doc-tests majordomus_cli\n\
1654                   test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
1655        let read = read_crate_output(out);
1656        assert_eq!(read.binaries.len(), 1);
1657        assert_eq!(read.binaries[0].name, "why");
1658        let listed: Vec<(EvidenceProducer, &str, &str)> = read
1659            .dropped
1660            .iter()
1661            .map(|d| (d.producer, d.what.as_str(), d.reason.as_str()))
1662            .collect();
1663        assert_eq!(
1664            listed,
1665            [
1666                (EvidenceProducer::Crate, "unittests src/lib.rs", UNIT_TESTS),
1667                (
1668                    EvidenceProducer::Crate,
1669                    "benches/speed.rs",
1670                    NOT_AN_INTEGRATION_BINARY
1671                ),
1672                (
1673                    EvidenceProducer::Crate,
1674                    "doc-tests majordomus_cli",
1675                    DOC_TESTS
1676                ),
1677            ]
1678        );
1679        for entry in &read.dropped {
1680            assert!(
1681                !entry.reason.chars().any(|c| c.is_ascii_digit()),
1682                "a reason carries no counts: {}",
1683                entry.reason
1684            );
1685        }
1686
1687        let reports = tempfile::tempdir().unwrap();
1688        let log = reports.path().join("crate.log");
1689        std::fs::write(&log, out).unwrap();
1690        let got = record(
1691            d.path(),
1692            &RecordRequest {
1693                suite: None,
1694                crate_output: Some(log),
1695                ..RecordRequest::new(Origin::Local)
1696            },
1697        )
1698        .unwrap();
1699        assert_eq!(got.recorded, 1);
1700        assert_eq!(
1701            got.dropped, read.dropped,
1702            "the recording carries the same list"
1703        );
1704        let ledger = Ledger::load(d.path()).unwrap();
1705        assert_eq!(
1706            ledger.executions.len(),
1707            1,
1708            "nothing dropped reached the ledger"
1709        );
1710        assert_eq!(ledger.executions[0].test, "crate:why");
1711    }
1712
1713    /// A result line that no `Running` line named belongs to no binary: it is listed, and
1714    /// never credited to the binary before it or after it.
1715    #[test]
1716    fn a_result_line_no_running_line_named_is_dropped() {
1717        let read = read_crate_output(
1718            "test result: ok. 1 passed; 0 failed\n\
1719                  Running tests/why.rs (target/debug/deps/why-1)\n\
1720             test result: ok. 2 passed; 0 failed; finished in 1.60s\n\
1721             test result: FAILED. 0 passed; 1 failed\n",
1722        );
1723        assert_eq!(read.binaries.len(), 1);
1724        assert_eq!(
1725            read.binaries[0].outcome,
1726            Outcome::Pass,
1727            "not the stray FAILED"
1728        );
1729        assert_eq!(read.dropped.len(), 2);
1730        for entry in &read.dropped {
1731            assert_eq!(
1732                (entry.producer, entry.what.as_str(), entry.reason.as_str()),
1733                (EvidenceProducer::Crate, "a result line", UNATTRIBUTED)
1734            );
1735        }
1736    }
1737
1738    fn report(dir: &tempfile::TempDir, name: &str, rows: &str) -> std::path::PathBuf {
1739        let p = dir.path().join(name);
1740        std::fs::write(&p, rows).unwrap();
1741        p
1742    }
1743
1744    fn refusal(root: &Path, req: &RecordRequest) -> String {
1745        record(root, req).unwrap_err().to_string()
1746    }
1747
1748    fn ledger_bytes(root: &Path) -> Option<Vec<u8>> {
1749        std::fs::read(root.join(LEDGER_PATH)).ok()
1750    }
1751
1752    #[test]
1753    fn a_report_is_stamped_with_its_runs_measurement_not_the_recorders() {
1754        let d = repo();
1755        let reports = tempfile::tempdir().unwrap();
1756        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1757        // a dirty stamp, then a clean recorder
1758        std::fs::write(d.path().join("pending.txt"), "x").unwrap();
1759        let dirty = measured(d.path(), Origin::Local, &tsv);
1760        std::fs::remove_file(d.path().join("pending.txt")).unwrap();
1761        assert_eq!(record(d.path(), &dirty).unwrap().working_tree, "dirty");
1762        // a clean stamp, then a dirty recorder
1763        let clean = measured(d.path(), Origin::Local, &tsv);
1764        std::fs::write(d.path().join("pending.txt"), "x").unwrap();
1765        assert_eq!(record(d.path(), &clean).unwrap().working_tree, "clean");
1766    }
1767
1768    #[test]
1769    fn a_report_without_a_measurement_records_an_unknown_tree() {
1770        let d = repo();
1771        let reports = tempfile::tempdir().unwrap();
1772        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1773        for origin in [Origin::Local, Origin::Release] {
1774            let got = record(
1775                d.path(),
1776                &RecordRequest {
1777                    suite: Some(tsv.clone()),
1778                    ..RecordRequest::new(origin)
1779                },
1780            );
1781            let got = got.unwrap();
1782            assert_eq!(got.working_tree, "unknown");
1783            assert!(got.run_record.provenance.is_empty());
1784            let row = Ledger::load(d.path())
1785                .unwrap()
1786                .latest("suite:07_scope")
1787                .unwrap()
1788                .clone();
1789            assert_eq!(row.working_tree, "unknown");
1790        }
1791    }
1792
1793    #[test]
1794    fn a_local_measurement_that_names_no_report_is_refused() {
1795        let d = repo();
1796        let reports = tempfile::tempdir().unwrap();
1797        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1798        for origin in [Origin::Local, Origin::Release] {
1799            let mut req = measured(d.path(), origin, &tsv);
1800            req.provenance
1801                .get_mut(&EvidenceProducer::Suite)
1802                .unwrap()
1803                .report = None;
1804            let err = refusal(d.path(), &req);
1805            assert!(err.contains("stamp with --report"), "{err}");
1806        }
1807        assert_eq!(ledger_bytes(d.path()), None);
1808    }
1809
1810    #[test]
1811    fn a_ci_measurement_without_a_report_is_accepted() {
1812        let d = repo();
1813        let reports = tempfile::tempdir().unwrap();
1814        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1815        let mut req = measured(d.path(), Origin::Ci, &tsv);
1816        req.provenance
1817            .get_mut(&EvidenceProducer::Suite)
1818            .unwrap()
1819            .report = None;
1820        assert_eq!(record(d.path(), &req).unwrap().working_tree, "clean");
1821    }
1822
1823    #[test]
1824    fn a_ci_report_without_a_measurement_is_refused_before_anything_is_written() {
1825        let d = repo();
1826        let reports = tempfile::tempdir().unwrap();
1827        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1828        let log = report(&reports, "crate.log", "");
1829        for req in [
1830            RecordRequest {
1831                suite: Some(tsv.clone()),
1832                ..RecordRequest::new(Origin::Ci)
1833            },
1834            RecordRequest {
1835                crate_output: Some(log),
1836                ..measured(d.path(), Origin::Ci, &tsv)
1837            },
1838        ] {
1839            let err = refusal(d.path(), &req);
1840            assert!(err.contains("give --provenance"), "{err}");
1841        }
1842        assert_eq!(ledger_bytes(d.path()), None);
1843    }
1844
1845    #[test]
1846    fn a_measurement_of_another_commit_is_refused() {
1847        let d = repo();
1848        let reports = tempfile::tempdir().unwrap();
1849        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1850        let old = measured(d.path(), Origin::Local, &tsv);
1851        std::fs::write(d.path().join("next.txt"), "x").unwrap();
1852        git(d.path(), &["add", "-A"]);
1853        git(d.path(), &["commit", "-qm", "next"]);
1854        let err = refusal(d.path(), &old);
1855        let then = &old.provenance[&EvidenceProducer::Suite].commit[..12];
1856        assert!(
1857            err.contains(then) && err.contains("in a checkout of the commit it ran"),
1858            "{err}"
1859        );
1860        let mut short = measured(d.path(), Origin::Local, &tsv);
1861        let m = short.provenance.get_mut(&EvidenceProducer::Suite).unwrap();
1862        m.commit.truncate(12);
1863        assert!(refusal(d.path(), &short).contains("was measured at"));
1864    }
1865
1866    #[test]
1867    fn a_measurement_of_another_report_is_refused() {
1868        let d = repo();
1869        let reports = tempfile::tempdir().unwrap();
1870        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1871        let other = report(&reports, "other.tsv", "07_scope\tok\t2\tparallel\n");
1872        let mut req = measured(d.path(), Origin::Local, &other);
1873        req.suite = Some(tsv);
1874        let err = refusal(d.path(), &req);
1875        assert!(err.contains("describes another report"), "{err}");
1876        assert_eq!(ledger_bytes(d.path()), None);
1877    }
1878
1879    #[test]
1880    fn a_measurement_for_a_report_not_given_is_refused() {
1881        let d = repo();
1882        let reports = tempfile::tempdir().unwrap();
1883        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1884        let mut req = measured(d.path(), Origin::Local, &tsv);
1885        let m = req.provenance[&EvidenceProducer::Suite].clone();
1886        for p in [EvidenceProducer::Crate, EvidenceProducer::Coverage] {
1887            let mut m = m.clone();
1888            m.producer = Some(p);
1889            req.provenance.insert(p, m);
1890            let err = refusal(d.path(), &req);
1891            assert!(
1892                err.contains(&format!("and no {} report", p.as_str())),
1893                "{err}"
1894            );
1895            req.provenance.remove(&p);
1896        }
1897    }
1898
1899    #[test]
1900    fn a_measurement_whose_producer_disagrees_with_its_key_is_refused() {
1901        let d = repo();
1902        let reports = tempfile::tempdir().unwrap();
1903        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1904        let mut req = measured(d.path(), Origin::Local, &tsv);
1905        req.provenance
1906            .get_mut(&EvidenceProducer::Suite)
1907            .unwrap()
1908            .producer = Some(EvidenceProducer::Crate);
1909        let err = refusal(d.path(), &req);
1910        assert!(
1911            err.contains("measures the crate report, not the suite one"),
1912            "{err}"
1913        );
1914    }
1915
1916    #[test]
1917    fn every_refusal_leaves_no_ledger_and_no_run_record() {
1918        let d = repo();
1919        let reports = tempfile::tempdir().unwrap();
1920        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
1921        let other = report(&reports, "other.tsv", "08_other\tok\t1\tparallel\n");
1922        let bad = report(&reports, "bad.tsv", "07_scope\tok\n");
1923        let cov = report(&reports, "cov.json", r#"{"schema": 2}"#);
1924        let out = reports.path().join("out/run.json");
1925        let with_out = |mut r: RecordRequest| {
1926            r.run_record = Some(out.clone());
1927            r
1928        };
1929        let unreported = {
1930            let mut r = measured(d.path(), Origin::Local, &tsv);
1931            r.provenance
1932                .get_mut(&EvidenceProducer::Suite)
1933                .unwrap()
1934                .report = None;
1935            r
1936        };
1937        let another = RecordRequest {
1938            suite: Some(tsv.clone()),
1939            ..measured(d.path(), Origin::Local, &other)
1940        };
1941        let foreign = {
1942            let mut r = measured(d.path(), Origin::Local, &tsv);
1943            r.provenance
1944                .get_mut(&EvidenceProducer::Suite)
1945                .unwrap()
1946                .commit = "a".repeat(40);
1947            r
1948        };
1949        let refusals = [
1950            RecordRequest::default(),
1951            RecordRequest {
1952                suite: Some(tsv.clone()),
1953                ..RecordRequest::new(Origin::Ci)
1954            },
1955            unreported,
1956            another,
1957            foreign,
1958            RecordRequest {
1959                suite: Some(bad),
1960                ..RecordRequest::default()
1961            },
1962            RecordRequest {
1963                coverage: Some(cov),
1964                ..RecordRequest::default()
1965            },
1966            RecordRequest {
1967                suite: Some(tsv.clone()),
1968                run_record: Some(reports.path().to_path_buf()),
1969                ..RecordRequest::default()
1970            },
1971            RecordRequest {
1972                suite: Some(tsv.clone()),
1973                run_record: Some(d.path().join(LEDGER_PATH)),
1974                ..RecordRequest::default()
1975            },
1976            RecordRequest {
1977                suite: Some(tsv.clone()),
1978                run_record: Some(d.path().join(LOCAL_LEDGER_PATH)),
1979                ..RecordRequest::default()
1980            },
1981            RecordRequest {
1982                suite: Some(tsv.clone()),
1983                run_record: Some("".into()),
1984                ..RecordRequest::default()
1985            },
1986        ];
1987        for (n, req) in refusals.into_iter().enumerate() {
1988            let keep = req.run_record.is_some();
1989            let req = if keep { req } else { with_out(req) };
1990            assert!(record(d.path(), &req).is_err(), "refusal {n} recorded");
1991            assert_eq!(ledger_bytes(d.path()), None, "refusal {n} wrote the ledger");
1992            assert!(!d.path().join(LOCAL_LEDGER_PATH).exists(), "refusal {n}");
1993            assert!(!out.exists(), "refusal {n} wrote a run record");
1994        }
1995    }
1996
1997    #[test]
1998    fn the_digest_is_taken_from_the_commit_when_the_run_was_clean() {
1999        let d = repo();
2000        let reports = tempfile::tempdir().unwrap();
2001        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2002        let req = measured(d.path(), Origin::Local, &tsv);
2003        std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo edited\n").unwrap();
2004        let got = record(d.path(), &req).unwrap();
2005        assert_eq!(
2006            got.run_record.executions[0].digest,
2007            digest_of(b"echo scope\n")
2008        );
2009    }
2010
2011    #[test]
2012    fn the_digest_is_taken_from_the_checkout_when_the_tree_is_not_known_clean() {
2013        let d = repo();
2014        let reports = tempfile::tempdir().unwrap();
2015        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2016        std::fs::write(d.path().join("test/cases/07_scope.sh"), "echo edited\n").unwrap();
2017        let dirty = measured(d.path(), Origin::Local, &tsv);
2018        let got = record(d.path(), &dirty).unwrap();
2019        assert_eq!(
2020            got.run_record.executions[0].digest,
2021            digest_of(b"echo edited\n")
2022        );
2023        let unmeasured = RecordRequest {
2024            suite: Some(tsv.clone()),
2025            ..RecordRequest::default()
2026        };
2027        let got = record(d.path(), &unmeasured).unwrap();
2028        assert_eq!(
2029            got.run_record.executions[0].digest,
2030            digest_of(b"echo edited\n")
2031        );
2032        // a source git cannot answer for, measured clean, is read from the checkout
2033        std::fs::write(d.path().join("test/cases/09_new.sh"), "echo new\n").unwrap();
2034        assert_eq!(
2035            digest_ran(
2036                d.path(),
2037                &got.commit,
2038                "test/cases/09_new.sh",
2039                TreeState::Clean
2040            ),
2041            digest_of(b"echo new\n")
2042        );
2043        assert_eq!(
2044            digest_ran(
2045                d.path(),
2046                &got.commit,
2047                "test/cases/none.sh",
2048                TreeState::Dirty
2049            ),
2050            "sha256:unreadable"
2051        );
2052    }
2053
2054    #[test]
2055    fn a_local_recording_changes_no_tracked_file() {
2056        let d = repo();
2057        let reports = tempfile::tempdir().unwrap();
2058        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2059        let req = RecordRequest {
2060            ledger: LedgerTarget::Local,
2061            ..measured(d.path(), Origin::Local, &tsv)
2062        };
2063        let got = record(d.path(), &req).unwrap();
2064        assert_eq!(got.run_record.ledger, LedgerTarget::Local);
2065        assert_eq!(
2066            ledger_bytes(d.path()),
2067            None,
2068            "the tracked ledger is not written"
2069        );
2070        let local = Ledger::load_from(d.path(), LedgerTarget::Local).unwrap();
2071        assert!(local.latest("suite:07_scope").is_some());
2072    }
2073
2074    #[test]
2075    fn the_run_record_written_is_the_run_record_returned() {
2076        let d = repo();
2077        let reports = tempfile::tempdir().unwrap();
2078        let tsv = report(
2079            &reports,
2080            "run.tsv",
2081            "07_scope\tok\t1\tparallel\n99_ghost\tok\t1\tparallel\n",
2082        );
2083        let out = reports.path().join("deep/run.json");
2084        let req = RecordRequest {
2085            run_record: Some(out.clone()),
2086            ..measured(d.path(), Origin::Local, &tsv)
2087        };
2088        let got = record(d.path(), &req).unwrap();
2089        let text = std::fs::read_to_string(&out).unwrap();
2090        assert!(text.ends_with("}\n"));
2091        let back: EvidenceRunRecord = serde_json::from_str(&text).unwrap();
2092        assert_eq!(back, got.run_record);
2093        assert_eq!(back.unknown, ["suite:99_ghost"]);
2094        assert_eq!(back.schema, RUN_RECORD_SCHEMA);
2095        let absent: Vec<_> = back.absent.iter().map(|a| a.producer).collect();
2096        assert_eq!(
2097            absent,
2098            [EvidenceProducer::Crate, EvidenceProducer::Coverage]
2099        );
2100    }
2101
2102    #[test]
2103    fn a_run_record_that_cannot_be_written_says_the_executions_were_recorded() {
2104        let d = repo();
2105        let reports = tempfile::tempdir().unwrap();
2106        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2107        let blocker = report(&reports, "file", "x");
2108        let req = RecordRequest {
2109            run_record: Some(blocker.join("run.json")),
2110            ..measured(d.path(), Origin::Local, &tsv)
2111        };
2112        let err = refusal(d.path(), &req);
2113        assert!(
2114            err.contains("the executions were recorded into")
2115                && err.contains("run record could not be written"),
2116            "{err}"
2117        );
2118        assert!(ledger_bytes(d.path()).is_some());
2119    }
2120
2121    #[test]
2122    fn the_outcomes_dropped_list_is_the_run_records() {
2123        let d = repo();
2124        let reports = tempfile::tempdir().unwrap();
2125        let log = report(&reports, "crate.log", "     Running unittests src/lib.rs (x)\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.1s\n");
2126        let got = record(
2127            d.path(),
2128            &RecordRequest {
2129                crate_output: Some(log),
2130                ..RecordRequest::default()
2131            },
2132        )
2133        .unwrap();
2134        assert_eq!(got.dropped.len(), 1);
2135        assert_eq!(got.dropped, got.run_record.dropped);
2136        assert_eq!(
2137            got.run_record.absent[1].reason,
2138            "the output named no integration test binary"
2139        );
2140    }
2141
2142    /// A schema-1 coverage summary as `scripts/rust-coverage --summary-json` writes it.
2143    const COVERAGE_SUMMARY: &str = r#"{"schema":1,"measurement":"scripts/rust-coverage",
2144        "test_code":"excluded",
2145        "crate":{"lines":{"covered":1,"total":2,"percent":50.0},
2146                 "functions":{"covered":1,"total":2,"percent":50.0},
2147                 "regions":{"covered":1,"total":2,"percent":50.0}},
2148        "domain":{"lines":{"covered":1,"total":2,"percent":50.0},
2149                  "functions":{"covered":1,"total":2,"percent":50.0},
2150                  "regions":{"covered":1,"total":2,"percent":50.0},
2151                  "files":["src/a.rs"],"missing":[]},
2152        "files":{}}"#;
2153
2154    /// A crate output or a coverage summary that cannot be read is refused, as a suite
2155    /// report is, before anything is written.
2156    #[test]
2157    fn an_unreadable_crate_output_or_coverage_summary_is_refused() {
2158        let d = repo();
2159        let reports = tempfile::tempdir().unwrap();
2160        let missing = reports.path().join("absent.txt");
2161        for req in [
2162            RecordRequest {
2163                crate_output: Some(missing.clone()),
2164                ..RecordRequest::new(Origin::Local)
2165            },
2166            RecordRequest {
2167                coverage: Some(missing.clone()),
2168                ..RecordRequest::new(Origin::Local)
2169            },
2170        ] {
2171            assert!(record(d.path(), &req).is_err());
2172            assert!(ledger_bytes(d.path()).is_none());
2173        }
2174    }
2175
2176    /// A coverage summary is bound to the checkout's commit in the run record; one that is
2177    /// not a schema-1 summary is refused and names the file.
2178    #[test]
2179    fn a_coverage_summary_is_bound_to_the_commit_and_a_malformed_one_is_refused() {
2180        let d = repo();
2181        let reports = tempfile::tempdir().unwrap();
2182        let good = report(&reports, "coverage.json", COVERAGE_SUMMARY);
2183        let req = RecordRequest {
2184            coverage: Some(good),
2185            ..RecordRequest::new(Origin::Local)
2186        };
2187        let got = record(d.path(), &req).unwrap();
2188        let cov = got.run_record.coverage.expect("the coverage record");
2189        assert_eq!(cov.commit.as_str(), got.commit);
2190        assert_eq!(cov.working_tree, TreeState::Unknown);
2191
2192        let bad = report(&reports, "bad.json", r#"{"schema":2}"#);
2193        let req = RecordRequest {
2194            coverage: Some(bad),
2195            ..RecordRequest::new(Origin::Local)
2196        };
2197        assert!(refusal(d.path(), &req).contains("bad.json"));
2198    }
2199
2200    /// A run record that would land on the ledger is refused however its path is spelled.
2201    #[test]
2202    fn a_run_record_spelled_as_another_path_to_the_ledger_is_refused() {
2203        let d = repo();
2204        let reports = tempfile::tempdir().unwrap();
2205        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2206        record(d.path(), &measured(d.path(), Origin::Local, &tsv)).unwrap();
2207        let ledger = d.path().join(".ai/repo/evidence/../evidence/ledger.json");
2208        let req = RecordRequest {
2209            run_record: Some(ledger),
2210            ..measured(d.path(), Origin::Local, &tsv)
2211        };
2212        assert!(
2213            refusal(d.path(), &req).contains("cannot hold a run record"),
2214            "the ledger, reached through `..`, was accepted as a run record"
2215        );
2216    }
2217
2218    /// A ledger that cannot be read, or cannot be written, fails the recording.
2219    #[test]
2220    fn a_ledger_that_cannot_be_read_or_written_fails_the_recording() {
2221        let reports = tempfile::tempdir().unwrap();
2222        let tsv = report(&reports, "run.tsv", "07_scope\tok\t1\tparallel\n");
2223
2224        let unreadable = repo();
2225        std::fs::create_dir_all(unreadable.path().join(LEDGER_PATH)).unwrap();
2226        let req = RecordRequest {
2227            suite: Some(tsv.clone()),
2228            ..RecordRequest::new(Origin::Local)
2229        };
2230        assert!(record(unreadable.path(), &req).is_err());
2231
2232        let unwritable = repo();
2233        std::fs::create_dir_all(unwritable.path().join(".ai/repo")).unwrap();
2234        std::fs::write(unwritable.path().join(".ai/repo/evidence"), "a file").unwrap();
2235        assert!(record(unwritable.path(), &req).is_err());
2236    }
2237}