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    /// No frame was found at or after the requested timestamp.
221    ///
222    /// Returned by `VideoDecoder::extract_frame()` when EOF is reached before
223    /// a frame at or after the target position is found.
224    #[error("no frame found at timestamp: {timestamp:?}")]
225    NoFrameAtTimestamp {
226        /// The timestamp that was requested.
227        timestamp: Duration,
228    },
229
230    /// An analysis operation failed for a structural reason.
231    ///
232    /// Returned by tools in [`crate::analysis`] when the operation cannot
233    /// proceed (e.g. zero interval, missing audio stream, unsupported format).
234    #[error("analysis failed: {reason}")]
235    AnalysisFailed {
236        /// Human-readable description of why the analysis failed.
237        reason: String,
238    },
239}
240
241impl DecodeError {
242    /// Creates a new [`DecodeError::DecodingFailed`] with the given reason.
243    ///
244    /// # Arguments
245    ///
246    /// * `reason` - Description of why decoding failed.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use ff_decode::DecodeError;
252    ///
253    /// let error = DecodeError::decoding_failed("Corrupted frame data");
254    /// assert!(error.to_string().contains("Corrupted frame data"));
255    /// assert!(error.is_recoverable());
256    /// ```
257    #[must_use]
258    pub fn decoding_failed(reason: impl Into<String>) -> Self {
259        Self::DecodingFailed {
260            timestamp: None,
261            reason: reason.into(),
262        }
263    }
264
265    /// Creates a new [`DecodeError::DecodingFailed`] with timestamp and reason.
266    ///
267    /// # Arguments
268    ///
269    /// * `timestamp` - The timestamp where decoding failed.
270    /// * `reason` - Description of why decoding failed.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use ff_decode::DecodeError;
276    /// use std::time::Duration;
277    ///
278    /// let error = DecodeError::decoding_failed_at(
279    ///     Duration::from_secs(30),
280    ///     "Invalid packet size"
281    /// );
282    /// assert!(error.to_string().contains("30s"));
283    /// assert!(error.is_recoverable());
284    /// ```
285    #[must_use]
286    pub fn decoding_failed_at(timestamp: Duration, reason: impl Into<String>) -> Self {
287        Self::DecodingFailed {
288            timestamp: Some(timestamp),
289            reason: reason.into(),
290        }
291    }
292
293    /// Creates a new [`DecodeError::SeekFailed`].
294    ///
295    /// # Arguments
296    ///
297    /// * `target` - The target position of the failed seek.
298    /// * `reason` - Description of why the seek failed.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use ff_decode::DecodeError;
304    /// use std::time::Duration;
305    ///
306    /// let error = DecodeError::seek_failed(
307    ///     Duration::from_secs(60),
308    ///     "Index not found"
309    /// );
310    /// assert!(error.to_string().contains("60s"));
311    /// assert!(error.is_recoverable());
312    /// ```
313    #[must_use]
314    pub fn seek_failed(target: Duration, reason: impl Into<String>) -> Self {
315        Self::SeekFailed {
316            target,
317            reason: reason.into(),
318        }
319    }
320
321    /// Creates a new [`DecodeError::DecoderUnavailable`].
322    ///
323    /// # Arguments
324    ///
325    /// * `codec` — Short codec name (e.g. `"exr"`).
326    /// * `hint` — Human-readable suggestion for the user.
327    #[must_use]
328    pub fn decoder_unavailable(codec: impl Into<String>, hint: impl Into<String>) -> Self {
329        Self::DecoderUnavailable {
330            codec: codec.into(),
331            hint: hint.into(),
332        }
333    }
334
335    /// Creates a new [`DecodeError::Ffmpeg`].
336    ///
337    /// # Arguments
338    ///
339    /// * `code` - The raw `FFmpeg` error code (negative integer). Pass `0` when no
340    ///   numeric code is available.
341    /// * `message` - Human-readable description of the error.
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use ff_decode::DecodeError;
347    ///
348    /// let error = DecodeError::ffmpeg(-22, "Invalid data found when processing input");
349    /// assert!(error.to_string().contains("Invalid data"));
350    /// assert!(error.to_string().contains("code=-22"));
351    /// ```
352    #[must_use]
353    pub fn ffmpeg(code: i32, message: impl Into<String>) -> Self {
354        Self::Ffmpeg {
355            code,
356            message: message.into(),
357        }
358    }
359
360    /// Returns `true` if this error is recoverable.
361    ///
362    /// Recoverable errors are those where the operation that raised the error
363    /// can be retried (or the decoder can transparently reconnect) without
364    /// rebuilding the decoder from scratch.
365    ///
366    /// | Variant | Recoverable |
367    /// |---|---|
368    /// | [`DecodingFailed`](Self::DecodingFailed) | ✓ — corrupt frame; skip and continue |
369    /// | [`SeekFailed`](Self::SeekFailed) | ✓ — retry at a different position |
370    /// | [`NetworkTimeout`](Self::NetworkTimeout) | ✓ — transient; reconnect |
371    /// | [`StreamInterrupted`](Self::StreamInterrupted) | ✓ — transient; reconnect |
372    /// | all others | ✗ |
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use ff_decode::DecodeError;
378    /// use std::time::Duration;
379    ///
380    /// // Decoding failures are recoverable
381    /// assert!(DecodeError::decoding_failed("test").is_recoverable());
382    ///
383    /// // Seek failures are recoverable
384    /// assert!(DecodeError::seek_failed(Duration::from_secs(1), "test").is_recoverable());
385    ///
386    /// ```
387    #[must_use]
388    pub fn is_recoverable(&self) -> bool {
389        match self {
390            Self::DecodingFailed { .. }
391            | Self::SeekFailed { .. }
392            | Self::NetworkTimeout { .. }
393            | Self::StreamInterrupted { .. } => true,
394            Self::FileNotFound { .. }
395            | Self::NoVideoStream { .. }
396            | Self::NoAudioStream { .. }
397            | Self::UnsupportedCodec { .. }
398            | Self::DecoderUnavailable { .. }
399            | Self::HwAccelUnavailable { .. }
400            | Self::InvalidOutputDimensions { .. }
401            | Self::ConnectionFailed { .. }
402            | Self::Io(_)
403            | Self::Ffmpeg { .. }
404            | Self::SeekNotSupported
405            | Self::UnsupportedResolution { .. }
406            | Self::StreamCorrupted { .. }
407            | Self::NoFrameAtTimestamp { .. }
408            | Self::AnalysisFailed { .. } => false,
409        }
410    }
411
412    /// Returns `true` if this error is fatal.
413    ///
414    /// Fatal errors indicate that the decoder cannot continue operating and
415    /// must be discarded; re-opening or reconfiguring is required.
416    ///
417    /// | Variant | Fatal |
418    /// |---|---|
419    /// | [`FileNotFound`](Self::FileNotFound) | ✓ |
420    /// | [`NoVideoStream`](Self::NoVideoStream) | ✓ |
421    /// | [`NoAudioStream`](Self::NoAudioStream) | ✓ |
422    /// | [`UnsupportedCodec`](Self::UnsupportedCodec) | ✓ |
423    /// | [`DecoderUnavailable`](Self::DecoderUnavailable) | ✓ |
424    /// | [`HwAccelUnavailable`](Self::HwAccelUnavailable) | ✓ — must reconfigure without HW |
425    /// | [`InvalidOutputDimensions`](Self::InvalidOutputDimensions) | ✓ — bad config |
426    /// | [`ConnectionFailed`](Self::ConnectionFailed) | ✓ — host unreachable |
427    /// | [`Io`](Self::Io) | ✓ — I/O failure |
428    /// | all others | ✗ |
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// use ff_decode::DecodeError;
434    /// use std::path::PathBuf;
435    ///
436    /// // File not found is fatal
437    /// assert!(DecodeError::FileNotFound { path: PathBuf::new() }.is_fatal());
438    ///
439    /// // Unsupported codec is fatal
440    /// assert!(DecodeError::UnsupportedCodec { codec: "test".to_string() }.is_fatal());
441    ///
442    /// ```
443    #[must_use]
444    pub fn is_fatal(&self) -> bool {
445        match self {
446            Self::FileNotFound { .. }
447            | Self::NoVideoStream { .. }
448            | Self::NoAudioStream { .. }
449            | Self::UnsupportedCodec { .. }
450            | Self::DecoderUnavailable { .. }
451            | Self::HwAccelUnavailable { .. }
452            | Self::InvalidOutputDimensions { .. }
453            | Self::ConnectionFailed { .. }
454            | Self::Io(_)
455            | Self::StreamCorrupted { .. }
456            | Self::AnalysisFailed { .. } => true,
457            Self::DecodingFailed { .. }
458            | Self::SeekFailed { .. }
459            | Self::NetworkTimeout { .. }
460            | Self::StreamInterrupted { .. }
461            | Self::Ffmpeg { .. }
462            | Self::SeekNotSupported
463            | Self::UnsupportedResolution { .. }
464            | Self::NoFrameAtTimestamp { .. } => false,
465        }
466    }
467}
468
469#[cfg(test)]
470#[allow(clippy::panic)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_decode_error_display() {
476        let error = DecodeError::FileNotFound {
477            path: PathBuf::from("/path/to/video.mp4"),
478        };
479        assert!(error.to_string().contains("File not found"));
480        assert!(error.to_string().contains("/path/to/video.mp4"));
481
482        let error = DecodeError::NoVideoStream {
483            path: PathBuf::from("/path/to/audio.mp3"),
484        };
485        assert!(error.to_string().contains("No video stream"));
486
487        let error = DecodeError::UnsupportedCodec {
488            codec: "unknown_codec".to_string(),
489        };
490        assert!(error.to_string().contains("Codec not supported"));
491        assert!(error.to_string().contains("unknown_codec"));
492    }
493
494    #[test]
495    fn test_decoding_failed_constructor() {
496        let error = DecodeError::decoding_failed("Corrupted frame data");
497        match error {
498            DecodeError::DecodingFailed { timestamp, reason } => {
499                assert!(timestamp.is_none());
500                assert_eq!(reason, "Corrupted frame data");
501            }
502            _ => panic!("Wrong error type"),
503        }
504    }
505
506    #[test]
507    fn test_decoding_failed_at_constructor() {
508        let error = DecodeError::decoding_failed_at(Duration::from_secs(30), "Invalid packet size");
509        match error {
510            DecodeError::DecodingFailed { timestamp, reason } => {
511                assert_eq!(timestamp, Some(Duration::from_secs(30)));
512                assert_eq!(reason, "Invalid packet size");
513            }
514            _ => panic!("Wrong error type"),
515        }
516    }
517
518    #[test]
519    fn test_seek_failed_constructor() {
520        let error = DecodeError::seek_failed(Duration::from_secs(60), "Index not found");
521        match error {
522            DecodeError::SeekFailed { target, reason } => {
523                assert_eq!(target, Duration::from_secs(60));
524                assert_eq!(reason, "Index not found");
525            }
526            _ => panic!("Wrong error type"),
527        }
528    }
529
530    #[test]
531    fn test_ffmpeg_constructor() {
532        let error = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
533        match error {
534            DecodeError::Ffmpeg { code, message } => {
535                assert_eq!(code, -22);
536                assert_eq!(message, "AVERROR_INVALIDDATA");
537            }
538            _ => panic!("Wrong error type"),
539        }
540    }
541
542    #[test]
543    fn ffmpeg_should_format_with_code_and_message() {
544        let error = DecodeError::ffmpeg(-22, "Invalid data");
545        assert!(error.to_string().contains("code=-22"));
546        assert!(error.to_string().contains("Invalid data"));
547    }
548
549    #[test]
550    fn ffmpeg_with_zero_code_should_be_constructible() {
551        let error = DecodeError::ffmpeg(0, "allocation failed");
552        assert!(matches!(error, DecodeError::Ffmpeg { code: 0, .. }));
553    }
554
555    #[test]
556    fn decoder_unavailable_should_include_codec_and_hint() {
557        let e = DecodeError::decoder_unavailable(
558            "exr",
559            "Requires FFmpeg built with EXR support (--enable-decoder=exr)",
560        );
561        assert!(e.to_string().contains("exr"));
562        assert!(e.to_string().contains("Requires FFmpeg"));
563    }
564
565    #[test]
566    fn decoder_unavailable_should_be_fatal() {
567        let e = DecodeError::decoder_unavailable("exr", "hint");
568        assert!(e.is_fatal());
569        assert!(!e.is_recoverable());
570    }
571
572    #[test]
573    fn test_is_recoverable() {
574        assert!(DecodeError::decoding_failed("test").is_recoverable());
575        assert!(DecodeError::seek_failed(Duration::from_secs(1), "test").is_recoverable());
576        assert!(
577            !DecodeError::FileNotFound {
578                path: PathBuf::new()
579            }
580            .is_recoverable()
581        );
582    }
583
584    #[test]
585    fn test_is_fatal() {
586        assert!(
587            DecodeError::FileNotFound {
588                path: PathBuf::new()
589            }
590            .is_fatal()
591        );
592        assert!(
593            DecodeError::NoVideoStream {
594                path: PathBuf::new()
595            }
596            .is_fatal()
597        );
598        assert!(
599            DecodeError::NoAudioStream {
600                path: PathBuf::new()
601            }
602            .is_fatal()
603        );
604        assert!(
605            DecodeError::UnsupportedCodec {
606                codec: "test".to_string()
607            }
608            .is_fatal()
609        );
610        assert!(!DecodeError::decoding_failed("test").is_fatal());
611    }
612
613    #[test]
614    fn test_io_error_conversion() {
615        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
616        let decode_error: DecodeError = io_error.into();
617        assert!(matches!(decode_error, DecodeError::Io(_)));
618    }
619
620    #[test]
621    fn test_hw_accel_unavailable() {
622        let error = DecodeError::HwAccelUnavailable {
623            accel: HardwareAccel::Nvdec,
624        };
625        assert!(
626            error
627                .to_string()
628                .contains("Hardware acceleration unavailable")
629        );
630        assert!(error.to_string().contains("Nvdec"));
631    }
632
633    // ── is_fatal / is_recoverable exhaustive coverage ────────────────────────
634
635    #[test]
636    fn file_not_found_should_be_fatal_and_not_recoverable() {
637        let e = DecodeError::FileNotFound {
638            path: PathBuf::new(),
639        };
640        assert!(e.is_fatal());
641        assert!(!e.is_recoverable());
642    }
643
644    #[test]
645    fn no_video_stream_should_be_fatal_and_not_recoverable() {
646        let e = DecodeError::NoVideoStream {
647            path: PathBuf::new(),
648        };
649        assert!(e.is_fatal());
650        assert!(!e.is_recoverable());
651    }
652
653    #[test]
654    fn no_audio_stream_should_be_fatal_and_not_recoverable() {
655        let e = DecodeError::NoAudioStream {
656            path: PathBuf::new(),
657        };
658        assert!(e.is_fatal());
659        assert!(!e.is_recoverable());
660    }
661
662    #[test]
663    fn unsupported_codec_should_be_fatal_and_not_recoverable() {
664        let e = DecodeError::UnsupportedCodec {
665            codec: "test".to_string(),
666        };
667        assert!(e.is_fatal());
668        assert!(!e.is_recoverable());
669    }
670
671    #[test]
672    fn decoder_unavailable_should_be_fatal_and_not_recoverable() {
673        let e = DecodeError::decoder_unavailable("exr", "hint");
674        assert!(e.is_fatal());
675        assert!(!e.is_recoverable());
676    }
677
678    #[test]
679    fn decoding_failed_should_be_recoverable_and_not_fatal() {
680        let e = DecodeError::decoding_failed("corrupt frame");
681        assert!(e.is_recoverable());
682        assert!(!e.is_fatal());
683    }
684
685    #[test]
686    fn seek_failed_should_be_recoverable_and_not_fatal() {
687        let e = DecodeError::seek_failed(Duration::from_secs(5), "index not found");
688        assert!(e.is_recoverable());
689        assert!(!e.is_fatal());
690    }
691
692    #[test]
693    fn hw_accel_unavailable_should_be_fatal_and_not_recoverable() {
694        let e = DecodeError::HwAccelUnavailable {
695            accel: HardwareAccel::Nvdec,
696        };
697        assert!(e.is_fatal());
698        assert!(!e.is_recoverable());
699    }
700
701    #[test]
702    fn invalid_output_dimensions_should_be_fatal_and_not_recoverable() {
703        let e = DecodeError::InvalidOutputDimensions {
704            width: 0,
705            height: 0,
706        };
707        assert!(e.is_fatal());
708        assert!(!e.is_recoverable());
709    }
710
711    #[test]
712    fn ffmpeg_error_should_be_neither_fatal_nor_recoverable() {
713        let e = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
714        assert!(!e.is_fatal());
715        assert!(!e.is_recoverable());
716    }
717
718    #[test]
719    fn io_error_should_be_fatal_and_not_recoverable() {
720        let e: DecodeError =
721            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied").into();
722        assert!(e.is_fatal());
723        assert!(!e.is_recoverable());
724    }
725
726    #[test]
727    fn network_timeout_should_be_recoverable_and_not_fatal() {
728        let e = DecodeError::NetworkTimeout {
729            code: -110,
730            endpoint: "rtmp://example.com/live".to_string(),
731            message: "timed out".to_string(),
732        };
733        assert!(e.is_recoverable());
734        assert!(!e.is_fatal());
735    }
736
737    #[test]
738    fn connection_failed_should_be_fatal_and_not_recoverable() {
739        let e = DecodeError::ConnectionFailed {
740            code: -111,
741            endpoint: "rtmp://example.com/live".to_string(),
742            message: "connection refused".to_string(),
743        };
744        assert!(e.is_fatal());
745        assert!(!e.is_recoverable());
746    }
747
748    #[test]
749    fn stream_interrupted_should_be_recoverable_and_not_fatal() {
750        let e = DecodeError::StreamInterrupted {
751            code: -5,
752            endpoint: "rtmp://example.com/live".to_string(),
753            message: "I/O error".to_string(),
754        };
755        assert!(e.is_recoverable());
756        assert!(!e.is_fatal());
757    }
758
759    #[test]
760    fn seek_not_supported_should_be_neither_fatal_nor_recoverable() {
761        let e = DecodeError::SeekNotSupported;
762        assert!(!e.is_fatal());
763        assert!(!e.is_recoverable());
764    }
765
766    #[test]
767    fn unsupported_resolution_display_should_contain_width_x_height() {
768        let e = DecodeError::UnsupportedResolution {
769            width: 40000,
770            height: 480,
771        };
772        let msg = e.to_string();
773        assert!(msg.contains("40000x480"), "expected '40000x480' in '{msg}'");
774    }
775
776    #[test]
777    fn unsupported_resolution_display_should_contain_axes_hint() {
778        let e = DecodeError::UnsupportedResolution {
779            width: 640,
780            height: 40000,
781        };
782        let msg = e.to_string();
783        assert!(msg.contains("32768"), "expected '32768' limit in '{msg}'");
784    }
785
786    #[test]
787    fn unsupported_resolution_should_be_neither_fatal_nor_recoverable() {
788        let e = DecodeError::UnsupportedResolution {
789            width: 40000,
790            height: 40000,
791        };
792        assert!(!e.is_fatal());
793        assert!(!e.is_recoverable());
794    }
795
796    #[test]
797    fn stream_corrupted_display_should_contain_packet_count() {
798        let e = DecodeError::StreamCorrupted {
799            consecutive_invalid_packets: 32,
800        };
801        let msg = e.to_string();
802        assert!(msg.contains("32"), "expected '32' in '{msg}'");
803    }
804
805    #[test]
806    fn stream_corrupted_display_should_mention_consecutive() {
807        let e = DecodeError::StreamCorrupted {
808            consecutive_invalid_packets: 32,
809        };
810        let msg = e.to_string();
811        assert!(
812            msg.contains("consecutive"),
813            "expected 'consecutive' in '{msg}'"
814        );
815    }
816
817    #[test]
818    fn stream_corrupted_should_be_fatal_and_not_recoverable() {
819        let e = DecodeError::StreamCorrupted {
820            consecutive_invalid_packets: 32,
821        };
822        assert!(e.is_fatal());
823        assert!(!e.is_recoverable());
824    }
825
826    #[test]
827    fn decode_error_no_frame_at_timestamp_should_display_correctly() {
828        let e = DecodeError::NoFrameAtTimestamp {
829            timestamp: Duration::from_secs(5),
830        };
831        let msg = e.to_string();
832        assert!(
833            msg.contains("no frame found at timestamp"),
834            "unexpected message: {msg}"
835        );
836        assert!(msg.contains("5s"), "expected timestamp in message: {msg}");
837    }
838
839    #[test]
840    fn decode_error_analysis_failed_should_display_correctly() {
841        let e = DecodeError::AnalysisFailed {
842            reason: "interval must be non-zero".to_string(),
843        };
844        let msg = e.to_string();
845        assert!(msg.contains("analysis failed"), "unexpected message: {msg}");
846        assert!(
847            msg.contains("interval must be non-zero"),
848            "expected reason in message: {msg}"
849        );
850    }
851
852    #[test]
853    fn no_frame_at_timestamp_should_be_neither_fatal_nor_recoverable() {
854        let e = DecodeError::NoFrameAtTimestamp {
855            timestamp: Duration::from_secs(10),
856        };
857        assert!(!e.is_fatal());
858        assert!(!e.is_recoverable());
859    }
860
861    #[test]
862    fn analysis_failed_should_be_fatal_and_not_recoverable() {
863        let e = DecodeError::AnalysisFailed {
864            reason: "zero interval".to_string(),
865        };
866        assert!(e.is_fatal());
867        assert!(!e.is_recoverable());
868    }
869}