Skip to main content

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}