Skip to main content

transcribe_cpp/
error.rs

1//! Error type mapped from `transcribe_status`.
2//!
3//! The C API reports failures as a `transcribe_status` int; this module maps
4//! each one to a distinct [`Error`] variant (requirements §3: "distinct
5//! failures stay distinct"). The grouping mirrors the Python binding's
6//! exception hierarchy — the conformance precedent — and the raw status is
7//! preserved via [`Error::raw_status`].
8
9use crate::result::Transcript;
10use transcribe_cpp_sys as sys;
11
12/// Convenience alias for results from this crate.
13pub type Result<T> = std::result::Result<T, Error>;
14
15/// Everything that can go wrong talking to the native library.
16///
17/// Match on the variant for typed handling; [`Error::raw_status`] recovers the
18/// underlying `transcribe_status` code (0 for errors raised purely on the Rust
19/// side, e.g. the load-time version gate or a `NUL` in a caller string).
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum Error {
23    /// `TRANSCRIBE_ERR_INVALID_ARG` (or `_SAMPLE_RATE`).
24    #[error("invalid argument: {0}")]
25    InvalidArgument(String),
26    /// `TRANSCRIBE_ERR_NOT_IMPLEMENTED` — the model has no path for this call.
27    #[error("not implemented by this model: {0}")]
28    NotImplemented(String),
29    /// `TRANSCRIBE_ERR_FILE_NOT_FOUND` — the GGUF path does not exist.
30    #[error("model file not found: {0}")]
31    ModelFileNotFound(String),
32    /// `TRANSCRIBE_ERR_GGUF` / `_UNSUPPORTED_ARCH` / `_UNSUPPORTED_VARIANT`.
33    #[error("model load failed: {0}")]
34    ModelLoad(String),
35    /// `TRANSCRIBE_ERR_OOM`.
36    #[error("out of memory: {0}")]
37    OutOfMemory(String),
38    /// `TRANSCRIBE_ERR_BACKEND` — the requested backend could not be satisfied.
39    #[error("backend error: {0}")]
40    Backend(String),
41    /// `TRANSCRIBE_ERR_UNSUPPORTED_{TASK,LANGUAGE,TIMESTAMPS,PNC,ITN}` — the
42    /// model cannot satisfy this request shape.
43    #[error("unsupported request: {0}")]
44    Unsupported(String),
45    /// `TRANSCRIBE_ERR_BAD_STRUCT_SIZE` — an ABI/struct-size fault. With the
46    /// generated FFI this indicates a header/library skew, not caller error.
47    #[error("ABI struct-size mismatch: {0}")]
48    BadStructSize(String),
49    /// `TRANSCRIBE_ERR_INPUT_TOO_LONG` — audio longer than the model can decode.
50    #[error("input too long: {0}")]
51    InputTooLong(String),
52    /// `TRANSCRIBE_ERR_ABORTED` — the abort callback returned true. The
53    /// transcript of chunks that completed before the abort is preserved.
54    #[error("operation aborted: {message}")]
55    Aborted {
56        message: String,
57        /// Partial transcript from chunks that completed before the abort.
58        partial: Option<Box<Transcript>>,
59    },
60    /// `TRANSCRIBE_ERR_OUTPUT_TRUNCATED` — the decode hit the generation budget
61    /// before end-of-stream; the transcript is incomplete by contract. The
62    /// partial transcript is always preserved.
63    #[error("output truncated before end-of-stream: {message}")]
64    OutputTruncated {
65        message: String,
66        /// The (incomplete) transcript produced before truncation.
67        partial: Option<Box<Transcript>>,
68    },
69    /// `TRANSCRIBE_ERR_OUTPUT_REPETITION` — the decode was stopped because the
70    /// output began repeating itself; the transcript is incomplete by contract.
71    /// The partial transcript, with the repeats dropped, is always preserved.
72    #[error("output began repeating before end-of-stream: {message}")]
73    OutputRepetition {
74        message: String,
75        /// The (incomplete) transcript produced before the loop, one copy kept.
76        partial: Option<Box<Transcript>>,
77    },
78    /// `TRANSCRIBE_ERR_UNSUPPORTED_ROLE` — see [`Model::roles`](crate::Model::roles).
79    #[error("unsupported role: {0}")]
80    UnsupportedRole(String),
81    /// `TRANSCRIBE_ERR_INPUT_TOO_SHORT` — the audio is shorter than the role's
82    /// minimum (e.g. [`LangIdInfo::min_audio_ms`](crate::LangIdInfo)).
83    #[error("input too short: {0}")]
84    InputTooShort(String),
85    /// The loaded library's base version disagrees with the headers this crate
86    /// was generated against (the pre-1.0 version lock). Raised on first use.
87    #[error("native library version mismatch: {0}")]
88    VersionMismatch(String),
89    /// A caller-supplied string contained an interior NUL byte.
90    #[error("string contains an interior NUL byte: {0}")]
91    Nul(#[from] std::ffi::NulError),
92    /// Another session of the same model already has a compute operation in
93    /// flight. The C library serializes compute per model (the 0.x limitation):
94    /// at most one run / batch / active stream across ALL of a model's sessions
95    /// at a time. Finish or drop the in-flight stream (one-shot runs/batches
96    /// just queue) before starting another, or give each concurrent worker its
97    /// own model. Raised purely on the Rust side, so [`Error::raw_status`] is 0.
98    #[error("model busy: {0}")]
99    Busy(String),
100    /// A non-OK status with no more specific mapping.
101    #[error("{0}")]
102    Other(String),
103}
104
105/// The category of an [`Error`], without its payload. One kind per variant.
106///
107/// Stable for matching and, under the `serde` feature, serialized as a
108/// snake_case string (`"aborted"`, `"model_file_not_found"`, ...). A kind
109/// this version does not know deserializes as [`ErrorKind::Other`].
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111#[non_exhaustive]
112#[cfg_attr(
113    feature = "serde",
114    derive(serde::Serialize, serde::Deserialize),
115    serde(rename_all = "snake_case")
116)]
117pub enum ErrorKind {
118    InvalidArgument,
119    NotImplemented,
120    ModelFileNotFound,
121    ModelLoad,
122    OutOfMemory,
123    Backend,
124    Unsupported,
125    BadStructSize,
126    InputTooLong,
127    Aborted,
128    OutputTruncated,
129    OutputRepetition,
130    UnsupportedRole,
131    VersionMismatch,
132    Nul,
133    Busy,
134    InputTooShort,
135    #[cfg_attr(feature = "serde", serde(other))]
136    Other,
137}
138
139/// An owned, plain-data snapshot of an [`Error`]: what crosses a process or
140/// IPC boundary (e.g. a Tauri command's error).
141///
142/// [`Error`] itself serializes to exactly this shape under the `serde`
143/// feature; deserialize into `ErrorReport` on the receiving side (an
144/// [`Error`] cannot be rebuilt from it). JSON looks like:
145///
146/// ```json
147/// { "kind": "aborted", "message": "operation aborted: ...", "status": 13, "partial": { "text": "..." } }
148/// ```
149///
150/// `partial` is `null` unless the error carried a partial transcript. It is
151/// always written (never skipped) so non-self-describing formats such as
152/// postcard round-trip.
153#[derive(Debug, Clone, PartialEq)]
154#[non_exhaustive]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub struct ErrorReport {
157    /// The error category.
158    pub kind: ErrorKind,
159    /// The error's `Display` text.
160    pub message: String,
161    /// [`Error::raw_status`]: the `transcribe_status`, or 0 for Rust-side errors.
162    #[cfg_attr(feature = "serde", serde(default))]
163    pub status: i32,
164    /// [`Error::partial`], if any.
165    #[cfg_attr(feature = "serde", serde(default))]
166    pub partial: Option<Transcript>,
167}
168
169impl From<&Error> for ErrorReport {
170    fn from(err: &Error) -> Self {
171        ErrorReport {
172            kind: err.kind(),
173            message: err.to_string(),
174            status: err.raw_status(),
175            partial: err.partial().cloned(),
176        }
177    }
178}
179
180/// Moves the partial transcript out instead of cloning it.
181impl From<Error> for ErrorReport {
182    fn from(err: Error) -> Self {
183        let (kind, message, status) = (err.kind(), err.to_string(), err.raw_status());
184        let partial = match err {
185            Error::Aborted { partial, .. }
186            | Error::OutputTruncated { partial, .. }
187            | Error::OutputRepetition { partial, .. } => partial.map(|p| *p),
188            _ => None,
189        };
190        ErrorReport {
191            kind,
192            message,
193            status,
194            partial,
195        }
196    }
197}
198
199/// Serializes as an [`ErrorReport`] (see there for the shape), without
200/// cloning the partial transcript.
201#[cfg(feature = "serde")]
202impl serde::Serialize for Error {
203    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
204        #[derive(serde::Serialize)]
205        struct Report<'a> {
206            kind: ErrorKind,
207            message: String,
208            status: i32,
209            partial: Option<&'a Transcript>,
210        }
211        Report {
212            kind: self.kind(),
213            message: self.to_string(),
214            status: self.raw_status(),
215            partial: self.partial(),
216        }
217        .serialize(s)
218    }
219}
220
221impl Error {
222    /// This error's [`ErrorKind`].
223    pub fn kind(&self) -> ErrorKind {
224        // Exhaustive on purpose: a new variant must pick a kind to compile.
225        match self {
226            Error::InvalidArgument(_) => ErrorKind::InvalidArgument,
227            Error::NotImplemented(_) => ErrorKind::NotImplemented,
228            Error::ModelFileNotFound(_) => ErrorKind::ModelFileNotFound,
229            Error::ModelLoad(_) => ErrorKind::ModelLoad,
230            Error::OutOfMemory(_) => ErrorKind::OutOfMemory,
231            Error::Backend(_) => ErrorKind::Backend,
232            Error::Unsupported(_) => ErrorKind::Unsupported,
233            Error::BadStructSize(_) => ErrorKind::BadStructSize,
234            Error::InputTooLong(_) => ErrorKind::InputTooLong,
235            Error::Aborted { .. } => ErrorKind::Aborted,
236            Error::OutputTruncated { .. } => ErrorKind::OutputTruncated,
237            Error::OutputRepetition { .. } => ErrorKind::OutputRepetition,
238            Error::UnsupportedRole(_) => ErrorKind::UnsupportedRole,
239            Error::InputTooShort(_) => ErrorKind::InputTooShort,
240            Error::VersionMismatch(_) => ErrorKind::VersionMismatch,
241            Error::Nul(_) => ErrorKind::Nul,
242            Error::Busy(_) => ErrorKind::Busy,
243            Error::Other(_) => ErrorKind::Other,
244        }
245    }
246
247    /// The `transcribe_status` this error was mapped from, or `0` for errors
248    /// raised on the Rust side (version gate, NUL in a string, …).
249    pub fn raw_status(&self) -> i32 {
250        use sys::transcribe_status as S;
251        let s = match self {
252            Error::InvalidArgument(_) => S::TRANSCRIBE_ERR_INVALID_ARG,
253            Error::NotImplemented(_) => S::TRANSCRIBE_ERR_NOT_IMPLEMENTED,
254            Error::ModelFileNotFound(_) => S::TRANSCRIBE_ERR_FILE_NOT_FOUND,
255            Error::ModelLoad(_) => S::TRANSCRIBE_ERR_GGUF,
256            Error::OutOfMemory(_) => S::TRANSCRIBE_ERR_OOM,
257            Error::Backend(_) => S::TRANSCRIBE_ERR_BACKEND,
258            Error::Unsupported(_) => S::TRANSCRIBE_ERR_UNSUPPORTED_TASK,
259            Error::BadStructSize(_) => S::TRANSCRIBE_ERR_BAD_STRUCT_SIZE,
260            Error::InputTooLong(_) => S::TRANSCRIBE_ERR_INPUT_TOO_LONG,
261            Error::Aborted { .. } => S::TRANSCRIBE_ERR_ABORTED,
262            Error::OutputTruncated { .. } => S::TRANSCRIBE_ERR_OUTPUT_TRUNCATED,
263            Error::OutputRepetition { .. } => S::TRANSCRIBE_ERR_OUTPUT_REPETITION,
264            Error::UnsupportedRole(_) => S::TRANSCRIBE_ERR_UNSUPPORTED_ROLE,
265            Error::InputTooShort(_) => S::TRANSCRIBE_ERR_INPUT_TOO_SHORT,
266            _ => S::TRANSCRIBE_OK,
267        };
268        s.0 as i32
269    }
270
271    /// The partial transcript carried by [`Error::Aborted`] /
272    /// [`Error::OutputTruncated`] / [`Error::OutputRepetition`], if any. `None`
273    /// for every other variant.
274    pub fn partial(&self) -> Option<&Transcript> {
275        match self {
276            Error::Aborted { partial, .. }
277            | Error::OutputTruncated { partial, .. }
278            | Error::OutputRepetition { partial, .. } => partial.as_deref(),
279            _ => None,
280        }
281    }
282}
283
284/// Human-readable text for a `transcribe_status` (from the C side).
285pub(crate) fn status_string(status: i32) -> String {
286    // Borrowed pointer into static storage; copy at the boundary.
287    let ptr = unsafe { sys::transcribe_status_string(status) };
288    if ptr.is_null() {
289        return format!("status {status}");
290    }
291    unsafe { std::ffi::CStr::from_ptr(ptr) }
292        .to_string_lossy()
293        .into_owned()
294}
295
296/// Build the mapped error for a non-OK status (without a partial transcript).
297/// The run path re-attaches partials for the two result-bearing statuses.
298pub(crate) fn error_for_status(status: sys::transcribe_status, context: &str) -> Error {
299    use sys::transcribe_status as S;
300    let code = status.0 as i32;
301    let msg = {
302        let s = status_string(code);
303        if context.is_empty() {
304            format!("{s} (status {code})")
305        } else {
306            format!("{context}: {s} (status {code})")
307        }
308    };
309    match status {
310        S::TRANSCRIBE_ERR_INVALID_ARG | S::TRANSCRIBE_ERR_SAMPLE_RATE => {
311            Error::InvalidArgument(msg)
312        }
313        S::TRANSCRIBE_ERR_NOT_IMPLEMENTED => Error::NotImplemented(msg),
314        S::TRANSCRIBE_ERR_FILE_NOT_FOUND => Error::ModelFileNotFound(msg),
315        S::TRANSCRIBE_ERR_GGUF
316        | S::TRANSCRIBE_ERR_UNSUPPORTED_ARCH
317        | S::TRANSCRIBE_ERR_UNSUPPORTED_VARIANT => Error::ModelLoad(msg),
318        S::TRANSCRIBE_ERR_OOM => Error::OutOfMemory(msg),
319        S::TRANSCRIBE_ERR_BACKEND => Error::Backend(msg),
320        S::TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE
321        | S::TRANSCRIBE_ERR_UNSUPPORTED_TASK
322        | S::TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS
323        | S::TRANSCRIBE_ERR_UNSUPPORTED_PNC
324        | S::TRANSCRIBE_ERR_UNSUPPORTED_ITN => Error::Unsupported(msg),
325        S::TRANSCRIBE_ERR_BAD_STRUCT_SIZE => Error::BadStructSize(msg),
326        S::TRANSCRIBE_ERR_INPUT_TOO_LONG => Error::InputTooLong(msg),
327        S::TRANSCRIBE_ERR_ABORTED => Error::Aborted {
328            message: msg,
329            partial: None,
330        },
331        S::TRANSCRIBE_ERR_OUTPUT_TRUNCATED => Error::OutputTruncated {
332            message: msg,
333            partial: None,
334        },
335        S::TRANSCRIBE_ERR_OUTPUT_REPETITION => Error::OutputRepetition {
336            message: msg,
337            partial: None,
338        },
339        S::TRANSCRIBE_ERR_UNSUPPORTED_ROLE => Error::UnsupportedRole(msg),
340        S::TRANSCRIBE_ERR_INPUT_TOO_SHORT => Error::InputTooShort(msg),
341        _ => Error::Other(msg),
342    }
343}
344
345/// `Ok(())` for `TRANSCRIBE_OK`, otherwise the mapped error.
346pub(crate) fn check(status: sys::transcribe_status, context: &str) -> Result<()> {
347    if status == sys::transcribe_status::TRANSCRIBE_OK {
348        Ok(())
349    } else {
350        Err(error_for_status(status, context))
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::mem::discriminant;
358    use sys::transcribe_status as S;
359
360    #[test]
361    fn every_status_maps_to_a_typed_variant() {
362        // Every non-OK status the linked library knows, found by walking codes
363        // upward until `transcribe_status_string` falls back to its "unknown"
364        // text, so a status appended to the header without a mapping fails here.
365        let unknown = status_string(-1);
366        let codes: Vec<S> = (1..256u32)
367            .take_while(|&c| status_string(c as i32) != unknown)
368            .map(S)
369            .collect();
370        assert!(
371            codes.len() >= S::TRANSCRIBE_ERR_UNSUPPORTED_ROLE.0 as usize,
372            "status walk stopped early at {} codes",
373            codes.len()
374        );
375        for status in codes {
376            let code = status.0;
377            let err = error_for_status(status, "ctx");
378            assert!(
379                !matches!(err, Error::Other(_)),
380                "status {code} falls through to Error::Other: {err:?}"
381            );
382            // raw_status must name a status that maps back to the same
383            // variant (grouped codes like SAMPLE_RATE report their group's
384            // primary status, never 0).
385            let raw = err.raw_status();
386            assert_ne!(raw, 0, "status {code} reports raw_status 0: {err:?}");
387            let back = error_for_status(S(raw as u32), "ctx");
388            assert_eq!(
389                discriminant(&back),
390                discriminant(&err),
391                "status {code} -> {err:?} -> raw {raw} -> {back:?}"
392            );
393            assert_eq!(back.kind(), err.kind(), "status {code}");
394            assert_ne!(err.kind(), ErrorKind::Other, "status {code}");
395        }
396        let role = error_for_status(S::TRANSCRIBE_ERR_UNSUPPORTED_ROLE, "ctx");
397        assert!(matches!(role, Error::UnsupportedRole(_)), "{role:?}");
398    }
399}