Skip to main content

retroglyph_recorder/
rgrec.rs

1//! `.rgrec`: the line-delimited JSON file format an [`InputRecording`] saves/loads as.
2//!
3//! One header line, then one line per event:
4//!
5//! ```text
6//! {"rgrec":1,"width":80,"height":24,"created":"2026-08-08T12:00:00Z"}
7//! {"delay_ms":0,"event":{"Key":{"code":{"Char":"a"},"modifiers":0,"kind":"Press","location":"Standard"}}}
8//! ```
9//!
10//! Line-delimited rather than one JSON document: a `.rgrec` diffs and reviews like source (an
11//! appended event is a one-line diff, not a reformat of the whole file), matching the reasoning
12//! retroglyph#461 already applied to VHS tapes. The per-event shape falls straight out of
13//! [`Event`]'s own derived [`Serialize`]/[`Deserialize`] (see `retroglyph-core`'s `serde`
14//! feature); this module only owns the header and the `delay_ms`/`event` envelope around it.
15
16use retroglyph_core::event::Event;
17use retroglyph_core::testing::InputRecording;
18use serde::{Deserialize, Serialize};
19use std::io::{self, BufRead, BufWriter, Write};
20use std::path::Path;
21use std::time::Duration;
22
23/// The current `.rgrec` format version, written into every header's `rgrec` field.
24const FORMAT_VERSION: u32 = 1;
25
26#[derive(Serialize, Deserialize)]
27struct Header {
28    rgrec: u32,
29    width: u16,
30    height: u16,
31    created: String,
32}
33
34#[derive(Serialize, Deserialize)]
35struct EventLine {
36    delay_ms: u64,
37    event: Event,
38}
39
40/// Writes `recording` to `path` as `.rgrec`, overwriting any existing file.
41///
42/// # Errors
43///
44/// Returns an error if `path` cannot be created or written to.
45pub fn write(recording: &InputRecording, path: impl AsRef<Path>) -> io::Result<()> {
46    let file = std::fs::File::create(path)?;
47    let mut out = BufWriter::new(file);
48
49    let header = Header {
50        rgrec: FORMAT_VERSION,
51        width: recording.width(),
52        height: recording.height(),
53        created: now_rfc3339(),
54    };
55    write_json_line(&mut out, &header)?;
56
57    for (delay, event) in recording.events() {
58        let line = EventLine {
59            #[allow(clippy::cast_possible_truncation)]
60            delay_ms: delay.as_millis() as u64,
61            event: event.clone(),
62        };
63        write_json_line(&mut out, &line)?;
64    }
65
66    out.flush()
67}
68
69/// Serializes `value` as one compact JSON line, terminated by `\n`.
70fn write_json_line<W: Write, T: Serialize>(out: &mut W, value: &T) -> io::Result<()> {
71    serde_json::to_writer(&mut *out, value).map_err(io::Error::other)?;
72    out.write_all(b"\n")
73}
74
75/// Reads a `.rgrec` file written by [`write()`] back into an [`InputRecording`].
76///
77/// # Errors
78///
79/// Returns an error if `path` cannot be opened or read, the file has no header line, or any
80/// line's JSON does not parse.
81pub fn read(path: impl AsRef<Path>) -> io::Result<InputRecording> {
82    let file = std::fs::File::open(path)?;
83    let mut lines = io::BufReader::new(file).lines();
84
85    let header_line = lines
86        .next()
87        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty .rgrec file"))??;
88    let header: Header = serde_json::from_str(&header_line).map_err(io::Error::other)?;
89
90    let mut recording = InputRecording::new(header.width, header.height);
91    for line in lines {
92        let line = line?;
93        if line.trim().is_empty() {
94            continue;
95        }
96        let entry: EventLine = serde_json::from_str(&line).map_err(io::Error::other)?;
97        recording.push(Duration::from_millis(entry.delay_ms), entry.event);
98    }
99    Ok(recording)
100}
101
102/// The current wall-clock time as an RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` UTC timestamp, with no
103/// external date/time dependency (see this crate's `Cargo.toml` for why: `.rgrec`'s header is the
104/// only place a timestamp is needed at all, not worth a dependency for).
105fn now_rfc3339() -> String {
106    let secs = std::time::SystemTime::now()
107        .duration_since(std::time::UNIX_EPOCH)
108        .map_or(0, |d| d.as_secs());
109    format_rfc3339(i64::try_from(secs).unwrap_or(i64::MAX))
110}
111
112/// Formats `secs` (Unix time, UTC) as `YYYY-MM-DDTHH:MM:SSZ`.
113fn format_rfc3339(secs: i64) -> String {
114    let days = secs.div_euclid(86_400);
115    let secs_of_day = secs.rem_euclid(86_400);
116    let (year, month, day) = civil_from_days(days);
117    let hour = secs_of_day / 3_600;
118    let minute = (secs_of_day % 3_600) / 60;
119    let second = secs_of_day % 60;
120    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
121}
122
123/// Howard Hinnant's `civil_from_days` algorithm (public domain): converts a day count since the
124/// Unix epoch (1970-01-01) into a `(year, month, day)` proleptic-Gregorian civil date.
125/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>
126const fn civil_from_days(z: i64) -> (i64, u32, u32) {
127    let z = z + 719_468;
128    let era = z.div_euclid(146_097);
129    #[allow(clippy::cast_sign_loss)]
130    let doe = (z - era * 146_097) as u64; // [0, 146096]
131    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
132    #[allow(clippy::cast_possible_wrap)]
133    let y = yoe as i64 + era * 400;
134    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
135    let mp = (5 * doy + 2) / 153; // [0, 11]
136    #[allow(clippy::cast_possible_truncation)]
137    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
138    #[allow(clippy::cast_possible_truncation)]
139    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
140    let y = if m <= 2 { y + 1 } else { y };
141    (y, m, d)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use retroglyph_core::event::{KeyCode, KeyEvent, KeyModifiers};
148
149    fn sample_recording() -> InputRecording {
150        let mut recording = InputRecording::new(80, 24);
151        recording.push(
152            Duration::ZERO,
153            Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
154        );
155        recording.push(
156            Duration::from_millis(125),
157            Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)),
158        );
159        recording
160    }
161
162    #[test]
163    fn write_then_read_round_trips_the_recording() {
164        let dir = std::env::temp_dir().join(format!("rgrec-test-{}", std::process::id()));
165        std::fs::create_dir_all(&dir).expect("create temp dir");
166        let path = dir.join("session.rgrec");
167
168        let recording = sample_recording();
169        write(&recording, &path).expect("write");
170        let round_tripped = read(&path).expect("read");
171
172        assert_eq!(round_tripped, recording);
173        std::fs::remove_file(&path).ok();
174    }
175
176    #[test]
177    fn write_produces_one_header_line_and_one_line_per_event() {
178        let dir = std::env::temp_dir().join(format!("rgrec-test-lines-{}", std::process::id()));
179        std::fs::create_dir_all(&dir).expect("create temp dir");
180        let path = dir.join("session.rgrec");
181
182        write(&sample_recording(), &path).expect("write");
183        let contents = std::fs::read_to_string(&path).expect("read_to_string");
184        let lines: Vec<&str> = contents.lines().collect();
185        assert_eq!(lines.len(), 3, "one header line plus one line per event");
186
187        let header: Header = serde_json::from_str(lines[0]).expect("parse header");
188        assert_eq!(header.rgrec, FORMAT_VERSION);
189        assert_eq!(header.width, 80);
190        assert_eq!(header.height, 24);
191        assert!(!header.created.is_empty());
192
193        std::fs::remove_file(&path).ok();
194    }
195
196    #[test]
197    fn read_errs_on_an_empty_file() {
198        let dir = std::env::temp_dir().join(format!("rgrec-test-empty-{}", std::process::id()));
199        std::fs::create_dir_all(&dir).expect("create temp dir");
200        let path = dir.join("empty.rgrec");
201        std::fs::write(&path, "").expect("write empty file");
202
203        let err = read(&path).unwrap_err();
204        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
205        std::fs::remove_file(&path).ok();
206    }
207
208    #[test]
209    fn format_rfc3339_matches_known_instants() {
210        assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
211        assert_eq!(format_rfc3339(1_754_654_400), "2025-08-08T12:00:00Z");
212    }
213}