tak_cli/record.rs
1//! The on-disk record format.
2//!
3//! One JSON object per line, keys sorted, stored as a git note on the commit the
4//! measurement describes. Line-oriented and order-independent so that git's
5//! `cat_sort_uniq` notes merge strategy resolves concurrent CI writers without
6//! conflicts — see [`crate::notes`].
7//!
8//! Serialisation goes through [`Record::to_line`] rather than plain
9//! `serde_json::to_string` because `cat_sort_uniq` dedupes on exact bytes: two
10//! writers emitting the same measurement must produce byte-identical lines.
11
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14
15/// Bumped when the record shape changes incompatibly. Readers skip lines whose
16/// version they do not understand rather than failing the whole history.
17pub const SCHEMA_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Record {
21 /// Schema version. Serialised first-by-name (`v`) purely by BTreeMap ordering.
22 pub v: u32,
23
24 /// Benchmark name, from `bench.toml`.
25 pub bench: String,
26
27 /// Which program produced this measurement. Defaults to the project itself;
28 /// set to `pnpm`, `npm`, … for competitive comparisons, which are wall-clock
29 /// only — instruction counts across different programs are not comparable.
30 pub tool: String,
31
32 /// Version of `tool`, when meaningful (release backfills, competitors).
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub version: Option<String>,
35
36 /// Runner class. Series MUST be partitioned on this: moving between runner
37 /// types shifts absolute numbers enough to look like a regression.
38 pub runner: String,
39
40 /// RFC 3339, second resolution.
41 pub ts: String,
42
43 /// Metric name -> value. Deterministic metrics (`instructions`) are integers;
44 /// timings are milliseconds. A BTreeMap so key order is stable across writers.
45 pub metrics: BTreeMap<String, f64>,
46}
47
48impl Record {
49 /// Serialise to a single line, with map keys sorted.
50 ///
51 /// Stability matters: `cat_sort_uniq` dedupes byte-identical lines, so an
52 /// identical measurement written twice must collapse to one entry.
53 pub fn to_line(&self) -> anyhow::Result<String> {
54 Ok(serde_json::to_string(self)?)
55 }
56
57 /// Parse one line. Returns `Ok(None)` for blank lines and for records from a
58 /// future schema version, so that a newer writer cannot break an older reader.
59 pub fn from_line(line: &str) -> anyhow::Result<Option<Self>> {
60 let line = line.trim();
61 if line.is_empty() {
62 return Ok(None);
63 }
64 let rec: Record = serde_json::from_str(line)?;
65 if rec.v > SCHEMA_VERSION {
66 return Ok(None);
67 }
68 Ok(Some(rec))
69 }
70}
71
72/// Parse a whole note body, skipping lines that fail to parse.
73///
74/// A single malformed line — hand-edited, or written by a broken tool — must not
75/// render an entire commit's history unreadable.
76pub fn parse_note(body: &str) -> Vec<Record> {
77 body.lines()
78 .filter_map(|l| Record::from_line(l).ok().flatten())
79 .collect()
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn rec() -> Record {
87 Record {
88 v: SCHEMA_VERSION,
89 bench: "hook-env".into(),
90 tool: "mise".into(),
91 version: None,
92 runner: "linux-amd64".into(),
93 ts: "2026-07-24T09:00:00Z".into(),
94 metrics: BTreeMap::from([
95 ("instructions".to_string(), 29_413_235.0),
96 ("wall_min_ms".to_string(), 11.28),
97 ]),
98 }
99 }
100
101 #[test]
102 fn line_roundtrips() {
103 let line = rec().to_line().unwrap();
104 let back = Record::from_line(&line).unwrap().unwrap();
105 assert_eq!(back.bench, "hook-env");
106 assert_eq!(back.metrics["instructions"], 29_413_235.0);
107 }
108
109 #[test]
110 fn serialisation_is_byte_stable() {
111 // cat_sort_uniq dedupes on exact bytes; two writers must agree.
112 assert_eq!(rec().to_line().unwrap(), rec().to_line().unwrap());
113 }
114
115 #[test]
116 fn blank_lines_are_skipped_not_errors() {
117 assert!(Record::from_line(" ").unwrap().is_none());
118 }
119
120 #[test]
121 fn future_schema_versions_are_skipped() {
122 let mut r = rec();
123 r.v = SCHEMA_VERSION + 1;
124 let line = r.to_line().unwrap();
125 assert!(Record::from_line(&line).unwrap().is_none());
126 }
127
128 #[test]
129 fn one_bad_line_does_not_poison_the_note() {
130 let body = format!(
131 "{}\nnot json at all\n{}",
132 rec().to_line().unwrap(),
133 rec().to_line().unwrap()
134 );
135 assert_eq!(parse_note(&body).len(), 2);
136 }
137}