Skip to main content

omni_dev/transcript/
format.rs

1//! Source-agnostic transcript output formats.
2//!
3//! [`Format`] is the user-facing format selector; per-format converters
4//! ([`srt`], [`vtt`], [`txt`], [`json`]) take `&[Cue]` so they can be reused
5//! by any [`TranscriptSource`](crate::transcript::source::TranscriptSource).
6
7use std::fmt;
8use std::str::FromStr;
9
10use crate::transcript::error::{Result, TranscriptError};
11use crate::transcript::source::Transcript;
12
13pub mod json;
14pub mod srt;
15pub mod txt;
16pub mod vtt;
17
18/// Output formats supported by `omni-dev transcript … fetch`.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum Format {
21    /// SubRip (`.srt`) — sequence-numbered cues with `HH:MM:SS,mmm` timecodes.
22    Srt,
23    /// WebVTT (`.vtt`) — `WEBVTT` header followed by cues with
24    /// `HH:MM:SS.mmm` timecodes.
25    Vtt,
26    /// Plain text — cue text only, one cue per line, no timing.
27    Txt,
28    /// JSON — the full [`Transcript`] struct serialised via serde.
29    Json,
30}
31
32impl Format {
33    /// All variants in declaration order. Useful for help output and tests.
34    pub const ALL: &'static [Self] = &[Self::Srt, Self::Vtt, Self::Txt, Self::Json];
35
36    /// Lowercase, file-extension-style identifier (`"srt"`, `"vtt"`,
37    /// `"txt"`, `"json"`).
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Srt => "srt",
41            Self::Vtt => "vtt",
42            Self::Txt => "txt",
43            Self::Json => "json",
44        }
45    }
46
47    /// Render `transcript` to the format's textual representation.
48    ///
49    /// Errors only for [`Format::Json`], which can fail if the transcript
50    /// contains values serde rejects (in practice unreachable for the
51    /// [`Transcript`] type, but the `Result` keeps the API uniform if other
52    /// formats grow fallible behaviour later).
53    pub fn render(self, transcript: &Transcript) -> Result<String> {
54        match self {
55            Self::Srt => Ok(srt::render(&transcript.cues)),
56            Self::Vtt => Ok(vtt::render(&transcript.cues)),
57            Self::Txt => Ok(txt::render(&transcript.cues)),
58            Self::Json => json::render(transcript),
59        }
60    }
61}
62
63impl fmt::Display for Format {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69impl FromStr for Format {
70    type Err = TranscriptError;
71
72    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
73        match s.to_ascii_lowercase().as_str() {
74            "srt" => Ok(Self::Srt),
75            "vtt" => Ok(Self::Vtt),
76            "txt" | "text" | "plain" => Ok(Self::Txt),
77            "json" => Ok(Self::Json),
78            other => Err(TranscriptError::ParseError(format!(
79                "unknown transcript format `{other}`; expected one of: srt, vtt, txt, json"
80            ))),
81        }
82    }
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used, clippy::expect_used)]
87mod tests {
88    use super::*;
89    use crate::transcript::cue::Cue;
90    use crate::transcript::source::TrackKind;
91
92    fn sample_transcript() -> Transcript {
93        Transcript {
94            source: "mock".into(),
95            locator_id: "abc".into(),
96            language: "en".into(),
97            kind: TrackKind::Manual,
98            cues: vec![Cue::new(0, 1000, "hello"), Cue::new(1000, 2500, "world")],
99        }
100    }
101
102    #[test]
103    fn as_str_round_trips_for_canonical_names() {
104        for &f in Format::ALL {
105            let parsed: Format = f.as_str().parse().unwrap();
106            assert_eq!(parsed, f);
107        }
108    }
109
110    #[test]
111    fn from_str_is_case_insensitive() {
112        assert_eq!(Format::from_str("SRT").unwrap(), Format::Srt);
113        assert_eq!(Format::from_str("Vtt").unwrap(), Format::Vtt);
114        assert_eq!(Format::from_str("JSON").unwrap(), Format::Json);
115    }
116
117    #[test]
118    fn from_str_accepts_txt_aliases() {
119        assert_eq!(Format::from_str("txt").unwrap(), Format::Txt);
120        assert_eq!(Format::from_str("text").unwrap(), Format::Txt);
121        assert_eq!(Format::from_str("plain").unwrap(), Format::Txt);
122    }
123
124    #[test]
125    fn from_str_unknown_errors_with_helpful_message() {
126        let err = Format::from_str("yaml").unwrap_err();
127        let msg = err.to_string();
128        assert!(msg.contains("yaml"));
129        assert!(msg.contains("srt"));
130        assert!(msg.contains("vtt"));
131        assert!(msg.contains("txt"));
132        assert!(msg.contains("json"));
133    }
134
135    #[test]
136    fn display_matches_as_str() {
137        for &f in Format::ALL {
138            assert_eq!(format!("{f}"), f.as_str());
139        }
140    }
141
142    #[test]
143    fn render_dispatches_to_srt() {
144        let out = Format::Srt.render(&sample_transcript()).unwrap();
145        assert!(out.contains("00:00:00,000 --> 00:00:01,000"));
146        assert!(out.contains("hello"));
147    }
148
149    #[test]
150    fn render_dispatches_to_vtt() {
151        let out = Format::Vtt.render(&sample_transcript()).unwrap();
152        assert!(out.starts_with("WEBVTT"));
153        assert!(out.contains("00:00:00.000 --> 00:00:01.000"));
154    }
155
156    #[test]
157    fn render_dispatches_to_txt() {
158        let out = Format::Txt.render(&sample_transcript()).unwrap();
159        assert_eq!(out, "hello\nworld\n");
160    }
161
162    #[test]
163    fn render_dispatches_to_json() {
164        let out = Format::Json.render(&sample_transcript()).unwrap();
165        let value: serde_json::Value = serde_json::from_str(&out).unwrap();
166        assert_eq!(value["language"], "en");
167        assert_eq!(value["cues"][0]["text"], "hello");
168    }
169
170    #[test]
171    fn all_constant_lists_each_variant_once() {
172        assert_eq!(Format::ALL.len(), 4);
173        let mut copy: Vec<Format> = Format::ALL.to_vec();
174        copy.sort_by_key(|f| f.as_str());
175        copy.dedup();
176        assert_eq!(copy.len(), 4);
177    }
178}