Skip to main content

ff_decode/
error.rs

1//! Error types for decoding operations.
2//!
3//! This module provides the [`DecodeError`] enum which represents all
4//! possible errors that can occur during video/audio decoding.
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use ff_format::{ErrorSeverity, MediaError};
10use thiserror::Error;
11
12use crate::HardwareAccel;
13
14/// Errors that can occur during decoding operations.
15///
16/// This enum covers all error conditions that may arise when opening,
17/// configuring, or decoding media files.
18///
19/// # Error Categories
20///
21/// - **File errors**: [`FileNotFound`](Self::FileNotFound)
22/// - **Stream errors**: [`NoVideoStream`](Self::NoVideoStream), [`NoAudioStream`](Self::NoAudioStream)
23/// - **Codec errors**: [`UnsupportedCodec`](Self::UnsupportedCodec)
24/// - **Runtime errors**: [`DecodingFailed`](Self::DecodingFailed), [`SeekFailed`](Self::SeekFailed)
25/// - **Hardware errors**: [`HwAccelUnavailable`](Self::HwAccelUnavailable)
26/// - **Configuration errors**: [`InvalidOutputDimensions`](Self::InvalidOutputDimensions)
27/// - **Internal errors**: [`Ffmpeg`](Self::Ffmpeg), [`Io`](Self::Io)
28#[derive(Error, Debug)]
29pub enum DecodeError {
30    /// File was not found at the specified path.
31    ///
32    /// This error occurs when attempting to open a file that doesn't exist.
33    #[error("File not found: {path}")]
34    FileNotFound {
35        /// Path that was not found.
36        path: PathBuf,
37    },
38
39    /// No video stream exists in the media file.
40    ///
41    /// This error occurs when trying to decode video from a file that
42    /// only contains audio or other non-video streams.
43    #[error("No video stream found in: {path}")]
44    NoVideoStream {
45        /// Path to the media file.
46        path: PathBuf,
47    },
48
49    /// No audio stream exists in the media file.
50    ///
51    /// This error occurs when trying to decode audio from a file that
52    /// only contains video or other non-audio streams.
53    #[error("No audio stream found in: {path}")]
54    NoAudioStream {
55        /// Path to the media file.
56        path: PathBuf,
57    },
58
59    /// The codec is not supported by this decoder.
60    ///
61    /// This may occur for uncommon or proprietary codecs that are not
62    /// included in the `FFmpeg` build.
63    #[error("Codec not supported: {codec}")]
64    UnsupportedCodec {
65        /// Name of the unsupported codec.
66        codec: String,
67    },
68
69    /// The decoder for a known codec is absent from this `FFmpeg` build.
70    ///
71    /// Unlike [`UnsupportedCodec`](Self::UnsupportedCodec), the codec ID is
72    /// recognised by `FFmpeg` but the decoder was not compiled in (e.g.
73    /// `--enable-decoder=exr` was omitted from the build).
74    #[error("Decoder unavailable: {codec} — {hint}")]
75    DecoderUnavailable {
76        /// Short name of the codec (e.g. `"exr"`).
77        codec: String,
78        /// Human-readable suggestion for the caller.
79        hint: String,
80    },
81
82    /// Decoding operation failed at a specific point.
83    ///
84    /// This can occur due to corrupted data, unexpected stream format,
85    /// or internal decoder errors.
86    #[error("Decoding failed at {timestamp:?}: {reason}")]
87    DecodingFailed {
88        /// Timestamp where decoding failed (if known).
89        timestamp: Option<Duration>,
90        /// Reason for the failure.
91        reason: String,
92    },
93
94    /// Seek operation failed.
95    ///
96    /// Seeking may fail for various reasons including corrupted index,
97    /// seeking beyond file bounds, or stream format limitations.
98    #[error("Seek failed to {target:?}: {reason}")]
99    SeekFailed {
100        /// Target position of the seek.
101        target: Duration,
102        /// Reason for the failure.
103        reason: String,
104    },
105
106    /// Requested hardware acceleration is not available.
107    ///
108    /// This error occurs when a specific hardware accelerator is requested
109    /// but the system doesn't support it. Consider using [`HardwareAccel::Auto`]
110    /// for automatic fallback.
111    #[error("Hardware acceleration unavailable: {accel:?}")]
112    HwAccelUnavailable {
113        /// The unavailable hardware acceleration type.
114        accel: HardwareAccel,
115    },
116
117    /// Output dimensions are invalid.
118    ///
119    /// Width and height passed to [`output_size`](crate::video::builder::VideoDecoderBuilder::output_size),
120    /// [`output_width`](crate::video::builder::VideoDecoderBuilder::output_width), or
121    /// [`output_height`](crate::video::builder::VideoDecoderBuilder::output_height) must be
122    /// greater than zero and even (required by most pixel formats).
123    #[error("Invalid output dimensions: {width}x{height} (must be > 0 and even)")]
124    InvalidOutputDimensions {
125        /// Requested output width.
126        width: u32,
127        /// Requested output height.
128        height: u32,
129    },
130
131    /// `FFmpeg` internal error.
132    ///
133    /// This wraps errors from the underlying `FFmpeg` library that don't
134    /// fit into other categories.
135    #[error("ffmpeg error: {message} (code={code})")]
136    Ffmpeg {
137        /// Raw `FFmpeg` error code (negative integer). `0` when no numeric code is available.
138        code: i32,
139        /// Human-readable error message from `av_strerror` or an internal description.
140        message: String,
141    },
142
143    /// I/O error during file operations.
144    ///
145    /// This wraps standard I/O errors such as permission denied,
146    /// disk full, or network errors for remote files.
147    #[error("IO error: {0}")]
148    Io(#[from] std::io::Error),
149
150    /// The connection timed out before a response was received.
151    ///
152    /// Maps from `FFmpeg` error code `AVERROR(ETIMEDOUT)`.
153    /// `endpoint` is the sanitized URL (password replaced with `***`,
154    /// query string removed).
155    #[error("network timeout: endpoint={endpoint} — {message} (code={code})")]
156    NetworkTimeout {
157        /// Raw `FFmpeg` error code.
158        code: i32,
159        /// Sanitized endpoint URL (no credentials, no query string).
160        endpoint: String,
161        /// Human-readable error message from `av_strerror`.
162        message: String,
163    },
164
165    /// The connection was refused or the host could not be reached.
166    ///
167    /// Maps from `FFmpeg` error codes `AVERROR(ECONNREFUSED)`,
168    /// `AVERROR(EHOSTUNREACH)`, `AVERROR(ENETUNREACH)`, and DNS failures.
169    /// `endpoint` is the sanitized URL (password replaced with `***`,
170    /// query string removed).
171    #[error("connection failed: endpoint={endpoint} — {message} (code={code})")]
172    ConnectionFailed {
173        /// Raw `FFmpeg` error code.
174        code: i32,
175        /// Sanitized endpoint URL (no credentials, no query string).
176        endpoint: String,
177        /// Human-readable error message from `av_strerror`.
178        message: String,
179    },
180
181    /// The stream was interrupted after a connection was established.
182    ///
183    /// Maps from `AVERROR(EIO)` and `AVERROR_EOF` in a network context.
184    /// `endpoint` is the sanitized URL (password replaced with `***`,
185    /// query string removed).
186    #[error("stream interrupted: endpoint={endpoint} — {message} (code={code})")]
187    StreamInterrupted {
188        /// Raw `FFmpeg` error code.
189        code: i32,
190        /// Sanitized endpoint URL (no credentials, no query string).
191        endpoint: String,
192        /// Human-readable error message from `av_strerror`.
193        message: String,
194    },
195
196    /// Seeking was requested on a live stream where seeking is not supported.
197    ///
198    /// Returned by `VideoDecoder::seek()` and `AudioDecoder::seek()` when
199    /// `is_live()` returns `true`.
200    #[error("seek is not supported on live streams")]
201    SeekNotSupported,
202
203    /// A decoded frame exceeds the supported resolution limit.
204    #[error("unsupported resolution {width}x{height}: exceeds 32768 in one or both axes")]
205    UnsupportedResolution {
206        /// Frame width.
207        width: u32,
208        /// Frame height.
209        height: u32,
210    },
211
212    /// Too many consecutive corrupt packets — the stream is unrecoverable.
213    #[error(
214        "stream corrupted: {consecutive_invalid_packets} consecutive invalid packets without recovery"
215    )]
216    StreamCorrupted {
217        /// Number of consecutive invalid packets that triggered the error.
218        consecutive_invalid_packets: u32,
219    },
220
221    /// No frame was found at or after the requested timestamp.
222    ///
223    /// Returned by `VideoDecoder::extract_frame()` when EOF is reached before
224    /// a frame at or after the target position is found.
225    #[error("no frame found at timestamp: {timestamp:?}")]
226    NoFrameAtTimestamp {
227        /// The timestamp that was requested.
228        timestamp: Duration,
229    },
230
231    /// Frame or thumbnail extraction failed for a structural reason.
232    ///
233    /// Returned by [`FrameExtractor`](crate::extract::FrameExtractor) and
234    /// [`ThumbnailSelector`](crate::extract::ThumbnailSelector) when extraction
235    /// cannot proceed (e.g. a zero interval, or no suitable frame was found).
236    #[error("extraction failed: {reason}")]
237    ExtractionFailed {
238        /// Human-readable description of why extraction failed.
239        reason: String,
240    },
241}
242
243impl DecodeError {
244    /// Creates a new [`DecodeError::DecodingFailed`] with the given reason.
245    ///
246    /// # Arguments
247    ///
248    /// * `reason` - Description of why decoding failed.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use ff_decode::{DecodeError, MediaError};
254    ///
255    /// let error = DecodeError::decoding_failed("Corrupted frame data");
256    /// assert!(error.to_string().contains("Corrupted frame data"));
257    /// assert!(error.is_recoverable());
258    /// ```
259    #[must_use]
260    pub fn decoding_failed(reason: impl Into<String>) -> Self {
261        Self::DecodingFailed {
262            timestamp: None,
263            reason: reason.into(),
264        }
265    }
266
267    /// Creates a new [`DecodeError::DecodingFailed`] with timestamp and reason.
268    ///
269    /// # Arguments
270    ///
271    /// * `timestamp` - The timestamp where decoding failed.
272    /// * `reason` - Description of why decoding failed.
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use ff_decode::{DecodeError, MediaError};
278    /// use std::time::Duration;
279    ///
280    /// let error = DecodeError::decoding_failed_at(
281    ///     Duration::from_secs(30),
282    ///     "Invalid packet size"
283    /// );
284    /// assert!(error.to_string().contains("30s"));
285    /// assert!(error.is_recoverable());
286    /// ```
287    #[must_use]
288    pub fn decoding_failed_at(timestamp: Duration, reason: impl Into<String>) -> Self {
289        Self::DecodingFailed {
290            timestamp: Some(timestamp),
291            reason: reason.into(),
292        }
293    }
294
295    /// Creates a new [`DecodeError::SeekFailed`].
296    ///
297    /// # Arguments
298    ///
299    /// * `target` - The target position of the failed seek.
300    /// * `reason` - Description of why the seek failed.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use ff_decode::{DecodeError, MediaError};
306    /// use std::time::Duration;
307    ///
308    /// let error = DecodeError::seek_failed(
309    ///     Duration::from_secs(60),
310    ///     "Index not found"
311    /// );
312    /// assert!(error.to_string().contains("60s"));
313    /// assert!(error.is_recoverable());
314    /// ```
315    #[must_use]
316    pub fn seek_failed(target: Duration, reason: impl Into<String>) -> Self {
317        Self::SeekFailed {
318            target,
319            reason: reason.into(),
320        }
321    }
322
323    /// Creates a new [`DecodeError::DecoderUnavailable`].
324    ///
325    /// # Arguments
326    ///
327    /// * `codec` — Short codec name (e.g. `"exr"`).
328    /// * `hint` — Human-readable suggestion for the user.
329    #[must_use]
330    pub fn decoder_unavailable(codec: impl Into<String>, hint: impl Into<String>) -> Self {
331        Self::DecoderUnavailable {
332            codec: codec.into(),
333            hint: hint.into(),
334        }
335    }
336
337    /// Creates a new [`DecodeError::Ffmpeg`].
338    ///
339    /// # Arguments
340    ///
341    /// * `code` - The raw `FFmpeg` error code (negative integer). Pass `0` when no
342    ///   numeric code is available.
343    /// * `message` - Human-readable description of the error.
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// use ff_decode::DecodeError;
349    ///
350    /// let error = DecodeError::ffmpeg(-22, "Invalid data found when processing input");
351    /// assert!(error.to_string().contains("Invalid data"));
352    /// assert!(error.to_string().contains("code=-22"));
353    /// ```
354    #[must_use]
355    pub fn ffmpeg(code: i32, message: impl Into<String>) -> Self {
356        Self::Ffmpeg {
357            code,
358            message: message.into(),
359        }
360    }
361}
362
363impl MediaError for DecodeError {
364    /// Recoverable errors are retryable without rebuilding the decoder (a corrupt
365    /// frame, a transient network fault); fatal errors mean it must be discarded.
366    fn severity(&self) -> ErrorSeverity {
367        match self {
368            Self::DecodingFailed { .. }
369            | Self::SeekFailed { .. }
370            | Self::NetworkTimeout { .. }
371            | Self::StreamInterrupted { .. } => ErrorSeverity::Recoverable,
372            Self::FileNotFound { .. }
373            | Self::NoVideoStream { .. }
374            | Self::NoAudioStream { .. }
375            | Self::UnsupportedCodec { .. }
376            | Self::DecoderUnavailable { .. }
377            | Self::HwAccelUnavailable { .. }
378            | Self::InvalidOutputDimensions { .. }
379            | Self::ConnectionFailed { .. }
380            | Self::Io(_)
381            | Self::StreamCorrupted { .. }
382            | Self::ExtractionFailed { .. } => ErrorSeverity::Fatal,
383            Self::Ffmpeg { .. }
384            | Self::SeekNotSupported
385            | Self::UnsupportedResolution { .. }
386            | Self::NoFrameAtTimestamp { .. } => ErrorSeverity::Other,
387        }
388    }
389}
390
391#[cfg(test)]
392#[allow(clippy::panic)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_decode_error_display() {
398        let error = DecodeError::FileNotFound {
399            path: PathBuf::from("/path/to/video.mp4"),
400        };
401        assert!(error.to_string().contains("File not found"));
402        assert!(error.to_string().contains("/path/to/video.mp4"));
403
404        let error = DecodeError::NoVideoStream {
405            path: PathBuf::from("/path/to/audio.mp3"),
406        };
407        assert!(error.to_string().contains("No video stream"));
408
409        let error = DecodeError::UnsupportedCodec {
410            codec: "unknown_codec".to_string(),
411        };
412        assert!(error.to_string().contains("Codec not supported"));
413        assert!(error.to_string().contains("unknown_codec"));
414    }
415
416    #[test]
417    fn test_decoding_failed_constructor() {
418        let error = DecodeError::decoding_failed("Corrupted frame data");
419        match error {
420            DecodeError::DecodingFailed { timestamp, reason } => {
421                assert!(timestamp.is_none());
422                assert_eq!(reason, "Corrupted frame data");
423            }
424            _ => panic!("Wrong error type"),
425        }
426    }
427
428    #[test]
429    fn test_decoding_failed_at_constructor() {
430        let error = DecodeError::decoding_failed_at(Duration::from_secs(30), "Invalid packet size");
431        match error {
432            DecodeError::DecodingFailed { timestamp, reason } => {
433                assert_eq!(timestamp, Some(Duration::from_secs(30)));
434                assert_eq!(reason, "Invalid packet size");
435            }
436            _ => panic!("Wrong error type"),
437        }
438    }
439
440    #[test]
441    fn test_seek_failed_constructor() {
442        let error = DecodeError::seek_failed(Duration::from_secs(60), "Index not found");
443        match error {
444            DecodeError::SeekFailed { target, reason } => {
445                assert_eq!(target, Duration::from_secs(60));
446                assert_eq!(reason, "Index not found");
447            }
448            _ => panic!("Wrong error type"),
449        }
450    }
451
452    #[test]
453    fn test_ffmpeg_constructor() {
454        let error = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
455        match error {
456            DecodeError::Ffmpeg { code, message } => {
457                assert_eq!(code, -22);
458                assert_eq!(message, "AVERROR_INVALIDDATA");
459            }
460            _ => panic!("Wrong error type"),
461        }
462    }
463
464    #[test]
465    fn ffmpeg_should_format_with_code_and_message() {
466        let error = DecodeError::ffmpeg(-22, "Invalid data");
467        assert!(error.to_string().contains("code=-22"));
468        assert!(error.to_string().contains("Invalid data"));
469    }
470
471    #[test]
472    fn ffmpeg_with_zero_code_should_be_constructible() {
473        let error = DecodeError::ffmpeg(0, "allocation failed");
474        assert!(matches!(error, DecodeError::Ffmpeg { code: 0, .. }));
475    }
476
477    #[test]
478    fn decoder_unavailable_should_include_codec_and_hint() {
479        let e = DecodeError::decoder_unavailable(
480            "exr",
481            "Requires FFmpeg built with EXR support (--enable-decoder=exr)",
482        );
483        assert!(e.to_string().contains("exr"));
484        assert!(e.to_string().contains("Requires FFmpeg"));
485    }
486
487    #[test]
488    fn decoder_unavailable_should_be_fatal() {
489        let e = DecodeError::decoder_unavailable("exr", "hint");
490        assert!(e.is_fatal());
491        assert!(!e.is_recoverable());
492    }
493
494    #[test]
495    fn test_is_recoverable() {
496        assert!(DecodeError::decoding_failed("test").is_recoverable());
497        assert!(DecodeError::seek_failed(Duration::from_secs(1), "test").is_recoverable());
498        assert!(
499            !DecodeError::FileNotFound {
500                path: PathBuf::new()
501            }
502            .is_recoverable()
503        );
504    }
505
506    #[test]
507    fn test_is_fatal() {
508        assert!(
509            DecodeError::FileNotFound {
510                path: PathBuf::new()
511            }
512            .is_fatal()
513        );
514        assert!(
515            DecodeError::NoVideoStream {
516                path: PathBuf::new()
517            }
518            .is_fatal()
519        );
520        assert!(
521            DecodeError::NoAudioStream {
522                path: PathBuf::new()
523            }
524            .is_fatal()
525        );
526        assert!(
527            DecodeError::UnsupportedCodec {
528                codec: "test".to_string()
529            }
530            .is_fatal()
531        );
532        assert!(!DecodeError::decoding_failed("test").is_fatal());
533    }
534
535    #[test]
536    fn test_io_error_conversion() {
537        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
538        let decode_error: DecodeError = io_error.into();
539        assert!(matches!(decode_error, DecodeError::Io(_)));
540    }
541
542    #[test]
543    fn test_hw_accel_unavailable() {
544        let error = DecodeError::HwAccelUnavailable {
545            accel: HardwareAccel::Nvdec,
546        };
547        assert!(
548            error
549                .to_string()
550                .contains("Hardware acceleration unavailable")
551        );
552        assert!(error.to_string().contains("Nvdec"));
553    }
554
555    // ── is_fatal / is_recoverable exhaustive coverage ────────────────────────
556
557    #[test]
558    fn file_not_found_should_be_fatal_and_not_recoverable() {
559        let e = DecodeError::FileNotFound {
560            path: PathBuf::new(),
561        };
562        assert!(e.is_fatal());
563        assert!(!e.is_recoverable());
564    }
565
566    #[test]
567    fn no_video_stream_should_be_fatal_and_not_recoverable() {
568        let e = DecodeError::NoVideoStream {
569            path: PathBuf::new(),
570        };
571        assert!(e.is_fatal());
572        assert!(!e.is_recoverable());
573    }
574
575    #[test]
576    fn no_audio_stream_should_be_fatal_and_not_recoverable() {
577        let e = DecodeError::NoAudioStream {
578            path: PathBuf::new(),
579        };
580        assert!(e.is_fatal());
581        assert!(!e.is_recoverable());
582    }
583
584    #[test]
585    fn unsupported_codec_should_be_fatal_and_not_recoverable() {
586        let e = DecodeError::UnsupportedCodec {
587            codec: "test".to_string(),
588        };
589        assert!(e.is_fatal());
590        assert!(!e.is_recoverable());
591    }
592
593    #[test]
594    fn decoder_unavailable_should_be_fatal_and_not_recoverable() {
595        let e = DecodeError::decoder_unavailable("exr", "hint");
596        assert!(e.is_fatal());
597        assert!(!e.is_recoverable());
598    }
599
600    #[test]
601    fn decoding_failed_should_be_recoverable_and_not_fatal() {
602        let e = DecodeError::decoding_failed("corrupt frame");
603        assert!(e.is_recoverable());
604        assert!(!e.is_fatal());
605    }
606
607    #[test]
608    fn seek_failed_should_be_recoverable_and_not_fatal() {
609        let e = DecodeError::seek_failed(Duration::from_secs(5), "index not found");
610        assert!(e.is_recoverable());
611        assert!(!e.is_fatal());
612    }
613
614    #[test]
615    fn hw_accel_unavailable_should_be_fatal_and_not_recoverable() {
616        let e = DecodeError::HwAccelUnavailable {
617            accel: HardwareAccel::Nvdec,
618        };
619        assert!(e.is_fatal());
620        assert!(!e.is_recoverable());
621    }
622
623    #[test]
624    fn invalid_output_dimensions_should_be_fatal_and_not_recoverable() {
625        let e = DecodeError::InvalidOutputDimensions {
626            width: 0,
627            height: 0,
628        };
629        assert!(e.is_fatal());
630        assert!(!e.is_recoverable());
631    }
632
633    #[test]
634    fn ffmpeg_error_should_be_neither_fatal_nor_recoverable() {
635        let e = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
636        assert!(!e.is_fatal());
637        assert!(!e.is_recoverable());
638    }
639
640    #[test]
641    fn io_error_should_be_fatal_and_not_recoverable() {
642        let e: DecodeError =
643            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied").into();
644        assert!(e.is_fatal());
645        assert!(!e.is_recoverable());
646    }
647
648    #[test]
649    fn network_timeout_should_be_recoverable_and_not_fatal() {
650        let e = DecodeError::NetworkTimeout {
651            code: -110,
652            endpoint: "rtmp://example.com/live".to_string(),
653            message: "timed out".to_string(),
654        };
655        assert!(e.is_recoverable());
656        assert!(!e.is_fatal());
657    }
658
659    #[test]
660    fn connection_failed_should_be_fatal_and_not_recoverable() {
661        let e = DecodeError::ConnectionFailed {
662            code: -111,
663            endpoint: "rtmp://example.com/live".to_string(),
664            message: "connection refused".to_string(),
665        };
666        assert!(e.is_fatal());
667        assert!(!e.is_recoverable());
668    }
669
670    #[test]
671    fn stream_interrupted_should_be_recoverable_and_not_fatal() {
672        let e = DecodeError::StreamInterrupted {
673            code: -5,
674            endpoint: "rtmp://example.com/live".to_string(),
675            message: "I/O error".to_string(),
676        };
677        assert!(e.is_recoverable());
678        assert!(!e.is_fatal());
679    }
680
681    #[test]
682    fn seek_not_supported_should_be_neither_fatal_nor_recoverable() {
683        let e = DecodeError::SeekNotSupported;
684        assert!(!e.is_fatal());
685        assert!(!e.is_recoverable());
686    }
687
688    #[test]
689    fn unsupported_resolution_display_should_contain_width_x_height() {
690        let e = DecodeError::UnsupportedResolution {
691            width: 40000,
692            height: 480,
693        };
694        let msg = e.to_string();
695        assert!(msg.contains("40000x480"), "expected '40000x480' in '{msg}'");
696    }
697
698    #[test]
699    fn unsupported_resolution_display_should_contain_axes_hint() {
700        let e = DecodeError::UnsupportedResolution {
701            width: 640,
702            height: 40000,
703        };
704        let msg = e.to_string();
705        assert!(msg.contains("32768"), "expected '32768' limit in '{msg}'");
706    }
707
708    #[test]
709    fn unsupported_resolution_should_be_neither_fatal_nor_recoverable() {
710        let e = DecodeError::UnsupportedResolution {
711            width: 40000,
712            height: 40000,
713        };
714        assert!(!e.is_fatal());
715        assert!(!e.is_recoverable());
716    }
717
718    #[test]
719    fn stream_corrupted_display_should_contain_packet_count() {
720        let e = DecodeError::StreamCorrupted {
721            consecutive_invalid_packets: 32,
722        };
723        let msg = e.to_string();
724        assert!(msg.contains("32"), "expected '32' in '{msg}'");
725    }
726
727    #[test]
728    fn stream_corrupted_display_should_mention_consecutive() {
729        let e = DecodeError::StreamCorrupted {
730            consecutive_invalid_packets: 32,
731        };
732        let msg = e.to_string();
733        assert!(
734            msg.contains("consecutive"),
735            "expected 'consecutive' in '{msg}'"
736        );
737    }
738
739    #[test]
740    fn stream_corrupted_should_be_fatal_and_not_recoverable() {
741        let e = DecodeError::StreamCorrupted {
742            consecutive_invalid_packets: 32,
743        };
744        assert!(e.is_fatal());
745        assert!(!e.is_recoverable());
746    }
747
748    #[test]
749    fn decode_error_no_frame_at_timestamp_should_display_correctly() {
750        let e = DecodeError::NoFrameAtTimestamp {
751            timestamp: Duration::from_secs(5),
752        };
753        let msg = e.to_string();
754        assert!(
755            msg.contains("no frame found at timestamp"),
756            "unexpected message: {msg}"
757        );
758        assert!(msg.contains("5s"), "expected timestamp in message: {msg}");
759    }
760
761    #[test]
762    fn decode_error_extraction_failed_should_display_correctly() {
763        let e = DecodeError::ExtractionFailed {
764            reason: "interval must be positive".to_string(),
765        };
766        let msg = e.to_string();
767        assert!(
768            msg.contains("extraction failed"),
769            "unexpected message: {msg}"
770        );
771        assert!(
772            msg.contains("interval must be positive"),
773            "expected reason in message: {msg}"
774        );
775    }
776
777    #[test]
778    fn no_frame_at_timestamp_should_be_neither_fatal_nor_recoverable() {
779        let e = DecodeError::NoFrameAtTimestamp {
780            timestamp: Duration::from_secs(10),
781        };
782        assert!(!e.is_fatal());
783        assert!(!e.is_recoverable());
784    }
785
786    #[test]
787    fn extraction_failed_should_be_fatal_and_not_recoverable() {
788        let e = DecodeError::ExtractionFailed {
789            reason: "no suitable frame".to_string(),
790        };
791        assert!(e.is_fatal());
792        assert!(!e.is_recoverable());
793    }
794}