Skip to main content

majordomus_cli/
ledger.rs

1//! The ledger: the append-only record of what happened to this checkout, and the one
2//! writer of it in this executable.
3//!
4//! `lib/common.sh`'s `mj_ledger_append` has been the only writer since the record existed.
5//! This is the second, and it exists for one reason: a capability of kind `command` must be
6//! able to record what it did, and a capability cannot shell out to the tool that calls it.
7//! Two writers of one record is the failure this repository exists to prevent, so the two
8//! are held to the same contract rather than left to drift:
9//!
10//! * the same file — `.ai/local/state/ledger.jsonl`, never tracked, created on first write;
11//! * the same envelope — `ts`, `event`, `head`, `branch`, `by`, then `session` when this
12//!   checkout has an open episode, then the event's own payload, in that order;
13//!   `head` is `NONE` in an unborn repository and `branch` is `DETACHED` off a branch,
14//!   because that is what the shell writes and a reader may not meet two spellings;
15//! * the same vocabulary — `share/events.yaml` declares every name the ledger accepts and
16//!   the payload keys each one must carry. An unregistered name is an error here exactly as
17//!   it is there: before that file existed a mistyped name produced a durable line that
18//!   every reader silently ignored.
19//!
20//! `test/cases/133_plan_transition.sh` holds the two writers to byte equality: it moves one
21//! issue through each engine and compares the envelope and the key order of the lines they
22//! append. Where they disagree the shell is the incumbent and is right; this file is the one
23//! that changes.
24//!
25//! ```
26//! use majordomus_cli::git::{GitInfo, GitState};
27//! use majordomus_cli::ledger::{self, Vocabulary};
28//! # let dir = std::env::temp_dir().join(format!("mj-ledger-doc-{}", std::process::id()));
29//! # let _ = std::fs::remove_dir_all(&dir);
30//! # std::fs::create_dir_all(&dir).unwrap();
31//! # std::fs::write(dir.join("events.yaml"),
32//! #   "version: 1\nevents:\n  - id: plan_start\n    requires: [issue]\n").unwrap();
33//! let vocabulary = Vocabulary::load(&dir.join("events.yaml")).unwrap();
34//! let git = GitState::Available(GitInfo {
35//!     toplevel: dir.clone(), head: Some("a".repeat(40)),
36//!     branch: Some("master".into()), working_tree: "clean".into(),
37//! });
38//!
39//! // One line, appended; the envelope is composed in a fixed order and the payload follows.
40//! let line = ledger::append(&dir, &vocabulary, &git, "2026-09-11T12:00:00Z",
41//!                           "plan_start", &[("issue", "I0001".into())]).unwrap();
42//! assert!(line.starts_with(r#"{"ts":"2026-09-11T12:00:00Z","event":"plan_start""#));
43//!
44//! // A name nothing declares never reaches the file, which is why the vocabulary exists.
45//! assert!(ledger::append(&dir, &vocabulary, &git, "2026-09-11T12:00:00Z",
46//!                        "plan_strat", &[("issue", "I0001".into())]).is_err());
47//!
48//! let (entries, skipped) = ledger::read(&dir);
49//! assert_eq!((entries.len(), skipped), (1, 0));
50//! # std::fs::remove_dir_all(&dir).unwrap();
51//! ```
52
53use std::collections::BTreeMap;
54use std::fmt;
55use std::fs::{self, OpenOptions};
56use std::io::Write;
57use std::path::{Path, PathBuf};
58
59use serde::{Deserialize, Serialize};
60
61use crate::git::{GitInfo, GitState};
62use crate::metadata::yaml;
63
64/// Where the untracked half of the layer lives, relative to the repository root.
65const STATE_DIR: &str = ".ai/local/state";
66/// The record itself.
67const LEDGER: &str = "ledger.jsonl";
68/// The pointer to this checkout's open episode.
69const SESSION_POINTER: &str = "session-current.yaml";
70
71/// Why a line was not written.
72///
73/// Every variant is a refusal to corrupt the record, never a partial write: the line is
74/// composed and validated in full before the file is opened.
75///
76/// ```
77/// use majordomus_cli::ledger::LedgerError;
78/// let e = LedgerError::MissingField { event: "plan_evidence".into(), field: "covers".into() };
79/// assert_eq!(e.to_string(), "event 'plan_evidence' is missing the required field 'covers'");
80/// ```
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum LedgerError {
83    /// The event name is not declared in `share/events.yaml`.
84    UnknownEvent {
85        /// The name that was offered.
86        event: String,
87        /// Every name that is declared, in declaration order.
88        known: Vec<String>,
89    },
90    /// The event is declared, but the payload omits a key the declaration requires.
91    MissingField {
92        /// The event.
93        event: String,
94        /// The key that is required and absent.
95        field: String,
96    },
97    /// `share/events.yaml` could not be read or does not parse.
98    Vocabulary {
99        /// The file.
100        path: PathBuf,
101        /// What is wrong.
102        reason: String,
103    },
104    /// The line was composed and the file could not be appended to.
105    Write {
106        /// The ledger.
107        path: PathBuf,
108        /// What the filesystem said.
109        reason: String,
110    },
111}
112
113impl fmt::Display for LedgerError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::UnknownEvent { event, known } => write!(
117                f,
118                "unregistered event '{event}'; declare it in share/events.yaml or use one of: {}",
119                known.join(" ")
120            ),
121            Self::MissingField { event, field } => {
122                write!(f, "event '{event}' is missing the required field '{field}'")
123            }
124            Self::Vocabulary { path, reason } => write!(f, "{}: {reason}", path.display()),
125            Self::Write { path, reason } => write!(f, "{}: {reason}", path.display()),
126        }
127    }
128}
129
130impl std::error::Error for LedgerError {}
131
132/// One event of the vocabulary, as `share/events.yaml` declares it.
133///
134/// Only the three fields a writer must honour are read. The rest of the declaration —
135/// `title`, `summary`, `public`, `note` — is what documentation and `history` project, and
136/// is deliberately not modelled here: a writer that grew opinions about prose would be a
137/// second reason for that file to change.
138#[derive(Debug, Clone, Deserialize)]
139struct EventDecl {
140    /// The name, as written into the record.
141    id: String,
142    /// Payload keys every line of this type must carry, beyond the envelope.
143    #[serde(default)]
144    requires: Vec<String>,
145}
146
147#[derive(Debug, Clone, Deserialize)]
148struct EventsFile {
149    #[serde(default)]
150    events: Vec<EventDecl>,
151}
152
153/// The event vocabulary, read once and shared.
154///
155/// ```
156/// use majordomus_cli::ledger::Vocabulary;
157/// // The default is empty, and an empty vocabulary accepts nothing: a writer that could
158/// // not read the declaration refuses every name rather than allowing any.
159/// let v = Vocabulary::default();
160/// assert!(v.ids().is_empty());
161/// assert!(v.requires("plan_start").is_none());
162/// ```
163#[derive(Debug, Clone, Default)]
164pub struct Vocabulary {
165    events: BTreeMap<String, Vec<String>>,
166    order: Vec<String>,
167}
168
169impl Vocabulary {
170    /// Read `share/events.yaml`.
171    ///
172    /// The file ships with the tool, so a repository never carries its own: a name this
173    /// executable does not know is a name nothing in the distribution writes.
174    ///
175    /// ```
176    /// use majordomus_cli::ledger::Vocabulary;
177    /// # let f = std::env::temp_dir().join(format!("mj-voc-{}.yaml", std::process::id()));
178    /// # std::fs::write(&f, "version: 1\nevents:\n  - id: plan_done\n    requires: [issue]\n").unwrap();
179    /// let v = Vocabulary::load(&f).unwrap();
180    /// assert_eq!(v.ids(), ["plan_done"]);
181    /// assert_eq!(v.requires("plan_done"), Some(&["issue".to_string()][..]));
182    /// # std::fs::remove_file(&f).unwrap();
183    ///
184    /// // A file that is not there is an error, never an empty vocabulary that accepts all.
185    /// assert!(Vocabulary::load(std::path::Path::new("/nonexistent/events.yaml")).is_err());
186    /// ```
187    pub fn load(events_yaml: &Path) -> Result<Self, LedgerError> {
188        let text = fs::read_to_string(events_yaml).map_err(|e| LedgerError::Vocabulary {
189            path: events_yaml.to_path_buf(),
190            reason: e.to_string(),
191        })?;
192        let parsed: EventsFile =
193            yaml::parse_into(&text).map_err(|reason| LedgerError::Vocabulary {
194                path: events_yaml.to_path_buf(),
195                reason,
196            })?;
197        let mut events = BTreeMap::new();
198        let mut order = Vec::new();
199        for e in parsed.events {
200            order.push(e.id.clone());
201            events.insert(e.id, e.requires);
202        }
203        Ok(Self { events, order })
204    }
205
206    /// Every declared name, in declaration order. What a refusal lists.
207    pub fn ids(&self) -> &[String] {
208        &self.order
209    }
210
211    /// The keys a line of this event must carry, or `None` when the name is not declared.
212    ///
213    /// `None` and `Some(&[])` are different answers: the first is a name nothing declares,
214    /// the second a declared event whose envelope is all it needs.
215    ///
216    /// ```
217    /// use majordomus_cli::ledger::Vocabulary;
218    /// assert!(Vocabulary::default().requires("nothing").is_none());
219    /// ```
220    pub fn requires(&self, event: &str) -> Option<&[String]> {
221        self.events.get(event).map(|v| v.as_slice())
222    }
223}
224
225/// One payload key and its value, as it will be written.
226///
227/// A pair rather than a map because order is part of the contract: the shell appends the
228/// caller's keys in the order the caller wrote them, and two writers that sort differently
229/// produce two spellings of one event.
230pub type Field<'a> = (&'a str, String);
231
232/// Append one line to the ledger of `root`.
233///
234/// The line is composed in full, validated against the vocabulary, and only then appended.
235/// `now` is passed in rather than read here so that a test can write a line whose timestamp
236/// it knows; every caller in the executable passes [`now`].
237///
238/// ```
239/// use majordomus_cli::git::GitState;
240/// use majordomus_cli::ledger::{self, Vocabulary};
241/// # let dir = std::env::temp_dir().join(format!("mj-append-{}", std::process::id()));
242/// # let _ = std::fs::remove_dir_all(&dir);
243/// # std::fs::create_dir_all(&dir).unwrap();
244/// # std::fs::write(dir.join("events.yaml"),
245/// #   "version: 1\nevents:\n  - id: plan_done\n    requires: [issue]\n").unwrap();
246/// let vocabulary = Vocabulary::load(&dir.join("events.yaml")).unwrap();
247/// let git = GitState::Unavailable { reason: "not a work tree".into() };
248///
249/// let line = ledger::append(&dir, &vocabulary, &git, "2026-09-11T12:00:00Z",
250///                           "plan_done", &[("issue", "I0001".into())]).unwrap();
251/// // Where git cannot answer, the two fields are spelled with the literals the shell uses,
252/// // so a reader never meets a second spelling of "unknown".
253/// assert!(line.contains(r#""head":"NONE""#), "{line}");
254/// assert!(line.contains(r#""branch":"DETACHED""#), "{line}");
255///
256/// // A declared event whose payload omits a required key writes nothing at all.
257/// let err = ledger::append(&dir, &vocabulary, &git, "2026-09-11T12:00:00Z",
258///                          "plan_done", &[]).unwrap_err();
259/// assert_eq!(err.to_string(), "event 'plan_done' is missing the required field 'issue'");
260/// assert_eq!(ledger::read(&dir).0.len(), 1, "the refusal appended nothing");
261/// # std::fs::remove_dir_all(&dir).unwrap();
262/// ```
263pub fn append(
264    root: &Path,
265    vocabulary: &Vocabulary,
266    git: &GitState,
267    now: &str,
268    event: &str,
269    payload: &[Field<'_>],
270) -> Result<String, LedgerError> {
271    let requires = vocabulary
272        .requires(event)
273        .ok_or_else(|| LedgerError::UnknownEvent {
274            event: event.to_string(),
275            known: vocabulary.ids().to_vec(),
276        })?;
277    for key in requires {
278        if !payload.iter().any(|(k, _)| k == key) {
279            return Err(LedgerError::MissingField {
280                event: event.to_string(),
281                field: key.clone(),
282            });
283        }
284    }
285
286    let (head, branch) = match git {
287        GitState::Available(GitInfo { head, branch, .. }) => (
288            head.clone().unwrap_or_else(|| "NONE".into()),
289            branch.clone().unwrap_or_else(|| "DETACHED".into()),
290        ),
291        // What the shell writes when git cannot answer: `mj_git_head` falls back to NONE
292        // and `mj_git_branch` to DETACHED, each independently of why git was silent.
293        GitState::Unavailable { .. } => ("NONE".into(), "DETACHED".into()),
294    };
295
296    let mut line = String::from("{");
297    push_pair(&mut line, "ts", now);
298    line.push(',');
299    push_pair(&mut line, "event", event);
300    line.push(',');
301    push_pair(&mut line, "head", &head);
302    line.push(',');
303    push_pair(&mut line, "branch", &branch);
304    line.push(',');
305    push_pair(&mut line, "by", &format!("majordomus/{}", crate::VERSION));
306    if let Some(session) = open_session_id(root) {
307        line.push(',');
308        push_pair(&mut line, "session", &session);
309    }
310    for (k, v) in payload {
311        line.push(',');
312        push_pair(&mut line, k, v);
313    }
314    line.push('}');
315
316    let dir = root.join(STATE_DIR);
317    fs::create_dir_all(&dir).map_err(|e| LedgerError::Write {
318        path: dir.clone(),
319        reason: e.to_string(),
320    })?;
321    let path = dir.join(LEDGER);
322    let mut f = OpenOptions::new()
323        .create(true)
324        .append(true)
325        .open(&path)
326        .map_err(|e| LedgerError::Write {
327            path: path.clone(),
328            reason: e.to_string(),
329        })?;
330    writeln!(f, "{line}").map_err(|e| LedgerError::Write {
331        path,
332        reason: e.to_string(),
333    })?;
334    Ok(line)
335}
336
337/// The envelope's timestamp, in the one spelling both writers use: `date -u
338/// +%Y-%m-%dT%H:%M:%SZ`, to the second, always UTC.
339///
340/// [`crate::peers::rfc3339`] already renders that spelling for the peer board; a second
341/// clock in this file would be a second answer to what time it is.
342///
343/// ```
344/// let t = majordomus_cli::ledger::now();
345/// assert_eq!(t.len(), 20, "{t}");
346/// assert!(t.ends_with('Z') && t.contains('T'), "{t}");
347/// ```
348pub fn now() -> String {
349    crate::peers::rfc3339(std::time::SystemTime::now())
350}
351
352/// `"key":"value"`, with the value escaped as JSON.
353///
354/// Every field the ledger carries is a string, in both writers. A payload that wanted a
355/// number would be a change to the record's shape, not something a caller may decide.
356fn push_pair(out: &mut String, key: &str, value: &str) {
357    out.push('"');
358    out.push_str(key);
359    out.push_str("\":");
360    escape_json(out, value);
361}
362
363/// Append `value` as a JSON string literal, quotes included.
364fn escape_json(out: &mut String, value: &str) {
365    out.push('"');
366    for c in value.chars() {
367        match c {
368            '"' => out.push_str("\\\""),
369            '\\' => out.push_str("\\\\"),
370            '\n' => out.push_str("\\n"),
371            '\r' => out.push_str("\\r"),
372            '\t' => out.push_str("\\t"),
373            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
374            c => out.push(c),
375        }
376    }
377    out.push('"');
378}
379
380/// This checkout's open episode, when it has one and it is this checkout's own.
381///
382/// The shell resolves the worker's own episode first, from `MJ_SESSION_KEY` or the
383/// provider's own session environment, and falls back to this pointer. Here the pointer is
384/// read directly and `MJ_SESSION_KEY` is honoured when it is set, which is what a capability
385/// running inside a provider's session is given. A record that names another worktree is
386/// another checkout's episode and is not this line's session — the same refusal
387/// `mj_open_session_id` makes.
388fn open_session_id(root: &Path) -> Option<String> {
389    let dir = root.join(STATE_DIR);
390    let file = match std::env::var("MJ_SESSION_KEY") {
391        Ok(k) if !k.is_empty() => dir.join(format!("session-{k}.yaml")),
392        _ => dir.join(SESSION_POINTER),
393    };
394    let text = fs::read_to_string(file).ok()?;
395    let map = yaml::parse_mapping(&text).ok()?;
396    if let Some(w) = map.get("worktree").and_then(yaml::scalar_string) {
397        if !w.is_empty() && Path::new(&w) != root {
398            return None;
399        }
400    }
401    map.get("session_id")
402        .and_then(yaml::scalar_string)
403        .filter(|s| !s.is_empty())
404}
405
406/// One line of the record, as a reader sees it.
407///
408/// The envelope is typed and the payload is left as it was written: a reader of one event
409/// knows its own keys, and this type may not grow a field every time an event does.
410///
411/// ```
412/// use majordomus_cli::ledger::Entry;
413/// let e: Entry = serde_json::from_str(
414///     r#"{"ts":"2026-09-11T12:00:00Z","event":"plan_done","head":"abc","branch":"master",
415///         "by":"majordomus/0.5.0","issue":"I0001"}"#).unwrap();
416/// assert_eq!(e.event, "plan_done");
417/// assert!(e.session.is_none(), "no episode was open when this was written");
418/// // Everything the envelope does not name stays in the payload, whatever the event is.
419/// assert_eq!(e.payload["issue"], "I0001");
420/// ```
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct Entry {
423    /// When it happened.
424    pub ts: String,
425    /// The event name, from the vocabulary.
426    pub event: String,
427    /// The commit the checkout was at.
428    pub head: String,
429    /// The branch it was on.
430    pub branch: String,
431    /// What wrote it: `majordomus/<version>`.
432    pub by: String,
433    /// The episode it belongs to, when there was one.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub session: Option<String>,
436    /// Everything else the line carried.
437    #[serde(flatten)]
438    pub payload: BTreeMap<String, serde_json::Value>,
439}
440
441/// Read the record of `root`, oldest first.
442///
443/// A line that does not parse is skipped rather than fatal: the ledger is append-only and
444/// survives every version of every writer that ever appended to it, so a reader that
445/// refused the whole file over one malformed line would lose the history it exists to show.
446/// The count of skipped lines is returned beside the entries so the loss is never silent.
447///
448/// ```
449/// // A checkout that has never written one has no record, which is an empty answer rather
450/// // than an error: absence is a state the lifecycle has, not a failure to read.
451/// let (entries, skipped) = majordomus_cli::ledger::read(std::path::Path::new("/nonexistent"));
452/// assert!(entries.is_empty() && skipped == 0);
453/// ```
454pub fn read(root: &Path) -> (Vec<Entry>, usize) {
455    let path = root.join(STATE_DIR).join(LEDGER);
456    let Ok(text) = fs::read_to_string(path) else {
457        return (Vec::new(), 0);
458    };
459    let mut entries = Vec::new();
460    let mut skipped = 0;
461    for line in text.lines() {
462        if line.trim().is_empty() {
463            continue;
464        }
465        match serde_json::from_str::<Entry>(line) {
466            Ok(e) => entries.push(e),
467            Err(_) => skipped += 1,
468        }
469    }
470    (entries, skipped)
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn vocabulary() -> Vocabulary {
478        let mut events = BTreeMap::new();
479        events.insert("plan_start".to_string(), vec!["issue".to_string()]);
480        events.insert(
481            "plan_evidence".to_string(),
482            vec!["issue".to_string(), "covers".to_string()],
483        );
484        Vocabulary {
485            events,
486            order: vec!["plan_start".into(), "plan_evidence".into()],
487        }
488    }
489
490    fn git() -> GitState {
491        GitState::Available(GitInfo {
492            toplevel: PathBuf::from("/r"),
493            head: Some("a".repeat(40)),
494            branch: Some("master".into()),
495            working_tree: "clean".into(),
496        })
497    }
498
499    /// The envelope, in the order and spelling `mj_ledger_append` writes it. This is the
500    /// assertion that fails when the two writers drift.
501    #[test]
502    fn the_envelope_is_the_shells_envelope() {
503        let dir = tempdir();
504        let line = append(
505            dir.path(),
506            &vocabulary(),
507            &git(),
508            "2026-09-11T12:00:00Z",
509            "plan_start",
510            &[("issue", "i-1".into())],
511        )
512        .expect("the event is declared and its payload complete");
513        assert_eq!(
514            line,
515            format!(
516                r#"{{"ts":"2026-09-11T12:00:00Z","event":"plan_start","head":"{}","branch":"master","by":"majordomus/{}","issue":"i-1"}}"#,
517                "a".repeat(40),
518                crate::VERSION
519            )
520        );
521    }
522
523    /// An unborn repository and a detached head are the two states the shell spells with
524    /// literals rather than leaving empty; a reader may not meet a third spelling.
525    #[test]
526    fn an_unavailable_git_is_none_and_detached() {
527        let dir = tempdir();
528        let line = append(
529            dir.path(),
530            &vocabulary(),
531            &GitState::Unavailable {
532                reason: "not a work tree".into(),
533            },
534            "2026-09-11T12:00:00Z",
535            "plan_start",
536            &[("issue", "i-1".into())],
537        )
538        .unwrap();
539        assert!(line.contains(r#""head":"NONE""#), "{line}");
540        assert!(line.contains(r#""branch":"DETACHED""#), "{line}");
541    }
542
543    /// The refusal that is the whole reason `share/events.yaml` exists: a name nothing
544    /// declares never reaches the file.
545    #[test]
546    fn an_unregistered_event_is_refused_and_writes_nothing() {
547        let dir = tempdir();
548        let err = append(
549            dir.path(),
550            &vocabulary(),
551            &git(),
552            "2026-09-11T12:00:00Z",
553            "plan_strat",
554            &[("issue", "i-1".into())],
555        )
556        .unwrap_err();
557        assert!(matches!(err, LedgerError::UnknownEvent { .. }));
558        assert!(
559            !dir.path().join(STATE_DIR).join(LEDGER).exists(),
560            "a refused event created the ledger"
561        );
562    }
563
564    /// A declared event whose payload omits a required key is refused the same way: the
565    /// declaration is a contract on the line, not documentation beside it.
566    #[test]
567    fn a_missing_required_field_is_refused() {
568        let dir = tempdir();
569        let err = append(
570            dir.path(),
571            &vocabulary(),
572            &git(),
573            "2026-09-11T12:00:00Z",
574            "plan_evidence",
575            &[("issue", "i-1".into())],
576        )
577        .unwrap_err();
578        assert_eq!(
579            err,
580            LedgerError::MissingField {
581                event: "plan_evidence".into(),
582                field: "covers".into()
583            }
584        );
585    }
586
587    /// A value carrying a quote, a backslash or a newline must not be able to end the
588    /// string it sits in. An issue title is repository content and reaches this function
589    /// unmodified.
590    #[test]
591    fn a_payload_value_cannot_escape_its_string() {
592        let dir = tempdir();
593        let line = append(
594            dir.path(),
595            &vocabulary(),
596            &git(),
597            "2026-09-11T12:00:00Z",
598            "plan_start",
599            &[("issue", "a\"b\\c\nd\te".into())],
600        )
601        .unwrap();
602        assert!(line.contains(r#""issue":"a\"b\\c\nd\te""#), "{line}");
603        let parsed: serde_json::Value =
604            serde_json::from_str(&line).expect("the line is valid JSON whatever the value held");
605        assert_eq!(parsed["issue"], "a\"b\\c\nd\te");
606    }
607
608    /// Append-only, and the lines come back in the order they were written.
609    #[test]
610    fn the_record_is_append_only_and_reads_back_in_order() {
611        let dir = tempdir();
612        for id in ["i-1", "i-2", "i-3"] {
613            append(
614                dir.path(),
615                &vocabulary(),
616                &git(),
617                "2026-09-11T12:00:00Z",
618                "plan_start",
619                &[("issue", id.into())],
620            )
621            .unwrap();
622        }
623        let (entries, skipped) = read(dir.path());
624        assert_eq!(skipped, 0);
625        let ids: Vec<&str> = entries
626            .iter()
627            .map(|e| e.payload["issue"].as_str().unwrap())
628            .collect();
629        assert_eq!(ids, ["i-1", "i-2", "i-3"]);
630    }
631
632    /// A line no reader can parse costs that line and is counted, never the file.
633    #[test]
634    fn an_unreadable_line_is_skipped_and_counted() {
635        let dir = tempdir();
636        let state = dir.path().join(STATE_DIR);
637        fs::create_dir_all(&state).unwrap();
638        fs::write(state.join(LEDGER), "not json\n{\"ts\":\"t\",\"event\":\"plan_start\",\"head\":\"h\",\"branch\":\"b\",\"by\":\"x\",\"issue\":\"i-1\"}\n").unwrap();
639        let (entries, skipped) = read(dir.path());
640        assert_eq!(skipped, 1);
641        assert_eq!(entries.len(), 1);
642        assert_eq!(entries[0].event, "plan_start");
643    }
644
645    fn tempdir() -> TempDir {
646        TempDir::new()
647    }
648
649    /// A directory that removes itself. The crate has no dev-dependency for this and one
650    /// test module is not a reason to add one.
651    struct TempDir(PathBuf);
652
653    impl TempDir {
654        fn new() -> Self {
655            let p = std::env::temp_dir().join(format!(
656                "mj-ledger-{}-{:?}",
657                std::process::id(),
658                std::thread::current().id()
659            ));
660            let _ = fs::remove_dir_all(&p);
661            fs::create_dir_all(&p).unwrap();
662            Self(p)
663        }
664        fn path(&self) -> &Path {
665            &self.0
666        }
667    }
668
669    impl Drop for TempDir {
670        fn drop(&mut self) {
671            let _ = fs::remove_dir_all(&self.0);
672        }
673    }
674}