Skip to main content

tear_types/
cast.rs

1//! Typed parser for asciinema v2 .cast rows.
2//!
3//! The asciinema v2 spec is JSON-lines: a single JSON-object header
4//! followed by N JSON-array event rows of shape `[t_seconds, kind,
5//! payload]`. `kind` is `"o"` (output written to stdout) or `"i"`
6//! (input typed by the user). This module lifts row parsing into a
7//! typed [`CastRow`] so every consumer of asciinema captures (`tear
8//! replay`, future TUI scrubbers, web playback bridges) shares one
9//! representation.
10//!
11//! Errors are intentionally non-fatal — malformed rows are surfaced
12//! as `Err(CastParseError::*)` so callers can choose to skip vs
13//! abort. `tear replay` skips silently to match `asciinema play`'s
14//! resilience.
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19/// What the row represents in the underlying terminal stream.
20#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum CastRowKind {
22    /// `"o"` — bytes the terminal emitted to stdout. Replay players
23    /// re-emit these to recreate the visual.
24    Output,
25    /// `"i"` — bytes the user typed. Replay players IGNORE these
26    /// because the recorded output already reflects the side
27    /// effects.
28    Input,
29}
30
31impl CastRowKind {
32    /// The single-character on-the-wire tag.
33    #[must_use]
34    pub fn as_str(self) -> &'static str {
35        match self {
36            CastRowKind::Output => "o",
37            CastRowKind::Input => "i",
38        }
39    }
40}
41
42/// One row of an asciinema v2 .cast.
43#[derive(Clone, Debug, PartialEq)]
44pub struct CastRow {
45    /// Wall-clock-seconds since the recording began.
46    pub t: f64,
47    pub kind: CastRowKind,
48    /// Raw bytes written/typed at `t`. Stored as `String` because
49    /// the asciinema v2 wire is JSON strings; callers that need to
50    /// re-emit invoke `.as_bytes()`.
51    pub payload: String,
52}
53
54#[derive(Debug, Error)]
55pub enum CastParseError {
56    #[error("row is not valid json: {0}")]
57    InvalidJson(String),
58    #[error("row is not a JSON array")]
59    NotAnArray,
60    #[error("row has {0} elements, expected at least 3")]
61    TooFewElements(usize),
62    #[error("row[0] (time) is not a number")]
63    BadTime,
64    #[error("row[1] (kind) is not a string")]
65    BadKindShape,
66    #[error("row[1] kind `{0}` is not 'o' or 'i'")]
67    UnknownKind(String),
68    #[error("row[2] (payload) is not a string")]
69    BadPayload,
70    #[error("row is the asciinema header (JSON object, not array)")]
71    HeaderRow,
72}
73
74impl CastRow {
75    /// Parse one JSON-encoded line. Returns `Err(HeaderRow)` for
76    /// lines that look like the asciinema header (JSON object); the
77    /// caller decides whether to skip silently or surface.
78    pub fn parse(line: &str) -> Result<Self, CastParseError> {
79        let trimmed = line.trim_start();
80        if trimmed.starts_with('{') {
81            return Err(CastParseError::HeaderRow);
82        }
83        let v: serde_json::Value = serde_json::from_str(line)
84            .map_err(|e| CastParseError::InvalidJson(e.to_string()))?;
85        let arr = v.as_array().ok_or(CastParseError::NotAnArray)?;
86        if arr.len() < 3 {
87            return Err(CastParseError::TooFewElements(arr.len()));
88        }
89        let t = arr[0].as_f64().ok_or(CastParseError::BadTime)?;
90        let kind_str = arr[1].as_str().ok_or(CastParseError::BadKindShape)?;
91        let kind = match kind_str {
92            "o" => CastRowKind::Output,
93            "i" => CastRowKind::Input,
94            other => return Err(CastParseError::UnknownKind(other.into())),
95        };
96        let payload = arr[2]
97            .as_str()
98            .ok_or(CastParseError::BadPayload)?
99            .to_string();
100        Ok(CastRow { t, kind, payload })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn parses_output_row() {
110        let r = CastRow::parse(r#"[0.5,"o","hi"]"#).unwrap();
111        assert_eq!(r.t, 0.5);
112        assert_eq!(r.kind, CastRowKind::Output);
113        assert_eq!(r.payload, "hi");
114    }
115
116    #[test]
117    fn parses_input_row() {
118        let r = CastRow::parse(r#"[1.0,"i","x"]"#).unwrap();
119        assert_eq!(r.kind, CastRowKind::Input);
120    }
121
122    #[test]
123    fn header_row_returns_specific_error() {
124        let err = CastRow::parse(r#"{"version":2}"#).unwrap_err();
125        assert!(matches!(err, CastParseError::HeaderRow));
126    }
127
128    #[test]
129    fn malformed_json_returns_invalid_json() {
130        let err = CastRow::parse("not json").unwrap_err();
131        assert!(matches!(err, CastParseError::InvalidJson(_)));
132    }
133
134    #[test]
135    fn too_few_elements_returns_specific_error() {
136        let err = CastRow::parse(r#"[0.0, "o"]"#).unwrap_err();
137        assert!(matches!(err, CastParseError::TooFewElements(2)));
138    }
139
140    #[test]
141    fn unknown_kind_surfaces_value() {
142        let err = CastRow::parse(r#"[0.0,"r","payload"]"#).unwrap_err();
143        match err {
144            CastParseError::UnknownKind(s) => assert_eq!(s, "r"),
145            other => panic!("expected UnknownKind, got {other:?}"),
146        }
147    }
148
149    #[test]
150    fn bad_time_when_string_in_time_slot() {
151        let err = CastRow::parse(r#"["zero","o","x"]"#).unwrap_err();
152        assert!(matches!(err, CastParseError::BadTime));
153    }
154
155    #[test]
156    fn bad_payload_when_array_in_payload_slot() {
157        let err = CastRow::parse(r#"[0.0,"o",[1,2,3]]"#).unwrap_err();
158        assert!(matches!(err, CastParseError::BadPayload));
159    }
160
161    #[test]
162    fn kind_label_round_trip() {
163        assert_eq!(CastRowKind::Output.as_str(), "o");
164        assert_eq!(CastRowKind::Input.as_str(), "i");
165    }
166
167    #[test]
168    fn not_an_array_when_top_level_is_string() {
169        let err = CastRow::parse(r#""hi""#).unwrap_err();
170        assert!(matches!(err, CastParseError::NotAnArray));
171    }
172}