Skip to main content

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}