retroglyph_recorder/
rgrec.rs1use 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
23const 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
40pub 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
69fn 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
75pub 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
102fn 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
112fn 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
123const 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; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; #[allow(clippy::cast_possible_wrap)]
133 let y = yoe as i64 + era * 400;
134 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; #[allow(clippy::cast_possible_truncation)]
137 let d = (doy - (153 * mp + 2) / 5 + 1) as u32; #[allow(clippy::cast_possible_truncation)]
139 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; 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}