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}