majordomus_cli/evidence/ledger.rs
1//! The ledger: the latest execution of every test, as a tracked file.
2//!
3//! # Why a file, and why this one
4//!
5//! Evidence that lives only in a CI job's log is evidence nobody can query, and evidence
6//! that lives in a database is evidence a clone does not carry. The repository already has
7//! a precedent for recorded, committed, reviewable measurement — the benchmark baselines
8//! under `.ai/repo/benchmarks/`, which are JSON, per platform, written by a tool and read
9//! by a gate. This is the same kind of artifact for the same reason, and it is deliberately
10//! shaped like them rather than like something new.
11//!
12//! # What it holds, and what it refuses to hold
13//!
14//! One entry per test: the latest execution. Not a history — a run per commit for a hundred
15//! tests would grow without bound and the question it answers ("is this claim proven now?")
16//! only ever reads the newest. Not stdout, not artifacts: a captured log of every case is
17//! megabytes per run and belongs where the run happened. What is kept is the durable
18//! semantic part — outcome, duration, commit, tree state, digest, time, origin, and the
19//! command that produces it again — which is what a reader needs to decide whether to
20//! believe it and how to check.
21//!
22//! # JSON, not the layer's YAML
23//!
24//! This repository reads a deliberately small YAML subset (`docs/SCHEMAS.md`), and a file
25//! only our own reader can parse is a trap this repository has already fallen into once.
26//! The ledger is machine-written and machine-read, so it is JSON: `serde` on both sides,
27//! no subset to respect, and `jq` works on it.
28//!
29//! # The whole lifecycle
30//!
31//! Load what is there (nothing, in a repository that has recorded nothing), merge the
32//! executions a run produced, save, and read back the same value:
33//!
34//! ```
35//! use majordomus_cli::evidence::{Execution, Ledger, Origin, Outcome, Runner};
36//!
37//! fn execution(name: &str, outcome: Outcome) -> Execution {
38//! Execution {
39//! test: format!("suite:{name}"),
40//! runner: Runner::Suite,
41//! source: format!("test/cases/{name}.sh"),
42//! outcome,
43//! seconds: 1,
44//! commit: "0".repeat(40),
45//! working_tree: "clean".into(),
46//! digest: "sha256:0".into(),
47//! at: "2026-09-11T00:00:00Z".into(),
48//! origin: Origin::Local,
49//! command: format!("bash test/run.sh {name}"),
50//! run: None,
51//! }
52//! }
53//!
54//! let root = tempfile::tempdir().unwrap();
55//!
56//! // a repository that has recorded nothing has an empty ledger, and that is not an error
57//! assert!(!Ledger::present(root.path()));
58//! let mut ledger = Ledger::load(root.path()).unwrap();
59//! assert!(ledger.executions.is_empty());
60//!
61//! ledger.merge([
62//! execution("08_other", Outcome::Fail),
63//! execution("07_scope", Outcome::Pass),
64//! ]);
65//! ledger.save(root.path()).unwrap();
66//!
67//! // what came back is what went in, ordered by test id so the tracked file diffs cleanly
68//! let read = Ledger::load(root.path()).unwrap();
69//! assert_eq!(read, ledger);
70//! let ids: Vec<&str> = read.executions.iter().map(|e| e.test.as_str()).collect();
71//! assert_eq!(ids, ["suite:07_scope", "suite:08_other"]);
72//! assert_eq!(read.summary().executions, 2);
73//! assert_eq!(read.by_outcome().get("pass"), Some(&1));
74//! ```
75
76//! # Example
77//!
78//! A tree that has recorded nothing has no ledger, and says so rather than refusing to
79//! answer — a repository that could not say "nothing was recorded" would say nothing.
80//!
81//! ```
82//! use majordomus_cli::evidence::Ledger;
83//! use majordomus_cli::synthetic::SyntheticRepository;
84//! let repo = SyntheticRepository::small().unwrap();
85//! let ledger = Ledger::load(repo.root()).unwrap();
86//! assert!(!Ledger::present(repo.root()));
87//! assert!(ledger.latest("suite:07_scope").is_none());
88//! ```
89
90use std::collections::BTreeMap;
91use std::path::Path;
92
93use serde::{Deserialize, Serialize};
94
95use super::{Execution, LedgerSummary};
96use crate::error::{Error, Result};
97
98/// Where the ledger lives, repository-relative. One spelling, used by the recorder, the
99/// reader and the documentation.
100pub const LEDGER_PATH: &str = ".ai/repo/evidence/ledger.json";
101
102/// The version of the ledger's own shape. A reader that meets a version it does not know
103/// refuses rather than guessing: a misread ledger is worse than an absent one, because an
104/// absent one reports `not run` and a misread one could report a pass.
105pub const LEDGER_VERSION: u32 = 1;
106
107/// # Example
108///
109/// ```
110/// use majordomus_cli::evidence::Ledger;
111/// let l = Ledger::empty();
112/// assert_eq!(l.summary().executions, 0);
113/// assert!(l.latest("suite:07_scope").is_none());
114/// ```
115/// The latest execution of every test this repository has recorded.
116///
117/// One entry per test, never a history: the question it answers ("is this claim proven
118/// now?") only ever reads the newest, and a run per commit for a hundred tests would grow
119/// without bound. It is a value first and a file second — [`Ledger::load`] and
120/// [`Ledger::save`] move it to and from [`LEDGER_PATH`] under a repository root, and a
121/// repository with no file has an empty one rather than an error.
122///
123/// ```
124/// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
125/// # fn execution(name: &str, outcome: Outcome) -> Execution {
126/// # Execution {
127/// # test: format!("suite:{name}"), runner: Runner::Suite,
128/// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
129/// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
130/// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
131/// # command: format!("bash test/run.sh {name}"),
132/// # run: None,
133/// # }
134/// # }
135/// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_PATH, LEDGER_VERSION};
136///
137/// let mut ledger = Ledger::empty();
138/// assert_eq!(ledger.version, LEDGER_VERSION);
139/// assert_eq!(LEDGER_PATH, ".ai/repo/evidence/ledger.json");
140///
141/// ledger.merge([execution("07_scope", Outcome::Pass)]);
142/// assert_eq!(ledger.executions.len(), 1);
143/// assert_eq!(ledger.latest("suite:07_scope").unwrap().outcome, Outcome::Pass);
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Ledger {
147 /// The shape's version.
148 pub version: u32,
149 /// The executions, one per test, ordered by test id so the file diffs cleanly.
150 pub executions: Vec<Execution>,
151}
152
153impl Default for Ledger {
154 fn default() -> Self {
155 Ledger {
156 version: LEDGER_VERSION,
157 executions: Vec::new(),
158 }
159 }
160}
161
162impl Ledger {
163 /// An empty ledger — what a repository that has recorded nothing has.
164 ///
165 /// Indistinguishable from a missing file everywhere it matters: [`Ledger::load`]
166 /// answers one for the other, [`Ledger::summary`] reports `present: false` for both,
167 /// and both make every claim read `not run`, which is the honest answer in either
168 /// case. Only [`Ledger::present`], which asks the filesystem, tells them apart.
169 ///
170 /// ```
171 /// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_VERSION};
172 ///
173 /// let ledger = Ledger::empty();
174 /// assert!(ledger.executions.is_empty());
175 /// assert_eq!(ledger.version, LEDGER_VERSION, "it still declares the shape it is");
176 /// assert!(!ledger.summary().present, "nothing recorded is not a present ledger");
177 /// ```
178 pub fn empty() -> Ledger {
179 Ledger::default()
180 }
181
182 /// Read a ledger from its text, wherever the text came from: the working copy under a
183 /// repository root, or the file as a commit holds it.
184 ///
185 /// The one reading of the shape, so that a ledger read from a commit and a ledger read
186 /// from the working tree are refused for the same reasons in the same words. A version
187 /// this executable does not know is refused rather than reinterpreted.
188 ///
189 /// ```
190 /// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_VERSION};
191 ///
192 /// let ledger = Ledger::parse(r#"{"version": 1, "executions": []}"#).unwrap();
193 /// assert_eq!(ledger.version, LEDGER_VERSION);
194 /// assert!(ledger.executions.is_empty());
195 ///
196 /// let refused = Ledger::parse(r#"{"version": 99, "executions": []}"#).unwrap_err();
197 /// assert!(refused.to_string().contains("version 99"), "{refused}");
198 /// assert!(Ledger::parse("not json at all").is_err());
199 /// ```
200 pub fn parse(text: &str) -> Result<Ledger> {
201 Ledger::parse_named(text, LEDGER_PATH)
202 }
203
204 /// [`Ledger::parse`], naming `path` in a refusal: the tracked ledger and the local one
205 /// are refused for the same reasons, each in its own name.
206 fn parse_named(text: &str, path: &str) -> Result<Ledger> {
207 let ledger: Ledger = serde_json::from_str(text).map_err(|e| Error::InvalidSurface {
208 surface: "evidence".into(),
209 reason: format!("{path} is not a ledger this version can read: {e}"),
210 })?;
211 if ledger.version != LEDGER_VERSION {
212 return Err(Error::InvalidSurface {
213 surface: "evidence".into(),
214 reason: format!(
215 "{path} declares version {} and this executable reads version \
216 {LEDGER_VERSION}; a ledger read under the wrong shape could report a pass \
217 that was never recorded",
218 ledger.version
219 ),
220 });
221 }
222 Ok(ledger)
223 }
224
225 /// Read the ledger of a repository. A repository with no ledger has an empty one; that
226 /// is not an error, and the summary says which it was.
227 ///
228 /// A ledger declaring a version this executable does not know is refused rather than
229 /// reinterpreted: an absent ledger reports `not run`, and a misread one could report a
230 /// pass that was never recorded.
231 ///
232 /// ```
233 /// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_PATH};
234 ///
235 /// let root = tempfile::tempdir().unwrap();
236 /// assert_eq!(Ledger::load(root.path()).unwrap(), Ledger::empty());
237 ///
238 /// std::fs::create_dir_all(root.path().join(".ai/repo/evidence")).unwrap();
239 /// std::fs::write(
240 /// root.path().join(LEDGER_PATH),
241 /// r#"{"version": 99, "executions": []}"#,
242 /// )
243 /// .unwrap();
244 /// let refused = Ledger::load(root.path()).unwrap_err().to_string();
245 /// assert!(refused.contains("version 99"), "{refused}");
246 /// ```
247 pub fn load(root: &Path) -> Result<Ledger> {
248 Ledger::load_from(root, LedgerTarget::Repo)
249 }
250
251 /// Whether the file is there at all, as opposed to there and empty.
252 ///
253 /// ```
254 /// use majordomus_cli::evidence::Ledger;
255 ///
256 /// let root = tempfile::tempdir().unwrap();
257 /// assert!(!Ledger::present(root.path()));
258 ///
259 /// Ledger::empty().save(root.path()).unwrap();
260 /// assert!(Ledger::present(root.path()), "written and empty is still written");
261 /// ```
262 pub fn present(root: &Path) -> bool {
263 root.join(LEDGER_PATH).exists()
264 }
265
266 /// The latest recorded execution of one test, by [`super::TestId::as_string`].
267 ///
268 /// `None` is the answer for a test no run has ever recorded, which the report renders
269 /// as `not run` rather than as a failure.
270 ///
271 /// ```
272 /// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
273 /// # fn execution(name: &str, outcome: Outcome) -> Execution {
274 /// # Execution {
275 /// # test: format!("suite:{name}"), runner: Runner::Suite,
276 /// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
277 /// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
278 /// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
279 /// # command: format!("bash test/run.sh {name}"),
280 /// # run: None,
281 /// # }
282 /// # }
283 /// use majordomus_cli::evidence::Ledger;
284 ///
285 /// let mut ledger = Ledger::empty();
286 /// ledger.merge([execution("07_scope", Outcome::Fail)]);
287 ///
288 /// assert_eq!(ledger.latest("suite:07_scope").unwrap().outcome, Outcome::Fail);
289 /// assert!(ledger.latest("suite:99_ghost").is_none(), "never run, never recorded");
290 /// ```
291 pub fn latest(&self, test: &str) -> Option<&Execution> {
292 self.executions.iter().find(|e| e.test == test)
293 }
294
295 /// Merge executions into the ledger: each one replaces the entry for its test and
296 /// leaves every other alone.
297 ///
298 /// A partial run therefore updates only what it ran, which is what makes `bash
299 /// test/run.sh 84_distribution_model` worth recording. The alternative — a whole-ledger
300 /// write per run — would silently delete the evidence for every test the run did not
301 /// include, turning a one-case run into a repository that has proven one thing.
302 ///
303 /// ```
304 /// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
305 /// # fn execution(name: &str, outcome: Outcome) -> Execution {
306 /// # Execution {
307 /// # test: format!("suite:{name}"), runner: Runner::Suite,
308 /// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
309 /// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
310 /// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
311 /// # command: format!("bash test/run.sh {name}"),
312 /// # run: None,
313 /// # }
314 /// # }
315 /// use majordomus_cli::evidence::Ledger;
316 ///
317 /// let mut ledger = Ledger::empty();
318 /// ledger.merge([
319 /// execution("07_scope", Outcome::Pass),
320 /// execution("08_other", Outcome::Pass),
321 /// ]);
322 ///
323 /// // a second run of one case replaces that case and touches nothing else
324 /// assert_eq!(ledger.merge([execution("07_scope", Outcome::Fail)]), 1);
325 /// assert_eq!(ledger.executions.len(), 2, "it replaced rather than appended");
326 /// assert_eq!(ledger.latest("suite:07_scope").unwrap().outcome, Outcome::Fail);
327 /// assert_eq!(
328 /// ledger.latest("suite:08_other").unwrap().outcome,
329 /// Outcome::Pass,
330 /// "recording one test must never erase another test's evidence",
331 /// );
332 /// ```
333 pub fn merge(&mut self, executions: impl IntoIterator<Item = Execution>) -> usize {
334 let mut n = 0;
335 for e in executions {
336 match self.executions.iter_mut().find(|x| x.test == e.test) {
337 Some(slot) => *slot = e,
338 None => self.executions.push(e),
339 }
340 n += 1;
341 }
342 crate::order::canonical(&mut self.executions);
343 n
344 }
345
346 /// Write the ledger, creating its directory. Trailing newline, two-space indent: a
347 /// tracked file a person reads in a diff.
348 ///
349 /// ```
350 /// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
351 /// # fn execution(name: &str, outcome: Outcome) -> Execution {
352 /// # Execution {
353 /// # test: format!("suite:{name}"), runner: Runner::Suite,
354 /// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
355 /// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
356 /// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
357 /// # command: format!("bash test/run.sh {name}"),
358 /// # run: None,
359 /// # }
360 /// # }
361 /// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_PATH};
362 ///
363 /// let root = tempfile::tempdir().unwrap();
364 /// let mut ledger = Ledger::empty();
365 /// ledger.merge([execution("07_scope", Outcome::Pass)]);
366 /// ledger.save(root.path()).unwrap();
367 ///
368 /// let text = std::fs::read_to_string(root.path().join(LEDGER_PATH)).unwrap();
369 /// assert!(text.ends_with('\n'), "a tracked file ends in a newline");
370 /// assert!(text.contains("\n \"version\": 1"), "two-space indent: {text}");
371 /// assert_eq!(Ledger::load(root.path()).unwrap(), ledger);
372 /// ```
373 pub fn save(&self, root: &Path) -> Result<()> {
374 self.save_to(root, LedgerTarget::Repo)
375 }
376
377 /// Read the ledger `target` names. An absent one is empty, as with [`Ledger::load`], and
378 /// a bad one is refused in its own name.
379 ///
380 /// ```
381 /// use majordomus_cli::evidence::ledger::{Ledger, LedgerTarget, LOCAL_LEDGER_PATH};
382 ///
383 /// let root = tempfile::tempdir().unwrap();
384 /// assert_eq!(Ledger::load_from(root.path(), LedgerTarget::Local).unwrap(), Ledger::empty());
385 /// Ledger::empty().save_to(root.path(), LedgerTarget::Local).unwrap();
386 /// assert!(root.path().join(LOCAL_LEDGER_PATH).is_file());
387 /// assert!(!Ledger::present(root.path()), "the tracked ledger is not written");
388 /// ```
389 pub fn load_from(root: &Path, target: LedgerTarget) -> Result<Ledger> {
390 let path = root.join(target.path());
391 if !path.exists() {
392 return Ok(Ledger::empty());
393 }
394 let text = std::fs::read_to_string(&path).map_err(Error::Transport)?;
395 Ledger::parse_named(&text, target.path())
396 }
397
398 /// Write the ledger `target` names, as [`Ledger::save`] writes the tracked one.
399 ///
400 /// ```
401 /// use majordomus_cli::evidence::ledger::{Ledger, LedgerTarget, LEDGER_PATH};
402 ///
403 /// let root = tempfile::tempdir().unwrap();
404 /// Ledger::empty().save_to(root.path(), LedgerTarget::Repo).unwrap();
405 /// assert!(root.path().join(LEDGER_PATH).is_file());
406 /// ```
407 pub fn save_to(&self, root: &Path, target: LedgerTarget) -> Result<()> {
408 let path = root.join(target.path());
409 if let Some(dir) = path.parent() {
410 std::fs::create_dir_all(dir).map_err(Error::Transport)?;
411 }
412 let mut text = serde_json::to_string_pretty(self).map_err(|e| Error::InvalidSurface {
413 surface: "evidence".into(),
414 reason: e.to_string(),
415 })?;
416 text.push('\n');
417 std::fs::write(&path, text).map_err(Error::Transport)
418 }
419
420 /// What the ledger is, for a report that has to say where its evidence came from:
421 /// where it lives, how much it holds, how new the newest of it is, and every distinct
422 /// commit its executions were recorded against.
423 ///
424 /// ```
425 /// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
426 /// # fn execution(name: &str, outcome: Outcome) -> Execution {
427 /// # Execution {
428 /// # test: format!("suite:{name}"), runner: Runner::Suite,
429 /// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
430 /// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
431 /// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
432 /// # command: format!("bash test/run.sh {name}"),
433 /// # run: None,
434 /// # }
435 /// # }
436 /// use majordomus_cli::evidence::ledger::{Ledger, LEDGER_PATH};
437 ///
438 /// let mut ledger = Ledger::empty();
439 /// ledger.merge([
440 /// execution("07_scope", Outcome::Pass),
441 /// execution("08_other", Outcome::Fail),
442 /// ]);
443 ///
444 /// let summary = ledger.summary();
445 /// assert_eq!(summary.path, LEDGER_PATH);
446 /// assert_eq!(summary.executions, 2);
447 /// assert_eq!(summary.newest.as_deref(), Some("2026-09-11T00:00:00Z"));
448 /// assert_eq!(summary.commits, ["0".repeat(40)], "distinct commits, deduplicated");
449 /// assert!(!Ledger::empty().summary().present);
450 /// ```
451 pub fn summary(&self) -> LedgerSummary {
452 // the BTreeSet is already in order; collecting it is the ordering
453 let commits: Vec<String> = self
454 .executions
455 .iter()
456 .map(|e| e.commit.clone())
457 .collect::<std::collections::BTreeSet<_>>()
458 .into_iter()
459 .collect();
460 LedgerSummary {
461 path: LEDGER_PATH.to_string(),
462 present: !self.executions.is_empty(),
463 executions: self.executions.len(),
464 newest: self.executions.iter().map(|e| e.at.clone()).max(),
465 commits,
466 }
467 }
468
469 /// How many executions there are of each outcome, for a one-line summary. An outcome
470 /// nothing recorded is absent from the map rather than present as a zero.
471 ///
472 /// ```
473 /// # use majordomus_cli::evidence::{Execution, Origin, Outcome, Runner};
474 /// # fn execution(name: &str, outcome: Outcome) -> Execution {
475 /// # Execution {
476 /// # test: format!("suite:{name}"), runner: Runner::Suite,
477 /// # source: format!("test/cases/{name}.sh"), outcome, seconds: 1,
478 /// # commit: "0".repeat(40), working_tree: "clean".into(), digest: "sha256:0".into(),
479 /// # at: "2026-09-11T00:00:00Z".into(), origin: Origin::Local,
480 /// # command: format!("bash test/run.sh {name}"),
481 /// # run: None,
482 /// # }
483 /// # }
484 /// use majordomus_cli::evidence::Ledger;
485 ///
486 /// let mut ledger = Ledger::empty();
487 /// ledger.merge([
488 /// execution("07_scope", Outcome::Pass),
489 /// execution("08_other", Outcome::Fail),
490 /// execution("09_third", Outcome::Pass),
491 /// ]);
492 ///
493 /// let counts = ledger.by_outcome();
494 /// assert_eq!(counts.get("pass"), Some(&2));
495 /// assert_eq!(counts.get("fail"), Some(&1));
496 /// assert_eq!(counts.get("skip"), None, "an outcome nothing recorded is absent");
497 /// ```
498 pub fn by_outcome(&self) -> BTreeMap<String, usize> {
499 outcome_counts(&self.executions)
500 }
501}
502
503/// Where the untracked, per-checkout ledger lives: under `.ai/local/`, which a repository
504/// ignores, so a recording made there changes no tracked file and moves no verdict.
505pub const LOCAL_LEDGER_PATH: &str = ".ai/local/evidence/ledger.json";
506
507/// Which ledger a recording writes: the tracked one a verdict is derived from, or the local
508/// one nothing reads to judge anything.
509///
510/// ```
511/// use majordomus_cli::evidence::ledger::{LedgerTarget, LEDGER_PATH, LOCAL_LEDGER_PATH};
512///
513/// assert_eq!(LedgerTarget::default(), LedgerTarget::Repo);
514/// assert_eq!(LedgerTarget::Repo.path(), LEDGER_PATH);
515/// assert_eq!(LedgerTarget::Local.path(), LOCAL_LEDGER_PATH);
516/// assert_eq!(serde_json::to_value(LedgerTarget::Local).unwrap(), "local");
517/// ```
518#[derive(
519 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
520)]
521#[serde(rename_all = "snake_case")]
522#[schemars(rename = "EvidenceLedgerTarget")]
523pub enum LedgerTarget {
524 /// The tracked ledger, [`LEDGER_PATH`].
525 #[default]
526 Repo,
527 /// The ignored local ledger, [`LOCAL_LEDGER_PATH`].
528 Local,
529}
530
531impl LedgerTarget {
532 /// The repository-relative path of the ledger this target names.
533 ///
534 /// ```
535 /// use majordomus_cli::evidence::ledger::LedgerTarget;
536 /// assert!(LedgerTarget::Local.path().starts_with(".ai/local/"));
537 /// ```
538 pub fn path(self) -> &'static str {
539 match self {
540 LedgerTarget::Repo => LEDGER_PATH,
541 LedgerTarget::Local => LOCAL_LEDGER_PATH,
542 }
543 }
544}
545
546/// How many of these executions there are of each outcome, keyed by the outcome's serde
547/// word; an outcome nothing recorded is absent rather than 0. The one count
548/// [`Ledger::by_outcome`] and a run record's totals both use.
549///
550/// ```
551/// use majordomus_cli::evidence::ledger::{outcome_counts, Ledger};
552///
553/// assert!(outcome_counts(&[]).is_empty());
554/// let ledger = Ledger::parse(r#"{"version": 1, "executions": []}"#).unwrap();
555/// assert_eq!(outcome_counts(&ledger.executions), ledger.by_outcome());
556/// ```
557pub fn outcome_counts(executions: &[Execution]) -> BTreeMap<String, usize> {
558 let mut m = BTreeMap::new();
559 for e in executions {
560 let k = serde_json::to_value(e.outcome)
561 .ok()
562 .and_then(|v| v.as_str().map(str::to_string))
563 .unwrap_or("unknown".into());
564 *m.entry(k).or_insert(0) += 1;
565 }
566 m
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::evidence::{Origin, Outcome, Runner};
573
574 fn execution(test: &str, outcome: Outcome) -> Execution {
575 Execution {
576 test: test.into(),
577 runner: Runner::Suite,
578 source: format!("test/cases/{test}.sh"),
579 outcome,
580 seconds: 1,
581 commit: "0".repeat(40),
582 working_tree: "clean".into(),
583 digest: "sha256:0".into(),
584 at: "2026-09-11T00:00:00Z".into(),
585 origin: Origin::Local,
586 command: format!("bash test/run.sh {test}"),
587 run: None,
588 }
589 }
590
591 /// The property the whole recorder depends on: recording one test leaves every other
592 /// test's evidence exactly as it was. Without it, running one case would erase the
593 /// repository's proof of everything else.
594 #[test]
595 fn merging_one_test_leaves_the_others_untouched() {
596 let mut l = Ledger::empty();
597 l.merge([execution("a", Outcome::Pass), execution("b", Outcome::Pass)]);
598 assert_eq!(l.executions.len(), 2);
599
600 l.merge([execution("a", Outcome::Fail)]);
601 assert_eq!(
602 l.executions.len(),
603 2,
604 "merging replaced rather than appended"
605 );
606 assert_eq!(l.latest("a").unwrap().outcome, Outcome::Fail);
607 assert_eq!(
608 l.latest("b").unwrap().outcome,
609 Outcome::Pass,
610 "recording one test destroyed another's evidence"
611 );
612 assert!(l.latest("c").is_none());
613 }
614
615 /// Ordered by test id, so that two runs of the same set produce the same file and a
616 /// diff shows what changed rather than what moved.
617 #[test]
618 fn the_file_is_ordered_by_test_so_it_diffs_cleanly() {
619 let mut l = Ledger::empty();
620 l.merge([
621 execution("z", Outcome::Pass),
622 execution("m", Outcome::Pass),
623 execution("a", Outcome::Pass),
624 ]);
625 let ids: Vec<&str> = l.executions.iter().map(|e| e.test.as_str()).collect();
626 assert_eq!(ids, vec!["a", "m", "z"]);
627 }
628
629 #[test]
630 fn a_round_trip_through_the_file_preserves_every_execution() {
631 let dir = tempfile::tempdir().unwrap();
632 let mut l = Ledger::empty();
633 l.merge([execution("a", Outcome::Pass), execution("b", Outcome::Fail)]);
634
635 assert!(!Ledger::present(dir.path()));
636 assert_eq!(Ledger::load(dir.path()).unwrap(), Ledger::empty());
637
638 l.save(dir.path()).unwrap();
639 assert!(Ledger::present(dir.path()));
640 assert_eq!(Ledger::load(dir.path()).unwrap(), l);
641
642 let s = l.summary();
643 assert_eq!(s.executions, 2);
644 assert!(s.present);
645 assert_eq!(s.path, LEDGER_PATH);
646 assert_eq!(s.commits.len(), 1);
647 assert_eq!(l.by_outcome().get("pass"), Some(&1));
648 assert_eq!(l.by_outcome().get("fail"), Some(&1));
649 }
650
651 /// A ledger written by a future version is refused, not reinterpreted. A reader that
652 /// guessed could report a pass that was never recorded, which is the one failure this
653 /// subsystem must not have.
654 #[test]
655 fn a_ledger_of_an_unknown_version_is_refused() {
656 let dir = tempfile::tempdir().unwrap();
657 std::fs::create_dir_all(dir.path().join(".ai/repo/evidence")).unwrap();
658 std::fs::write(
659 dir.path().join(LEDGER_PATH),
660 r#"{"version": 99, "executions": []}"#,
661 )
662 .unwrap();
663 let err = Ledger::load(dir.path()).unwrap_err().to_string();
664 assert!(err.contains("version 99"), "{err}");
665
666 std::fs::write(dir.path().join(LEDGER_PATH), "not json at all").unwrap();
667 assert!(Ledger::load(dir.path()).is_err());
668 }
669
670 /// A missing ledger and an empty one both mean "nothing is recorded". Neither may be
671 /// an error, because a fresh clone has the first and a repository mid-adoption has the
672 /// second, and both must report every claim as `not run` rather than refusing to
673 /// answer.
674 #[test]
675 fn a_repository_that_recorded_nothing_is_not_an_error() {
676 let dir = tempfile::tempdir().unwrap();
677 let l = Ledger::load(dir.path()).unwrap();
678 assert!(l.executions.is_empty());
679 assert!(!l.summary().present);
680 assert_eq!(l.summary().executions, 0);
681 assert!(l.summary().newest.is_none());
682 }
683
684 #[test]
685 fn the_local_target_reads_and_writes_under_ai_local() {
686 let dir = tempfile::tempdir().unwrap();
687 let mut ledger = Ledger::empty();
688 ledger.merge([execution("07_scope", Outcome::Pass)]);
689 ledger.save_to(dir.path(), LedgerTarget::Local).unwrap();
690 assert!(dir.path().join(".ai/local/evidence/ledger.json").is_file());
691 assert!(
692 !dir.path().join(LEDGER_PATH).exists(),
693 "the tracked ledger is untouched"
694 );
695 assert_eq!(
696 Ledger::load_from(dir.path(), LedgerTarget::Local).unwrap(),
697 ledger
698 );
699 assert_eq!(Ledger::load(dir.path()).unwrap(), Ledger::empty());
700 }
701
702 #[test]
703 fn an_absent_local_ledger_is_empty() {
704 let dir = tempfile::tempdir().unwrap();
705 let mut tracked = Ledger::empty();
706 tracked.merge([execution("07_scope", Outcome::Pass)]);
707 tracked.save(dir.path()).unwrap();
708 assert_eq!(
709 Ledger::load_from(dir.path(), LedgerTarget::Local).unwrap(),
710 Ledger::empty()
711 );
712 }
713
714 #[test]
715 fn a_bad_local_ledger_names_its_own_path() {
716 let dir = tempfile::tempdir().unwrap();
717 std::fs::create_dir_all(dir.path().join(".ai/local/evidence")).unwrap();
718 std::fs::write(dir.path().join(LOCAL_LEDGER_PATH), "not json").unwrap();
719 let err = Ledger::load_from(dir.path(), LedgerTarget::Local)
720 .unwrap_err()
721 .to_string();
722 assert!(err.contains(LOCAL_LEDGER_PATH), "{err}");
723 assert!(!err.contains(LEDGER_PATH), "{err}");
724 std::fs::write(
725 dir.path().join(LOCAL_LEDGER_PATH),
726 r#"{"version": 7, "executions": []}"#,
727 )
728 .unwrap();
729 let err = Ledger::load_from(dir.path(), LedgerTarget::Local)
730 .unwrap_err()
731 .to_string();
732 assert!(
733 err.contains(LOCAL_LEDGER_PATH) && err.contains("version 7"),
734 "{err}"
735 );
736 }
737
738 #[test]
739 fn outcome_counts_is_what_by_outcome_answers_for_the_ledgers_executions() {
740 let mut ledger = Ledger::empty();
741 ledger.merge([
742 execution("01_a", Outcome::Pass),
743 execution("02_b", Outcome::Fail),
744 execution("03_c", Outcome::Pass),
745 execution("04_d", Outcome::Error),
746 ]);
747 let counts = outcome_counts(&ledger.executions);
748 assert_eq!(counts, ledger.by_outcome());
749 assert_eq!(counts.get("pass"), Some(&2));
750 assert_eq!(counts.get("error"), Some(&1));
751 assert_eq!(counts.get("skip"), None);
752 }
753}