Skip to main content

aurum_core/output/
mod.rs

1//! Output formatters: txt, srt, json — and secure file commit transactions.
2//!
3//! Formatters stream into any [`io::Write`] with a byte budget (JOE-1604).
4
5pub mod transaction;
6
7use crate::error::{Result, UserError};
8#[cfg(test)]
9use crate::providers::Segment;
10use crate::providers::TranscriptionResult;
11use std::io::{self, Write};
12use std::path::Path;
13
14pub use transaction::{commit_text, CommitMode, OutputTransaction, SymlinkPolicy};
15
16/// Default maximum serialized output size (32 MiB).
17pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
18
19/// Supported output formats.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum OutputFormat {
22    Txt,
23    Srt,
24    Json,
25}
26
27impl OutputFormat {
28    pub fn parse(s: &str) -> Result<Self> {
29        match s.to_ascii_lowercase().as_str() {
30            "txt" | "text" => Ok(Self::Txt),
31            "srt" => Ok(Self::Srt),
32            "json" => Ok(Self::Json),
33            other => Err(UserError::InvalidOutputFormat {
34                format: other.to_string(),
35            }
36            .into()),
37        }
38    }
39
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Txt => "txt",
43            Self::Srt => "srt",
44            Self::Json => "json",
45        }
46    }
47
48    pub fn default_extension(self) -> &'static str {
49        self.as_str()
50    }
51}
52
53/// Writer that tracks bytes written and enforces a hard budget.
54struct BudgetWriter<'a, W: Write> {
55    inner: &'a mut W,
56    written: usize,
57    max: usize,
58}
59
60impl<'a, W: Write> BudgetWriter<'a, W> {
61    fn new(inner: &'a mut W, max: usize) -> Self {
62        Self {
63            inner,
64            written: 0,
65            max: max.max(1),
66        }
67    }
68}
69
70impl<W: Write> Write for BudgetWriter<'_, W> {
71    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
72        if self.written.saturating_add(buf.len()) > self.max {
73            return Err(io::Error::other(format!(
74                "output exceeds maximum of {} bytes (would write {})",
75                self.max,
76                self.written.saturating_add(buf.len())
77            )));
78        }
79        let n = self.inner.write(buf)?;
80        self.written = self.written.saturating_add(n);
81        Ok(n)
82    }
83
84    fn flush(&mut self) -> io::Result<()> {
85        self.inner.flush()
86    }
87}
88
89/// Format a transcription result into the requested representation.
90///
91/// Implemented on top of the streaming writer with the same limit policy.
92pub fn format_result(result: &TranscriptionResult, format: OutputFormat) -> Result<String> {
93    format_result_with_limit(result, format, DEFAULT_MAX_OUTPUT_BYTES)
94}
95
96/// Like [`format_result`] with an explicit byte budget.
97pub fn format_result_with_limit(
98    result: &TranscriptionResult,
99    format: OutputFormat,
100    max_bytes: usize,
101) -> Result<String> {
102    let mut buf = Vec::new();
103    write_result_with_limit(result, format, &mut buf, max_bytes)?;
104    // Strip trailing newline added by write for convenience parity with historical
105    // format_result (txt had no trailing newline; srt/json vary). We keep the
106    // streaming write's trailing newline only for write paths; format_result
107    // trims a single trailing `\n` when present for txt/json compatibility.
108    match format {
109        OutputFormat::Txt => {
110            if buf.last() == Some(&b'\n') {
111                buf.pop();
112            }
113            String::from_utf8(buf).map_err(|e| {
114                crate::error::TranscriptionError::internal(format!("utf-8 output: {e}"))
115            })
116        }
117        OutputFormat::Srt | OutputFormat::Json => {
118            // Keep content; ensure no extra double-newline from write helper.
119            if buf.last() == Some(&b'\n') && format == OutputFormat::Json {
120                // pretty json from serde doesn't need our trailing NL for string form
121                // but tests compare parseable JSON — trailing NL is fine for parse.
122            }
123            String::from_utf8(buf).map_err(|e| {
124                crate::error::TranscriptionError::internal(format!("utf-8 output: {e}"))
125            })
126        }
127    }
128}
129
130/// Write formatted output to a writer (streaming; no intermediate full String for SRT cues).
131pub fn write_result<W: Write>(
132    result: &TranscriptionResult,
133    format: OutputFormat,
134    writer: W,
135) -> Result<()> {
136    write_result_with_limit(result, format, writer, DEFAULT_MAX_OUTPUT_BYTES)
137}
138
139/// Stream format into `writer` with a maximum output size.
140pub fn write_result_with_limit<W: Write>(
141    result: &TranscriptionResult,
142    format: OutputFormat,
143    mut writer: W,
144    max_bytes: usize,
145) -> Result<()> {
146    let mut budget = BudgetWriter::new(&mut writer, max_bytes);
147    match format {
148        OutputFormat::Txt => write_txt(result, &mut budget),
149        OutputFormat::Srt => write_srt(result, &mut budget),
150        OutputFormat::Json => write_json(result, &mut budget),
151    }
152    .map_err(map_write_err)?;
153    // Writers always terminate with a newline (txt/json) or cue newlines (srt).
154    let _ = budget.written;
155    Ok(())
156}
157
158fn map_write_err(e: io::Error) -> crate::error::TranscriptionError {
159    let msg = e.to_string();
160    if msg.contains("output exceeds maximum") {
161        return UserError::Other {
162            message: format!(
163                "output too large: {msg}\n  Hint: raise the output byte budget or split the transcript."
164            ),
165        }
166        .into();
167    }
168    // Broken pipe → user/environment, not internal panic.
169    if e.kind() == io::ErrorKind::BrokenPipe {
170        return crate::error::EnvironmentError::Other {
171            message: "broken pipe while writing output".into(),
172        }
173        .into();
174    }
175    crate::error::EnvironmentError::Io(e).into()
176}
177
178/// Format and commit a transcription result through the shared output transaction.
179pub fn write_result_to_path(
180    result: &TranscriptionResult,
181    format: OutputFormat,
182    path: &Path,
183    mode: CommitMode,
184) -> Result<()> {
185    write_result_to_path_with_limit(result, format, path, mode, DEFAULT_MAX_OUTPUT_BYTES)
186}
187
188/// Like [`write_result_to_path`] with an explicit byte budget.
189pub fn write_result_to_path_with_limit(
190    result: &TranscriptionResult,
191    format: OutputFormat,
192    path: &Path,
193    mode: CommitMode,
194    max_bytes: usize,
195) -> Result<()> {
196    OutputTransaction::new(path, mode).commit_with(|tmp| {
197        let mut file = std::fs::OpenOptions::new()
198            .write(true)
199            .open(tmp)
200            .map_err(crate::error::EnvironmentError::Io)?;
201        write_result_with_limit(result, format, &mut file, max_bytes)?;
202        file.flush().map_err(crate::error::EnvironmentError::Io)?;
203        file.sync_all()
204            .map_err(crate::error::EnvironmentError::Io)?;
205        Ok(())
206    })
207}
208
209fn write_txt<W: Write>(result: &TranscriptionResult, w: &mut W) -> io::Result<()> {
210    let text = result.text.trim();
211    w.write_all(text.as_bytes())?;
212    w.write_all(b"\n")?;
213    Ok(())
214}
215
216fn write_srt<W: Write>(result: &TranscriptionResult, w: &mut W) -> io::Result<()> {
217    if result.segments.is_empty() {
218        if result.text.trim().is_empty() {
219            return Ok(());
220        }
221        let end = if result.duration_secs > 0.0 {
222            result.duration_secs
223        } else {
224            1.0
225        };
226        if !end.is_finite() || result.duration_secs < 0.0 {
227            return Err(io::Error::new(
228                io::ErrorKind::InvalidData,
229                "invalid transcript duration for SRT",
230            )); // InvalidData is intentional (not Other)
231        }
232        write!(
233            w,
234            "1\n{} --> {}\n{}\n",
235            format_ts(0.0),
236            format_ts(end),
237            result.text.trim().replace(['\r', '\n'], " ")
238        )?;
239        return Ok(());
240    }
241
242    let mut cue = 1usize;
243    for seg in &result.segments {
244        if !seg.start.is_finite() || !seg.end.is_finite() || seg.end < seg.start {
245            return Err(io::Error::new(
246                io::ErrorKind::InvalidData,
247                format!(
248                    "invalid SRT timestamps at cue {cue}: start={} end={}",
249                    seg.start, seg.end
250                ),
251            ));
252        }
253        let text = seg.text.trim();
254        if text.is_empty() {
255            continue;
256        }
257        let text = text.replace(['\r', '\n'], " ");
258        write!(
259            w,
260            "{}\n{} --> {}\n{}\n\n",
261            cue,
262            format_ts(seg.start),
263            format_ts(seg.end),
264            text
265        )?;
266        cue += 1;
267    }
268    Ok(())
269}
270
271/// SRT timestamp: HH:MM:SS,mmm
272fn format_ts(secs: f64) -> String {
273    let total_ms = (secs.max(0.0) * 1000.0).round() as u64;
274    let ms = total_ms % 1000;
275    let total_secs = total_ms / 1000;
276    let s = total_secs % 60;
277    let total_mins = total_secs / 60;
278    let m = total_mins % 60;
279    let h = total_mins / 60;
280    format!("{h:02}:{m:02}:{s:02},{ms:03}")
281}
282
283fn write_json<W: Write>(result: &TranscriptionResult, w: &mut W) -> io::Result<()> {
284    // Versioned external DTO (JOE-1614) — single contract for CLI/embed JSON.
285    let payload = crate::dto::SttResultDto::from_result(result);
286    serde_json::to_writer_pretty(&mut *w, &payload).map_err(io::Error::other)?;
287    w.write_all(b"\n")?;
288    Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn sample_result() -> TranscriptionResult {
296        TranscriptionResult::local(
297            "Hello world. This is a test.".into(),
298            vec![
299                Segment {
300                    start: 0.0,
301                    end: 1.2,
302                    text: "Hello world.".into(),
303                },
304                Segment {
305                    start: 1.4,
306                    end: 3.5,
307                    text: "This is a test.".into(),
308                },
309            ],
310            Some("en".into()),
311            "base".into(),
312            3.5,
313        )
314    }
315
316    #[test]
317    fn txt_is_plain() {
318        let s = format_result(&sample_result(), OutputFormat::Txt).unwrap();
319        assert_eq!(s, "Hello world. This is a test.");
320    }
321
322    #[test]
323    fn srt_has_cues() {
324        let s = format_result(&sample_result(), OutputFormat::Srt).unwrap();
325        assert!(s.contains("1\n"));
326        assert!(s.contains("00:00:00,000 --> 00:00:01,200"));
327        assert!(s.contains("Hello world."));
328        assert!(s.contains("2\n"));
329        assert!(s.contains("This is a test."));
330    }
331
332    #[test]
333    fn json_roundtrip_fields() {
334        let s = format_result(&sample_result(), OutputFormat::Json).unwrap();
335        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
336        assert_eq!(v["text"], "Hello world. This is a test.");
337        assert_eq!(v["model"], "base");
338        assert_eq!(v["provider"], "local");
339        assert_eq!(v["language"], "en");
340        assert_eq!(v["timestamps_reliable"], true);
341        assert_eq!(v["backend_kind"], "asr");
342        assert_eq!(v["cleanup_style"], "raw");
343        assert_eq!(v["schema_version"], 1);
344        assert!(v.get("cleanup_provider").is_none() || v["cleanup_provider"].is_null());
345        assert!(v.get("original_text").is_none() || v["original_text"].is_null());
346        assert!(v["segments"].as_array().unwrap().len() == 2);
347    }
348
349    #[test]
350    fn json_includes_cleanup_metadata() {
351        let mut r = sample_result();
352        r.cleanup_style = crate::cleanup::CleanupStyle::Clean;
353        r.cleanup_provider = Some(crate::cleanup::CleanupProviderKind::Rules);
354        r.original_text = Some("um hello".into());
355        r.text = "Hello.".into();
356        let s = format_result(&r, OutputFormat::Json).unwrap();
357        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
358        assert_eq!(v["cleanup_style"], "clean");
359        assert_eq!(v["cleanup_provider"], "rules");
360        assert_eq!(v["original_text"], "um hello");
361        assert_eq!(v["text"], "Hello.");
362    }
363
364    #[test]
365    fn parse_formats() {
366        assert_eq!(OutputFormat::parse("txt").unwrap(), OutputFormat::Txt);
367        assert_eq!(OutputFormat::parse("SRT").unwrap(), OutputFormat::Srt);
368        assert_eq!(OutputFormat::parse("json").unwrap(), OutputFormat::Json);
369        assert!(OutputFormat::parse("docx").is_err());
370    }
371
372    #[test]
373    fn format_ts_values() {
374        assert_eq!(format_ts(0.0), "00:00:00,000");
375        assert_eq!(format_ts(61.5), "00:01:01,500");
376        assert_eq!(format_ts(3661.001), "01:01:01,001");
377    }
378
379    #[test]
380    fn stream_matches_format_for_srt() {
381        let r = sample_result();
382        let mut stream = Vec::new();
383        write_result(&r, OutputFormat::Srt, &mut stream).unwrap();
384        let via_format = format_result(&r, OutputFormat::Srt).unwrap();
385        // format_result may or may not strip; both should contain same cues.
386        let stream_s = String::from_utf8(stream).unwrap();
387        assert!(stream_s.contains("Hello world."));
388        assert!(via_format.contains("Hello world."));
389        assert_eq!(
390            stream_s.replace("\r\n", "\n").trim(),
391            via_format.replace("\r\n", "\n").trim()
392        );
393    }
394
395    #[test]
396    fn budget_rejects_large_output() {
397        let r = sample_result();
398        let err = format_result_with_limit(&r, OutputFormat::Json, 32).unwrap_err();
399        assert!(err.to_string().contains("too large") || err.to_string().contains("maximum"));
400    }
401
402    #[test]
403    fn invalid_srt_timestamps_rejected() {
404        let mut r = sample_result();
405        r.segments[0].end = f64::NAN;
406        let err = write_result(&r, OutputFormat::Srt, Vec::new()).unwrap_err();
407        assert!(err.to_string().contains("timestamp") || err.to_string().contains("invalid"));
408    }
409
410    #[test]
411    fn large_segment_set_streams() {
412        let segs: Vec<Segment> = (0..5000)
413            .map(|i| Segment {
414                start: i as f64,
415                end: i as f64 + 0.5,
416                text: format!("cue {i}"),
417            })
418            .collect();
419        let r = TranscriptionResult::local(
420            "many".into(),
421            segs,
422            Some("en".into()),
423            "base".into(),
424            5000.0,
425        );
426        let mut out = Vec::new();
427        write_result_with_limit(&r, OutputFormat::Srt, &mut out, 8 * 1024 * 1024).unwrap();
428        assert!(out.len() > 10_000);
429        assert!(out
430            .windows(5)
431            .any(|w| w == b"cue 0" || w.starts_with(b"cue ")));
432    }
433}