Skip to main content

zenkey_fleet/
record.rs

1//! `.zrec` capture and replay (issue #39; RFC 09 §5.2 documents the
2//! etiquette, this module is normative for the format).
3//!
4//! A `.zrec` file is newline-delimited JSON in the explorers' one row
5//! dialect — the same shape `topic echo --format ndjson` emits and
6//! [`crate::ingest::parse_row`] reads back — upgraded with what a pipe does
7//! not need but a capture does: a versioned header line naming what was
8//! asked, a lossless `"bytes"` payload (a `"value"` is a rendering), a
9//! pacing offset `"t"` on the observer's arrival clock, and drop records
10//! interleaved **where the gap happened** (RFC 09 §5.1 O6, applied to a
11//! file — a capture taken while behind is a partial view and says so at
12//! the position of the loss).
13//!
14//! Replay is publishing. Every replayed sample rides a declared publisher
15//! ([`crate::write::declare_publication`], P7 — no ad-hoc puts), gets the
16//! *replaying* session's HLC (re-stamped deliberately: a preserved foreign
17//! HLC silently loses every RFC 04 §3.2 reconciliation), and a recorded
18//! delete passes the same class-conscious retire gate as a live one
19//! ([`crate::write::check_retire`], RFC 04 §1.2 v1.12). The etiquette the
20//! CLI enforces on top — dry-run first, header-base refusal without an
21//! explicit override — is RFC 09 §5.2's.
22
23use std::collections::HashMap;
24use std::io::{BufRead, Write};
25use std::time::{Duration, Instant};
26
27use anyhow::{Context, Result, anyhow, bail};
28use base64::Engine as _;
29use serde::{Deserialize, Serialize};
30use zenoh::Session;
31use zenoh::sample::SampleKind;
32
33use crate::ingest::{IngestRow, parse_row};
34use crate::registry::SliceSet;
35use crate::sub::{EventStream, FleetEvent, SampleView, StreamItem};
36
37/// The current `.zrec` format version, written into every header.
38pub const ZREC_VERSION: u32 = 1;
39
40fn b64(bytes: &[u8]) -> String {
41    base64::engine::general_purpose::STANDARD.encode(bytes)
42}
43
44/// RFC 3339 UTC "now", seconds precision — the header's provenance stamp.
45/// A hand-rolled civil-date conversion (Hinnant's days algorithm) beats a
46/// clock crate this crate needs for nothing else.
47pub fn rfc3339_now() -> String {
48    rfc3339_from_unix(
49        std::time::SystemTime::now()
50            .duration_since(std::time::UNIX_EPOCH)
51            .map(|d| d.as_secs())
52            .unwrap_or(0),
53    )
54}
55
56fn rfc3339_from_unix(secs: u64) -> String {
57    let (days, rem) = (secs / 86_400, secs % 86_400);
58    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
59    // Civil from days since 1970-01-01 (era-based, valid far past 2100).
60    let z = days as i64 + 719_468;
61    let era = z.div_euclid(146_097);
62    let doe = z.rem_euclid(146_097);
63    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
64    let y = yoe + era * 400;
65    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
66    let mp = (5 * doy + 2) / 153;
67    let d = doy - (153 * mp + 2) / 5 + 1;
68    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
69    let y = if mo <= 2 { y + 1 } else { y };
70    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
71}
72
73/// The first line of a `.zrec` file: what was asked, under which base, and
74/// when (RFC 09 §5.1 O4 — a capture names its question). The `base` is the
75/// operator's *stated* deployment base at capture time; recorded keys are
76/// full wire keys and are never re-derived from it (O3).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ZrecHeader {
79    /// Format version ([`ZREC_VERSION`]).
80    pub zrec: u32,
81    /// The full wire selectors the capture watched. A wildcard selector
82    /// never crosses an `@`-chunk, so a `**` capture excludes the verbatim
83    /// planes by construction (O5) — the reader states that rather than
84    /// letting the file claim "everything".
85    pub selectors: Vec<String>,
86    /// The deployment base the operator resolved at capture time
87    /// (may be empty: the base-less bus-root deployment).
88    pub base: String,
89    /// Capture start, RFC 3339 wall clock — provenance, not a pacing clock
90    /// (pacing rides each row's `t`).
91    pub captured_at: String,
92}
93
94/// A `.zrec` writer over any byte sink: header first, then rows as they
95/// arrive, drop records in place. Wrap the sink in a `BufWriter` — the
96/// writer emits line-at-a-time and never buffers samples itself, so a
97/// capture streams in bounded memory.
98pub struct ZrecWriter<W: Write> {
99    out: W,
100    /// The capture epoch on this observer's monotonic clock: every row's
101    /// `t` is an offset from here. Stamped at construction, so a sample
102    /// received before the writer existed saturates to 0 rather than
103    /// underflowing.
104    epoch: Instant,
105    samples: u64,
106    dropped: u64,
107}
108
109impl<W: Write> ZrecWriter<W> {
110    /// Write the header line and hand back a row writer.
111    pub fn new(mut out: W, header: &ZrecHeader) -> Result<Self> {
112        serde_json::to_writer(&mut out, header).context("write .zrec header")?;
113        out.write_all(b"\n").context("write .zrec header")?;
114        Ok(ZrecWriter {
115            out,
116            epoch: Instant::now(),
117            samples: 0,
118            dropped: 0,
119        })
120    }
121
122    /// Write one observed sample as a row.
123    ///
124    /// The payload rides lossless (`"bytes"`), the pacing offset is the
125    /// observer's arrival clock (`"t"`, µs since the capture epoch), and
126    /// the publisher's HLC — when one rode the sample — is carried
127    /// informatively (`"timestamp"`): replay re-stamps (RFC 09 §5.2). QoS
128    /// is stored as a profile *name* only when the wire's actual axes match
129    /// one (RFC 04 §3); axes matching no profile are not approximated.
130    pub fn write_sample(&mut self, view: &SampleView) -> Result<()> {
131        let t_us = u64::try_from(
132            view.received
133                .saturating_duration_since(self.epoch)
134                .as_micros(),
135        )
136        .unwrap_or(u64::MAX);
137        let mut obj = serde_json::json!({
138            "key": view.key,
139            "t": t_us,
140        });
141        if view.kind == SampleKind::Delete {
142            obj["delete"] = true.into();
143        } else {
144            obj["bytes"] = b64(&view.payload.to_bytes()).into();
145        }
146        if !view.encoding.is_empty() {
147            obj["encoding"] = view.encoding.clone().into();
148        }
149        if let Some(t) = view.timestamp {
150            obj["timestamp"] = t.to_string().into();
151        }
152        if let Some(profile) = zenkey::qos::QosProfile::ALL
153            .into_iter()
154            .find(|p| view.qos_matches(*p))
155        {
156            obj["qos"] = profile.name().into();
157        }
158        if let Some(a) = &view.attachment {
159            obj["attachment_b64"] = b64(&a.to_bytes()).into();
160        }
161        serde_json::to_writer(&mut self.out, &obj).context("write .zrec row")?;
162        self.out.write_all(b"\n").context("write .zrec row")?;
163        self.samples += 1;
164        Ok(())
165    }
166
167    /// Write a drop record where the gap happened (O6 on a file).
168    pub fn write_dropped(&mut self, n: u64) -> Result<()> {
169        serde_json::to_writer(&mut self.out, &serde_json::json!({ "dropped": n }))
170            .context("write .zrec drop record")?;
171        self.out
172            .write_all(b"\n")
173            .context("write .zrec drop record")?;
174        self.dropped += n;
175        Ok(())
176    }
177
178    /// Samples and drops written so far — the progress line's numbers.
179    pub fn counts(&self) -> (u64, u64) {
180        (self.samples, self.dropped)
181    }
182
183    /// Flush and hand the sink back.
184    pub fn finish(mut self) -> Result<W> {
185        self.out.flush().context("flush .zrec")?;
186        Ok(self.out)
187    }
188}
189
190/// Bounds on a capture. Unset bounds mean "until the caller stops the
191/// loop" (Ctrl-C is the caller's `select!`, not this module's business —
192/// [`record()`](record) is cancel-safe between lines).
193#[derive(Debug, Clone, Copy, Default)]
194pub struct RecordBounds {
195    /// Stop after this many samples (drop records do not count).
196    pub max_samples: Option<u64>,
197    /// Stop after this long, measured from entering [`record()`](record).
198    pub max_duration: Option<Duration>,
199}
200
201/// Drain a monitor's event stream into a `.zrec` writer until a bound is
202/// hit or the stream ends. Samples and interleaved drops are recorded;
203/// liveliness and tick events are not part of the format. `on_progress` is
204/// called after every written line with (samples, dropped) — throttle in
205/// the callback, not here.
206///
207/// Cancel-safe: dropping the future mid-`recv` loses nothing already
208/// written (each line lands whole); call [`ZrecWriter::finish`] afterwards
209/// to flush.
210pub async fn record<W: Write>(
211    events: &mut EventStream,
212    writer: &mut ZrecWriter<W>,
213    bounds: RecordBounds,
214    mut on_progress: impl FnMut(u64, u64),
215) -> Result<()> {
216    let deadline = bounds.max_duration.map(|d| Instant::now() + d);
217    loop {
218        let (samples, _) = writer.counts();
219        if bounds.max_samples.is_some_and(|max| samples >= max) {
220            return Ok(());
221        }
222        let item = match deadline {
223            Some(d) => {
224                let left = d.saturating_duration_since(Instant::now());
225                if left.is_zero() {
226                    return Ok(());
227                }
228                match tokio::time::timeout(left, events.recv()).await {
229                    Ok(item) => item,
230                    Err(_) => return Ok(()),
231                }
232            }
233            None => events.recv().await,
234        };
235        match item {
236            Some(StreamItem::Event(FleetEvent::Sample(view))) => {
237                writer.write_sample(&view)?;
238            }
239            Some(StreamItem::Dropped(n)) => {
240                writer.write_dropped(n)?;
241            }
242            Some(_) => continue,
243            None => return Ok(()),
244        }
245        let (samples, dropped) = writer.counts();
246        on_progress(samples, dropped);
247    }
248}
249
250/// What a capture did — the shared report shape both frontends render.
251#[derive(Debug, Clone, Serialize)]
252pub struct RecordReport {
253    /// The header as written: a capture names its question (O4).
254    pub header: ZrecHeader,
255    /// Where the capture went, when it went to a file.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub out: Option<String>,
258    /// Samples written.
259    pub samples: u64,
260    /// Samples the capture missed while behind — stored in the file as
261    /// interleaved drop records *and* totalled here (O6).
262    pub dropped: u64,
263    /// Wall-clock capture length.
264    pub duration_ms: u64,
265}
266
267/// One `.zrec` line after the header.
268#[derive(Debug, Clone)]
269pub enum ZrecItem {
270    /// A publishable row, its pacing offset (absent on a hand-piped ndjson
271    /// row — replay treats that as "no delay"), and the capture-time
272    /// publisher HLC, informative only.
273    Sample {
274        row: IngestRow,
275        t_us: Option<u64>,
276        timestamp: Option<String>,
277    },
278    /// Samples the capture itself missed at this position (O6).
279    Dropped(u64),
280}
281
282/// A `.zrec` reader over any buffered byte source: header up front, then
283/// one item per line — bounded memory, like the writer.
284pub struct ZrecReader<R: BufRead> {
285    header: ZrecHeader,
286    lines: std::io::Lines<R>,
287    /// 1-based number of the last line handed out (the header is line 1).
288    line: u64,
289}
290
291impl<R: BufRead> ZrecReader<R> {
292    /// Parse the header line. A file without one is not a `.zrec` — plain
293    /// ndjson pipes replay through `topic pub --from ndjson`, which needs
294    /// no base contract because the operator is the pacing.
295    pub fn new(source: R) -> Result<Self> {
296        let mut lines = source.lines();
297        let first = lines
298            .next()
299            .ok_or_else(|| anyhow!("empty file — not a .zrec (no header line)"))?
300            .context("read .zrec header")?;
301        let header: ZrecHeader = serde_json::from_str(&first)
302            .map_err(|e| anyhow!("line 1 is not a .zrec header: {e}"))?;
303        if header.zrec != ZREC_VERSION {
304            bail!(
305                "unsupported .zrec version {} (this reader speaks {})",
306                header.zrec,
307                ZREC_VERSION
308            );
309        }
310        Ok(ZrecReader {
311            header,
312            lines,
313            line: 1,
314        })
315    }
316
317    pub fn header(&self) -> &ZrecHeader {
318        &self.header
319    }
320
321    /// The next item, or `Err` naming the line and the reason — a malformed
322    /// row is counted by the caller, never silently skipped
323    /// ([`crate::ingest`]'s rule). `None` ends the file.
324    #[allow(clippy::should_implement_trait)] // fallible, line-numbered next
325    pub fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
326        loop {
327            let line = match self.lines.next()? {
328                Ok(l) => l,
329                Err(e) => {
330                    self.line += 1;
331                    return Some(Err(format!("line {}: read: {e}", self.line)));
332                }
333            };
334            self.line += 1;
335            if line.trim().is_empty() {
336                continue;
337            }
338            // A drop record is `{"dropped": n}` — no key, not a row.
339            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
340                && v.get("key").is_none()
341                && let Some(n) = v.get("dropped").and_then(serde_json::Value::as_u64)
342            {
343                return Some(Ok(ZrecItem::Dropped(n)));
344            }
345            return Some(match parse_row(&line) {
346                Ok(row) => {
347                    let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
348                    Ok(ZrecItem::Sample {
349                        row,
350                        t_us: v.get("t").and_then(serde_json::Value::as_u64),
351                        timestamp: v
352                            .get("timestamp")
353                            .and_then(serde_json::Value::as_str)
354                            .map(str::to_string),
355                    })
356                }
357                Err(e) => Err(format!("line {}: {e}", self.line)),
358            });
359        }
360    }
361}
362
363/// Where a replay's writes go.
364pub enum ReplayTarget<'a> {
365    /// No session at all: list what would be published, publish nothing.
366    /// The zero-puts guarantee is structural — there is nothing to put on.
367    DryRun,
368    /// Real puts through declared publishers on this session.
369    Bus {
370        session: &'a Session,
371        /// Registry slices for the retire gate's `ttl_s` awareness; `None`
372        /// classifies from the grammar alone.
373        slices: Option<&'a SliceSet>,
374    },
375}
376
377/// Replay events, surfaced as they happen so a frontend can render them —
378/// the report at the end carries the counts.
379#[derive(Debug, Clone)]
380pub enum ReplayEvent<'a> {
381    /// Dry run: this row would publish.
382    WouldPut {
383        key: &'a str,
384        bytes: usize,
385        encoding: Option<&'a str>,
386    },
387    /// Dry run: this row would tombstone.
388    WouldRetire { key: &'a str },
389    /// A row that could not be parsed — counted, never skipped.
390    Malformed { reason: String },
391    /// A delete row the retire gate refused (RFC 04 §1.2 v1.12).
392    Refused { key: String, reason: String },
393    /// The capture itself missed this many samples here (O6): the replay
394    /// is a partial view of a partial view, and both halves are counted.
395    CaptureDropped(u64),
396}
397
398/// What a replay did — the shared report shape both frontends render.
399#[derive(Debug, Clone, Serialize)]
400pub struct ReplayReport {
401    /// The capture header, echoed: a replay names what it replayed.
402    pub header: ZrecHeader,
403    pub dry_run: bool,
404    pub speed: f64,
405    /// Rows published (dry run: rows that would have been).
406    pub published: u64,
407    /// Tombstones sent (dry run: would have been).
408    pub tombstones: u64,
409    /// Rows that could not be parsed — counted, never skipped.
410    pub malformed: u64,
411    /// Delete rows the retire gate refused.
412    pub refused: u64,
413    /// Samples the *capture* missed (summed from the file's drop records):
414    /// this replay is a partial view and says so (O6).
415    pub capture_dropped: u64,
416    /// The first few malformed/refused reasons, for the human render.
417    #[serde(skip_serializing_if = "Vec::is_empty")]
418    pub first_errors: Vec<String>,
419}
420
421/// Replay a `.zrec` onto a bus — or list what doing so would publish.
422///
423/// Pacing follows each row's `t` divided by `speed` (must be positive);
424/// a dry run lists instantly, because a preview that takes the capture's
425/// duration is a preview nobody runs. Delete rows pass
426/// [`crate::write::check_retire`] under the **header's** base — the keys
427/// were captured under it, and classifying them under anything else would
428/// re-derive what O3 says must not be re-derived; `i_know` is the operator
429/// saying the off-state cleanup is meant. Publishers are declared once per
430/// distinct key and undeclared at the end.
431pub async fn replay<R: BufRead>(
432    reader: &mut ZrecReader<R>,
433    target: ReplayTarget<'_>,
434    speed: f64,
435    i_know: bool,
436    default_qos: &str,
437    mut on_event: impl FnMut(ReplayEvent<'_>),
438) -> Result<ReplayReport> {
439    if !(speed.is_finite() && speed > 0.0) {
440        bail!("--speed must be a positive number (got {speed})");
441    }
442    let base = reader.header().base.clone();
443    let mut report = ReplayReport {
444        header: reader.header().clone(),
445        dry_run: matches!(target, ReplayTarget::DryRun),
446        speed,
447        published: 0,
448        tombstones: 0,
449        malformed: 0,
450        refused: 0,
451        capture_dropped: 0,
452        first_errors: Vec::new(),
453    };
454    let record_err = |report: &mut ReplayReport, reason: String, refused: bool| {
455        if refused {
456            report.refused += 1;
457        } else {
458            report.malformed += 1;
459        }
460        if report.first_errors.len() < 3 {
461            report.first_errors.push(reason);
462        }
463    };
464    let mut publications: HashMap<String, crate::write::Publication> = HashMap::new();
465    let mut prev_t: Option<u64> = None;
466    while let Some(item) = reader.next() {
467        let (row, t_us) = match item {
468            Ok(ZrecItem::Sample { row, t_us, .. }) => (row, t_us),
469            Ok(ZrecItem::Dropped(n)) => {
470                report.capture_dropped += n;
471                on_event(ReplayEvent::CaptureDropped(n));
472                continue;
473            }
474            Err(reason) => {
475                on_event(ReplayEvent::Malformed {
476                    reason: reason.clone(),
477                });
478                record_err(&mut report, reason, false);
479                continue;
480            }
481        };
482        let slices = match &target {
483            ReplayTarget::Bus { slices, .. } => *slices,
484            ReplayTarget::DryRun => None,
485        };
486        if row.delete
487            && let Err(e) = crate::write::check_retire(&base, &row.key, slices, i_know)
488        {
489            let reason = e.to_string();
490            on_event(ReplayEvent::Refused {
491                key: row.key.clone(),
492                reason: reason.clone(),
493            });
494            record_err(&mut report, format!("{}: {reason}", row.key), true);
495            continue;
496        }
497        match &target {
498            ReplayTarget::DryRun => {
499                if row.delete {
500                    on_event(ReplayEvent::WouldRetire { key: &row.key });
501                    report.tombstones += 1;
502                } else {
503                    on_event(ReplayEvent::WouldPut {
504                        key: &row.key,
505                        bytes: row.payload.len(),
506                        encoding: row.encoding.as_deref(),
507                    });
508                    report.published += 1;
509                }
510            }
511            ReplayTarget::Bus { session, .. } => {
512                // Original pacing, scaled — the observer's arrival clock is
513                // the only clock a capture has for "when" (RFC 09 §5.2).
514                if let (Some(prev), Some(t)) = (prev_t, t_us)
515                    && t > prev
516                {
517                    let delay = Duration::from_micros(t - prev).div_f64(speed);
518                    tokio::time::sleep(delay).await;
519                }
520                if t_us.is_some() {
521                    prev_t = t_us;
522                }
523                let publication = match publications.entry(row.key.clone()) {
524                    std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
525                    std::collections::hash_map::Entry::Vacant(e) => {
526                        let qos_name = row.qos.as_deref().unwrap_or(default_qos);
527                        let Some(qos) = zenkey::qos::QosProfile::from_name(qos_name) else {
528                            let reason = format!("unknown QoS profile {qos_name:?}");
529                            on_event(ReplayEvent::Malformed {
530                                reason: reason.clone(),
531                            });
532                            record_err(&mut report, reason, false);
533                            continue;
534                        };
535                        let publication = crate::write::declare_publication(
536                            session,
537                            &row.key,
538                            qos,
539                            row.encoding.as_deref(),
540                        )
541                        .await?;
542                        e.insert(publication)
543                    }
544                };
545                if row.delete {
546                    publication.retire().await?;
547                    report.tombstones += 1;
548                } else {
549                    publication.send(row.payload, row.attachment).await?;
550                    report.published += 1;
551                }
552            }
553        }
554    }
555    for (_, publication) in publications.drain() {
556        publication.undeclare().await?;
557    }
558    Ok(report)
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    /// The provenance stamp is a real RFC 3339 instant, leap-era safe.
566    #[test]
567    fn the_wall_clock_formats_correctly() {
568        assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
569        assert_eq!(rfc3339_from_unix(951_782_400), "2000-02-29T00:00:00Z");
570        assert_eq!(rfc3339_from_unix(1_786_492_800), "2026-08-12T00:00:00Z");
571        assert!(!rfc3339_now().is_empty());
572    }
573
574    fn header() -> ZrecHeader {
575        ZrecHeader {
576            zrec: ZREC_VERSION,
577            selectors: vec!["v1/**".into()],
578            base: String::new(),
579            captured_at: "2026-08-12T00:00:00Z".into(),
580        }
581    }
582
583    /// The header round-trips, and a versioned reader refuses what it
584    /// cannot speak rather than guessing.
585    #[test]
586    fn the_header_is_a_contract() {
587        let mut sink = Vec::new();
588        let writer = ZrecWriter::new(&mut sink, &header()).unwrap();
589        let _ = writer.finish().unwrap();
590        let reader = ZrecReader::new(sink.as_slice()).unwrap();
591        assert_eq!(reader.header(), &header());
592
593        let future = r#"{"zrec":99,"selectors":[],"base":"","captured_at":"x"}"#;
594        let err = ZrecReader::new(future.as_bytes())
595            .err()
596            .unwrap()
597            .to_string();
598        assert!(err.contains("version 99"), "{err}");
599
600        let not_zrec = r#"{"key":"v1/x","value":1}"#;
601        let err = ZrecReader::new(not_zrec.as_bytes())
602            .err()
603            .unwrap()
604            .to_string();
605        assert!(err.contains("header"), "{err}");
606    }
607
608    /// Drop records read back as drops, at their position (O6): the gap is
609    /// part of the record, not a footnote.
610    #[test]
611    fn drops_are_interleaved_facts() {
612        let body = format!(
613            "{}\n{}\n{}\n{}\n",
614            serde_json::to_string(&header()).unwrap(),
615            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
616            r#"{"dropped":7}"#,
617            r#"{"key":"v1/h/state/p/a","t":1000,"bytes":"Ag=="}"#,
618        );
619        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
620        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
621        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Dropped(7)))));
622        assert!(matches!(
623            reader.next(),
624            Some(Ok(ZrecItem::Sample {
625                t_us: Some(1000),
626                ..
627            }))
628        ));
629        assert!(reader.next().is_none());
630    }
631
632    /// A malformed line is an error naming its line number — counted by
633    /// the caller, never a skip.
634    #[test]
635    fn malformed_lines_are_named_not_skipped() {
636        let body = format!(
637            "{}\nnot json\n{}\n",
638            serde_json::to_string(&header()).unwrap(),
639            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
640        );
641        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
642        let err = match reader.next() {
643            Some(Err(e)) => e,
644            other => panic!("expected a named error, got {other:?}"),
645        };
646        assert!(err.starts_with("line 2:"), "{err}");
647        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
648    }
649
650    /// A dry run performs zero puts by construction — there is no session —
651    /// and still counts and classifies every row.
652    #[tokio::test]
653    async fn a_dry_run_lists_and_publishes_nothing() {
654        let body = format!(
655            "{}\n{}\n{}\n{}\n",
656            serde_json::to_string(&header()).unwrap(),
657            r#"{"key":"v1/h-0123456789ab/state/p/health","t":0,"bytes":"eyJvayI6dHJ1ZX0=","encoding":"application/json"}"#,
658            r#"{"dropped":3}"#,
659            r#"{"key":"v1/h-0123456789ab/state/p/health","t":500000,"delete":true}"#,
660        );
661        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
662        let mut would = Vec::new();
663        let report = replay(
664            &mut reader,
665            ReplayTarget::DryRun,
666            1.0,
667            false,
668            "refreshed",
669            |ev| {
670                would.push(format!("{ev:?}"));
671            },
672        )
673        .await
674        .unwrap();
675        assert!(report.dry_run);
676        assert_eq!(report.published, 1);
677        assert_eq!(report.tombstones, 1); // state-shaped: licensed without force
678        assert_eq!(report.capture_dropped, 3);
679        assert_eq!(report.malformed, 0);
680        assert_eq!(would.len(), 3, "{would:?}");
681    }
682
683    /// A recorded delete off the state class keeps its price on replay
684    /// (RFC 04 §1.2 v1.12): refused without `i_know`, counted.
685    #[tokio::test]
686    async fn replayed_tombstones_pass_the_retire_gate() {
687        let body = format!(
688            "{}\n{}\n",
689            serde_json::to_string(&header()).unwrap(),
690            r#"{"key":"v1/h-0123456789ab/telemetry/p/temp","t":0,"delete":true}"#,
691        );
692        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
693        let report = replay(
694            &mut reader,
695            ReplayTarget::DryRun,
696            1.0,
697            false,
698            "refreshed",
699            |_| {},
700        )
701        .await
702        .unwrap();
703        assert_eq!(report.refused, 1);
704        assert_eq!(report.tombstones, 0);
705        assert!(
706            report.first_errors[0].contains("telemetry"),
707            "{:?}",
708            report.first_errors
709        );
710    }
711
712    /// Speed is a positive finite scale, stated rather than clamped.
713    #[tokio::test]
714    async fn speed_must_be_positive() {
715        let body = serde_json::to_string(&header()).unwrap() + "\n";
716        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
717            let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
718            let err = replay(
719                &mut reader,
720                ReplayTarget::DryRun,
721                bad,
722                false,
723                "refreshed",
724                |_| {},
725            )
726            .await
727            .unwrap_err()
728            .to_string();
729            assert!(err.contains("speed"), "{err}");
730        }
731    }
732}