Skip to main content

motion_core/
error.rs

1//! Unified engine failures with retained causes and frame diagnostics.
2
3use std::{error::Error as StdError, fmt, io};
4
5use crate::{ErrorReport, ValidationError};
6
7/// Owned source error that can cross rendering-worker boundaries.
8pub type BoxedError = Box<dyn StdError + Send + Sync + 'static>;
9
10/// Stable categories for programmatic handling across engine boundaries.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum ErrorKind {
14    /// Invalid scene properties or exhausted structural/raster allowances.
15    Validation,
16    /// Filesystem or other operating-system I/O failure.
17    Io,
18    /// Loading or decoding a named resource failed.
19    Asset,
20    /// SVG parsing failed.
21    Svg,
22    /// Formula compilation failed.
23    Formula,
24    /// Project parameters could not be decoded.
25    Parameters,
26    /// Timeline construction or timing failed.
27    Timeline,
28    /// A requested GPU could not be initialized.
29    GpuUnavailable,
30    /// GPU submission, readback or allocation failed without confirmed device loss.
31    Gpu,
32    /// An initialized GPU device was lost.
33    DeviceLost,
34    /// A scene callback panicked at a worker boundary.
35    ScenePanic,
36    /// Video or audio encoding failed.
37    Encoder,
38    /// Cancellation was observed before output commit.
39    Cancelled,
40    /// An extension or other subsystem failed.
41    Other,
42}
43
44/// Engine failure retaining its original source and optional frame context.
45///
46/// Lower-level geometry APIs still return ValidationError directly. Converting
47/// them into this type retains all property/path information. Synchronous scene
48/// callbacks still unwind; workers turn caught payloads into ScenePanic.
49///
50/// ```
51/// use motion_core::{Error, ErrorKind};
52/// let error = Error::from(std::io::Error::other("disk failure")).at_frame(12);
53/// assert_eq!(error.kind(), ErrorKind::Io);
54/// assert_eq!(error.frame(), Some(12));
55/// assert!(error.downcast_ref::<std::io::Error>().is_some());
56/// ```
57#[derive(Debug)]
58#[non_exhaustive]
59pub enum Error {
60    /// Detailed scene/property diagnostic.
61    Validation(ValidationError),
62    /// Original filesystem or operating-system error.
63    Io(io::Error),
64    /// Named resource failure with the original decoder/load error.
65    Asset {
66        /// Resource category, such as image, font or model.
67        kind: &'static str,
68        /// User-facing resource identifier or source path.
69        source_name: String,
70        /// Original failure.
71        cause: BoxedError,
72    },
73    /// SVG parser failure.
74    Svg(BoxedError),
75    /// Formula compilation failure, optionally retaining structured diagnostics.
76    Formula {
77        /// User-facing explanation.
78        message: String,
79        /// Original structured diagnostics with source ranges, when available.
80        source: Option<BoxedError>,
81    },
82    /// Invalid serialized project parameters.
83    Parameters(BoxedError),
84    /// Timeline construction failure.
85    Timeline {
86        /// Timeline identifier.
87        timeline: String,
88        /// Violated timing contract.
89        message: String,
90    },
91    /// GPU setup failure; explicit GPU selection must not silently fall back.
92    GpuUnavailable(BoxedError),
93    /// GPU submission/readback failure without a confirmed device-loss callback.
94    Gpu(BoxedError),
95    /// Runtime GPU loss with its original driver error.
96    DeviceLost(BoxedError),
97    /// Caught scene panic. The message is retained for string payloads.
98    ScenePanic {
99        /// Registered scene or timeline name.
100        timeline: String,
101        /// Requested frame index.
102        frame: u64,
103        /// Panic payload text, or a non-string-payload marker.
104        message: String,
105    },
106    /// Native encoder failure with its original source.
107    Encoder(BoxedError),
108    /// Export was cancelled before destination commit.
109    Cancelled,
110    /// An otherwise classified error annotated with a frame index.
111    AtFrame {
112        /// Requested frame index.
113        frame: u64,
114        /// Original classified error.
115        cause: Box<Error>,
116    },
117    /// Extension error not covered by a more specific category.
118    Other(BoxedError),
119}
120
121impl Error {
122    /// The report of several failures carried by this single error, if any.
123    pub fn report(&self) -> Option<&ErrorReport> {
124        match self {
125            Self::Other(cause) => cause.downcast_ref::<ErrorReport>(),
126            Self::AtFrame { cause, .. } => cause.report(),
127            _ => None,
128        }
129    }
130
131    /// Associates an original failure with a named resource load.
132    pub fn asset(
133        kind: &'static str,
134        source_name: impl Into<String>,
135        cause: impl StdError + Send + Sync + 'static,
136    ) -> Self {
137        Self::Asset {
138            kind,
139            source_name: source_name.into(),
140            cause: Box::new(cause),
141        }
142    }
143
144    /// Formula failure without structured diagnostics.
145    pub fn formula(message: impl Into<String>) -> Self {
146        Self::Formula {
147            message: message.into(),
148            source: None,
149        }
150    }
151
152    /// Formula failure retaining its structured diagnostics as the source.
153    pub fn formula_with_source(
154        message: impl Into<String>,
155        source: impl StdError + Send + Sync + 'static,
156    ) -> Self {
157        Self::Formula {
158            message: message.into(),
159            source: Some(Box::new(source)),
160        }
161    }
162
163    /// Classifies an error independently of any frame annotation.
164    pub fn kind(&self) -> ErrorKind {
165        match self {
166            Self::Validation(_) => ErrorKind::Validation,
167            Self::Io(_) => ErrorKind::Io,
168            Self::Asset { .. } => ErrorKind::Asset,
169            Self::Svg(_) => ErrorKind::Svg,
170            Self::Formula { .. } => ErrorKind::Formula,
171            Self::Parameters(_) => ErrorKind::Parameters,
172            Self::Timeline { .. } => ErrorKind::Timeline,
173            Self::GpuUnavailable(_) => ErrorKind::GpuUnavailable,
174            Self::Gpu(_) => ErrorKind::Gpu,
175            Self::DeviceLost(_) => ErrorKind::DeviceLost,
176            Self::ScenePanic { .. } => ErrorKind::ScenePanic,
177            Self::Encoder(_) => ErrorKind::Encoder,
178            Self::Cancelled => ErrorKind::Cancelled,
179            Self::AtFrame { cause, .. } => cause.kind(),
180            Self::Other(cause) => {
181                cause
182                    .downcast_ref::<ErrorReport>()
183                    .map_or(ErrorKind::Other, |report| {
184                        report
185                            .first()
186                            .map_or(ErrorKind::Validation, |error| error.kind())
187                    })
188            }
189        }
190    }
191
192    /// Frame of failure, if supplied by rendering or a worker boundary.
193    pub fn frame(&self) -> Option<u64> {
194        match self {
195            Self::Validation(error) => error.frame.map(u64::from),
196            Self::ScenePanic { frame, .. } | Self::AtFrame { frame, .. } => Some(*frame),
197            _ => None,
198        }
199    }
200
201    /// Attaches a frame without replacing a more specific existing frame.
202    /// Validation diagnostics keep their original concrete type and fields.
203    pub fn at_frame(mut self, frame: u64) -> Self {
204        if self.frame().is_some() {
205            return self;
206        }
207        if let Self::Validation(error) = &mut self
208            && let Ok(frame) = u32::try_from(frame)
209        {
210            error.frame = Some(frame);
211            return self;
212        }
213        Self::AtFrame {
214            frame,
215            cause: Box::new(self),
216        }
217    }
218
219    /// Finds an original concrete error through the complete source chain.
220    /// This preserves property-diagnostic queries without parsing display strings.
221    pub fn downcast_ref<T: StdError + 'static>(&self) -> Option<&T> {
222        let mut current: Option<&(dyn StdError + 'static)> = Some(self);
223        while let Some(error) = current {
224            if let Some(error) = error.downcast_ref::<T>() {
225                return Some(error);
226            }
227            current = error.source();
228        }
229        None
230    }
231
232    /// Classifies a caught scene panic while preserving its scene and frame.
233    pub fn scene_panic(
234        timeline: impl Into<String>,
235        frame: u64,
236        payload: Box<dyn std::any::Any + Send>,
237    ) -> Self {
238        let message = payload
239            .downcast_ref::<String>()
240            .cloned()
241            .or_else(|| {
242                payload
243                    .downcast_ref::<&str>()
244                    .map(|message| (*message).to_owned())
245            })
246            .unwrap_or_else(|| "non-string panic payload".to_owned());
247        Self::ScenePanic {
248            timeline: timeline.into(),
249            frame,
250            message,
251        }
252    }
253}
254
255impl fmt::Display for Error {
256    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            Self::Validation(error) => error.fmt(formatter),
259            Self::Io(error) => error.fmt(formatter),
260            Self::Asset {
261                kind,
262                source_name,
263                cause,
264            } => write!(
265                formatter,
266                "{kind} asset `{source_name}` failed to load: {cause}"
267            ),
268            Self::Svg(cause) => write!(formatter, "SVG parse error: {cause}"),
269            Self::Formula { message, .. } => write!(formatter, "formula compile error: {message}"),
270            Self::Parameters(cause) => write!(formatter, "invalid parameters: {cause}"),
271            Self::Timeline { timeline, message } => {
272                write!(formatter, "timeline `{timeline}`: {message}")
273            }
274            Self::GpuUnavailable(cause) => write!(formatter, "GPU unavailable: {cause}"),
275            Self::Gpu(cause) => write!(formatter, "GPU error: {cause}"),
276            Self::DeviceLost(cause) => write!(formatter, "GPU device lost: {cause}"),
277            Self::ScenePanic {
278                timeline,
279                frame,
280                message,
281            } => write!(
282                formatter,
283                "scene panicked at frame {frame} of `{timeline}`: {message}"
284            ),
285            Self::Encoder(cause) => write!(formatter, "encoder error: {cause}"),
286            Self::Cancelled => formatter.write_str("export cancelled"),
287            Self::AtFrame { frame, cause } => write!(formatter, "frame {frame}: {cause}"),
288            Self::Other(cause) => cause.fmt(formatter),
289        }
290    }
291}
292
293impl StdError for Error {
294    fn source(&self) -> Option<&(dyn StdError + 'static)> {
295        match self {
296            Self::Validation(error) => Some(error),
297            Self::Io(error) => Some(error),
298            Self::Asset { cause, .. }
299            | Self::Svg(cause)
300            | Self::Parameters(cause)
301            | Self::GpuUnavailable(cause)
302            | Self::Gpu(cause)
303            | Self::DeviceLost(cause)
304            | Self::Encoder(cause)
305            | Self::Other(cause) => Some(cause.as_ref()),
306            Self::Formula { source, .. } => source
307                .as_deref()
308                .map(|cause| cause as &(dyn StdError + 'static)),
309            Self::AtFrame { cause, .. } => Some(cause.as_ref()),
310            _ => None,
311        }
312    }
313}
314
315impl From<ValidationError> for Error {
316    fn from(error: ValidationError) -> Self {
317        if error.field == "formula" {
318            Self::Formula {
319                message: error.to_string(),
320                source: Some(Box::new(error)),
321            }
322        } else {
323            Self::Validation(error)
324        }
325    }
326}
327
328impl From<io::Error> for Error {
329    fn from(error: io::Error) -> Self {
330        Self::Io(error)
331    }
332}
333
334impl From<BoxedError> for Error {
335    fn from(error: BoxedError) -> Self {
336        Self::Other(error)
337    }
338}
339
340impl From<std::collections::TryReserveError> for Error {
341    fn from(error: std::collections::TryReserveError) -> Self {
342        Self::Io(io::Error::new(io::ErrorKind::OutOfMemory, error))
343    }
344}
345
346impl From<std::env::VarError> for Error {
347    fn from(error: std::env::VarError) -> Self {
348        Self::Parameters(Box::new(error))
349    }
350}
351
352impl From<std::str::Utf8Error> for Error {
353    fn from(error: std::str::Utf8Error) -> Self {
354        Self::Other(Box::new(error))
355    }
356}
357
358impl From<std::sync::mpsc::RecvError> for Error {
359    fn from(error: std::sync::mpsc::RecvError) -> Self {
360        Self::Gpu(Box::new(error))
361    }
362}
363
364impl From<std::array::TryFromSliceError> for Error {
365    fn from(error: std::array::TryFromSliceError) -> Self {
366        Self::Gpu(Box::new(error))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn classification_frames_and_sources_survive_annotation() {
376        fn send_sync<T: Send + Sync>() {}
377        send_sync::<Error>();
378        let original = io::Error::new(io::ErrorKind::PermissionDenied, "destination denied");
379        let error = Error::Encoder(Box::new(original)).at_frame(7).at_frame(9);
380        assert_eq!(error.kind(), ErrorKind::Encoder);
381        assert_eq!(error.frame(), Some(7));
382        assert_eq!(
383            error.downcast_ref::<io::Error>().unwrap().kind(),
384            io::ErrorKind::PermissionDenied
385        );
386        assert!(
387            error
388                .to_string()
389                .contains("frame 7: encoder error: destination denied")
390        );
391        let original = ValidationError::invalid("nodes[2]", "opacity", "invalid opacity");
392        let error = Error::from(original).at_frame(11);
393        let diagnostic = error.downcast_ref::<ValidationError>().unwrap();
394        assert_eq!(diagnostic.frame, Some(11));
395        assert_eq!(diagnostic.path, "nodes[2]");
396        assert_eq!(error.frame(), Some(11));
397        assert_eq!(error.kind(), ErrorKind::Validation);
398    }
399
400    #[test]
401    fn panic_payloads_keep_scene_and_frame() {
402        for payload in [
403            Box::new("bad scene") as Box<dyn std::any::Any + Send>,
404            Box::new("bad scene".to_owned()),
405        ] {
406            let error = Error::scene_panic("intro", 23, payload);
407            assert_eq!(error.kind(), ErrorKind::ScenePanic);
408            assert_eq!(error.frame(), Some(23));
409            assert!(error.to_string().contains("intro"));
410            assert!(error.to_string().contains("bad scene"));
411        }
412        assert!(
413            Error::scene_panic("intro", 1, Box::new(42_u32))
414                .to_string()
415                .contains("non-string")
416        );
417        assert_eq!(Error::Cancelled.kind(), ErrorKind::Cancelled);
418    }
419}