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