Skip to main content

rvcsi_adapter_file/
lib.rs

1//! # rvCSI file/replay adapter
2//!
3//! The `.rvcsi` capture container, its [`FileRecorder`], and the
4//! [`FileReplayAdapter`] [`CsiSource`](rvcsi_core::CsiSource) (ADR-095 FR1/FR10,
5//! D9).
6//!
7//! A `.rvcsi` file is plain [JSONL]: the first line is a [`CaptureHeader`]
8//! describing the session; every subsequent line is one
9//! [`rvcsi_core::CsiFrame`] serialized as compact JSON. The format is simple,
10//! deterministic, append-friendly and trivially inspectable with `head` / `jq`.
11//!
12//! Typical use:
13//!
14//! ```no_run
15//! use rvcsi_adapter_file::{CaptureHeader, FileRecorder, FileReplayAdapter};
16//! use rvcsi_core::{AdapterKind, AdapterProfile, CsiSource, SessionId, SourceId};
17//!
18//! # fn demo() -> rvcsi_core::Result<()> {
19//! let header = CaptureHeader::new(
20//!     SessionId(1),
21//!     SourceId::from("file:lab.rvcsi"),
22//!     AdapterProfile::offline(AdapterKind::File),
23//! );
24//! let mut rec = FileRecorder::create("lab.rvcsi", &header)?;
25//! // rec.write_frame(&frame)?; ...
26//! rec.finish()?;
27//!
28//! let mut replay = FileReplayAdapter::open("lab.rvcsi")?;
29//! while let Some(frame) = replay.next_frame()? {
30//!     // hand `frame` downstream — its ValidationStatus is preserved as recorded
31//!     let _ = frame;
32//! }
33//! # Ok(())
34//! # }
35//! ```
36//!
37//! [JSONL]: https://jsonlines.org/
38
39#![forbid(unsafe_code)]
40#![warn(missing_docs)]
41
42mod format;
43mod recorder;
44mod replay;
45
46pub use format::{CaptureHeader, CAPTURE_VERSION};
47pub use recorder::FileRecorder;
48pub use replay::FileReplayAdapter;
49
50use std::path::Path;
51
52use rvcsi_core::{CsiFrame, Result};
53
54/// Read an entire `.rvcsi` capture into memory: its [`CaptureHeader`] and every
55/// [`CsiFrame`] it contains, in recording order.
56///
57/// This is a convenience wrapper over [`FileReplayAdapter`]; for large captures
58/// or streaming use, prefer iterating [`FileReplayAdapter`] directly. Errors are
59/// the same as [`FileReplayAdapter::open`] / [`FileReplayAdapter::next_frame`]:
60/// an [`rvcsi_core::RvcsiError::Io`] for a missing/unreadable file, an
61/// [`rvcsi_core::RvcsiError::Parse`] (offset `0`) for a bad header, or an
62/// [`rvcsi_core::RvcsiError::Parse`] carrying the 1-based line number for a
63/// malformed frame line.
64pub fn read_all(path: impl AsRef<Path>) -> Result<(CaptureHeader, Vec<CsiFrame>)> {
65    use rvcsi_core::CsiSource;
66    let mut adapter = FileReplayAdapter::open(path)?;
67    let header = adapter.header().clone();
68    let mut frames = Vec::new();
69    while let Some(frame) = adapter.next_frame()? {
70        frames.push(frame);
71    }
72    Ok((header, frames))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use rvcsi_core::{
79        AdapterKind, AdapterProfile, CsiSource, FrameId, RvcsiError, SessionId, SourceId,
80        ValidationStatus,
81    };
82    use std::fs::File;
83    use std::io::{Read, Write};
84
85    fn header() -> CaptureHeader {
86        CaptureHeader::new(
87            SessionId(1),
88            SourceId::from("it-test"),
89            AdapterProfile::offline(AdapterKind::File),
90        )
91        .with_created_unix_ns(0)
92        .with_calibration_version("room@v1")
93        .with_runtime_config_json(r#"{"window_ms":500}"#)
94    }
95
96    /// A small varied set of frames: two accepted (quality 0.9), two degraded
97    /// with reasons, one recovered — varying timestamps / channels / subcarrier
98    /// counts.
99    fn sample_frames() -> Vec<CsiFrame> {
100        let mut frames = Vec::new();
101
102        let mut f0 = CsiFrame::from_iq(
103            FrameId(0),
104            SessionId(1),
105            SourceId::from("it-test"),
106            AdapterKind::File,
107            1_000,
108            1,
109            20,
110            vec![1.0, 2.0, 3.0, 4.0],
111            vec![0.5, 0.5, 0.5, 0.5],
112        )
113        .with_rssi(-55);
114        f0.validation = ValidationStatus::Accepted;
115        f0.quality_score = 0.9;
116        frames.push(f0);
117
118        let mut f1 = CsiFrame::from_iq(
119            FrameId(1),
120            SessionId(1),
121            SourceId::from("it-test"),
122            AdapterKind::File,
123            2_000,
124            6,
125            40,
126            vec![0.1; 8],
127            vec![0.2; 8],
128        );
129        f1.validation = ValidationStatus::Degraded;
130        f1.quality_score = 0.4;
131        f1.quality_reasons = vec!["missing rssi".to_string(), "low snr".to_string()];
132        frames.push(f1);
133
134        let mut f2 = CsiFrame::from_iq(
135            FrameId(2),
136            SessionId(1),
137            SourceId::from("it-test"),
138            AdapterKind::File,
139            3_000,
140            11,
141            20,
142            vec![5.0, 6.0],
143            vec![1.0, -1.0],
144        )
145        .with_rssi(-70)
146        .with_noise_floor(-95);
147        f2.validation = ValidationStatus::Accepted;
148        f2.quality_score = 0.9;
149        frames.push(f2);
150
151        let mut f3 = CsiFrame::from_iq(
152            FrameId(3),
153            SessionId(1),
154            SourceId::from("it-test"),
155            AdapterKind::File,
156            2_500, // deliberately out of order — replay preserves it verbatim
157            6,
158            20,
159            vec![0.0; 3],
160            vec![0.0; 3],
161        );
162        f3.validation = ValidationStatus::Recovered;
163        f3.quality_score = 0.3;
164        frames.push(f3);
165
166        let mut f4 = CsiFrame::from_iq(
167            FrameId(4),
168            SessionId(1),
169            SourceId::from("it-test"),
170            AdapterKind::File,
171            4_000,
172            36,
173            80,
174            vec![2.0; 6],
175            vec![0.0; 6],
176        );
177        f4.validation = ValidationStatus::Degraded;
178        f4.quality_score = 0.5;
179        f4.quality_reasons = vec!["amplitude spike".to_string()];
180        frames.push(f4);
181
182        frames
183    }
184
185    #[test]
186    fn record_then_replay_roundtrips_exactly() {
187        let tmp = tempfile::NamedTempFile::new().unwrap();
188        let header = header();
189        let frames = sample_frames();
190
191        let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
192        for f in &frames {
193            rec.write_frame(f).unwrap();
194        }
195        assert_eq!(rec.frames_written(), frames.len() as u64);
196        rec.finish().unwrap();
197
198        let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
199        assert_eq!(adapter.header(), &header);
200        let mut got = Vec::new();
201        while let Some(f) = adapter.next_frame().unwrap() {
202            got.push(f);
203        }
204        assert_eq!(got, frames);
205        assert_eq!(adapter.health().frames_delivered, frames.len() as u64);
206        assert!(!adapter.health().connected);
207    }
208
209    #[test]
210    fn re_serializing_replayed_frames_is_byte_identical() {
211        let tmp = tempfile::NamedTempFile::new().unwrap();
212        let header = header();
213        let frames = sample_frames();
214        let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
215        for f in &frames {
216            rec.write_frame(f).unwrap();
217        }
218        rec.finish().unwrap();
219
220        let mut original = String::new();
221        File::open(tmp.path()).unwrap().read_to_string(&mut original).unwrap();
222
223        // Round-trip the whole capture and re-emit it; bytes must match.
224        let (h, fs) = read_all(tmp.path()).unwrap();
225        let tmp2 = tempfile::NamedTempFile::new().unwrap();
226        let mut rec2 = FileRecorder::create(tmp2.path(), &h).unwrap();
227        for f in &fs {
228            rec2.write_frame(f).unwrap();
229        }
230        rec2.finish().unwrap();
231        let mut reemitted = String::new();
232        File::open(tmp2.path()).unwrap().read_to_string(&mut reemitted).unwrap();
233
234        assert_eq!(original, reemitted);
235    }
236
237    #[test]
238    fn read_all_matches_replay() {
239        let tmp = tempfile::NamedTempFile::new().unwrap();
240        let header = header();
241        let frames = sample_frames();
242        let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
243        for f in &frames {
244            rec.write_frame(f).unwrap();
245        }
246        rec.finish().unwrap();
247
248        let (h, fs) = read_all(tmp.path()).unwrap();
249        assert_eq!(h, header);
250        assert_eq!(fs, frames);
251    }
252
253    #[test]
254    fn header_only_capture_has_no_frames() {
255        let tmp = tempfile::NamedTempFile::new().unwrap();
256        let header = header();
257        FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap();
258
259        let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
260        assert!(adapter.next_frame().unwrap().is_none());
261
262        let (h, fs) = read_all(tmp.path()).unwrap();
263        assert_eq!(h, header);
264        assert!(fs.is_empty());
265    }
266
267    #[test]
268    fn bad_header_line_is_parse_error_at_offset_zero() {
269        let tmp = tempfile::NamedTempFile::new().unwrap();
270        {
271            let mut f = File::create(tmp.path()).unwrap();
272            f.write_all(b"not json\n").unwrap();
273        }
274        match FileReplayAdapter::open(tmp.path()) {
275            Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
276            other => panic!("expected Parse at offset 0, got {other:?}"),
277        }
278        match read_all(tmp.path()) {
279            Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
280            other => panic!("expected Parse at offset 0, got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn garbage_frame_after_good_frames_reports_line_number() {
286        let tmp = tempfile::NamedTempFile::new().unwrap();
287        let header = header();
288        {
289            let mut f = File::create(tmp.path()).unwrap();
290            serde_json::to_writer(&mut f, &header).unwrap();
291            f.write_all(b"\n").unwrap();
292            // lines 2 + 3: good frames
293            let frames = sample_frames();
294            serde_json::to_writer(&mut f, &frames[0]).unwrap();
295            f.write_all(b"\n").unwrap();
296            serde_json::to_writer(&mut f, &frames[1]).unwrap();
297            f.write_all(b"\n").unwrap();
298            // line 4: garbage
299            f.write_all(b"{ not a frame }\n").unwrap();
300        }
301        let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
302        assert!(adapter.next_frame().unwrap().is_some()); // line 2
303        assert!(adapter.next_frame().unwrap().is_some()); // line 3
304        match adapter.next_frame() {
305            Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 4),
306            other => panic!("expected Parse at line 4, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn nonexistent_path_is_io_error() {
312        match FileReplayAdapter::open("/no/such/file/at/all.rvcsi") {
313            Err(RvcsiError::Io(_)) => {}
314            other => panic!("expected Io error, got {other:?}"),
315        }
316        match read_all("/no/such/file/at/all.rvcsi") {
317            Err(RvcsiError::Io(_)) => {}
318            other => panic!("expected Io error, got {other:?}"),
319        }
320    }
321
322    #[test]
323    fn counters_are_consistent() {
324        let tmp = tempfile::NamedTempFile::new().unwrap();
325        let header = header();
326        let frames = sample_frames();
327        let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
328        for (i, f) in frames.iter().enumerate() {
329            rec.write_frame(f).unwrap();
330            assert_eq!(rec.frames_written(), (i + 1) as u64);
331        }
332        rec.finish().unwrap();
333
334        let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
335        let mut n = 0u64;
336        while adapter.next_frame().unwrap().is_some() {
337            n += 1;
338            assert_eq!(adapter.health().frames_delivered, n);
339        }
340        assert_eq!(n, frames.len() as u64);
341    }
342}