Skip to main content

ff_pipeline/
error.rs

1//! Error types for pipeline operations.
2//!
3//! This module provides [`PipelineError`], which covers all failure modes that
4//! can arise when building or running a [`Pipeline`](crate::Pipeline).
5
6use ff_format::{ErrorSeverity, MediaError};
7
8/// Errors that can occur while building or running a pipeline.
9///
10/// # Error Categories
11///
12/// - **Downstream errors**: [`Decode`](Self::Decode), [`Filter`](Self::Filter),
13///   [`Encode`](Self::Encode) — propagated from the underlying crates via `#[from]`
14/// - **Configuration errors**: [`NoInput`](Self::NoInput), [`NoOutput`](Self::NoOutput),
15///   [`SecondaryInputWithoutFilter`](Self::SecondaryInputWithoutFilter)
16///   — returned by [`PipelineBuilder::build`](crate::PipelineBuilder::build)
17/// - **Runtime control**: [`Cancelled`](Self::Cancelled) — returned by
18///   [`Pipeline::run`](crate::Pipeline::run) when the progress callback returns `false`
19/// - **Availability**: [`FrameNotAvailable`](Self::FrameNotAvailable) — no frame at position
20#[derive(Debug, thiserror::Error)]
21pub enum PipelineError {
22    /// A decoding step failed.
23    ///
24    /// Wraps [`ff_decode::DecodeError`] and is produced automatically via `#[from]`
25    /// when a decode operation inside the pipeline returns an error.
26    #[error("decode failed: {0}")]
27    Decode(#[from] ff_decode::DecodeError),
28
29    /// A filter graph step failed.
30    ///
31    /// Wraps [`ff_filter::FilterError`] and is produced automatically via `#[from]`
32    /// when the filter graph inside the pipeline returns an error.
33    #[error("filter failed: {0}")]
34    Filter(#[from] ff_filter::FilterError),
35
36    /// An encoding step failed.
37    ///
38    /// Wraps [`ff_encode::EncodeError`] and is produced automatically via `#[from]`
39    /// when an encode operation inside the pipeline returns an error.
40    #[error("encode failed: {0}")]
41    Encode(#[from] ff_encode::EncodeError),
42
43    /// No input path was provided to the builder.
44    ///
45    /// At least one call to [`PipelineBuilder::input`](crate::PipelineBuilder::input)
46    /// is required before [`PipelineBuilder::build`](crate::PipelineBuilder::build).
47    #[error("no input specified")]
48    NoInput,
49
50    /// No output path and config were provided to the builder.
51    ///
52    /// A call to [`PipelineBuilder::output`](crate::PipelineBuilder::output) is
53    /// required before [`PipelineBuilder::build`](crate::PipelineBuilder::build).
54    #[error("no output specified")]
55    NoOutput,
56
57    /// `secondary_input()` was called but no filter graph was provided.
58    ///
59    /// A secondary input only makes sense when a multi-slot filter is set via
60    /// [`PipelineBuilder::filter`](crate::PipelineBuilder::filter).
61    #[error("secondary input provided without a filter graph")]
62    SecondaryInputWithoutFilter,
63
64    /// The pipeline was cancelled by the progress callback.
65    ///
66    /// Returned by [`Pipeline::run`](crate::Pipeline::run) when the
67    /// [`ProgressCallback`](crate::ProgressCallback) returns `false`.
68    #[error("pipeline cancelled by caller")]
69    Cancelled,
70
71    /// An I/O error (e.g. creating the output directory for thumbnails).
72    #[error("i/o error: {0}")]
73    Io(#[from] std::io::Error),
74
75    /// No frame was available at the requested position.
76    ///
77    /// Returned by thumbnail and seek-and-decode operations when the decoder
78    /// reports `Ok(None)` — the position is past the end of the stream or no
79    /// decodable frame exists at that point.
80    #[error("no frame available at the requested position")]
81    FrameNotAvailable,
82}
83
84impl MediaError for PipelineError {
85    fn severity(&self) -> ErrorSeverity {
86        match self {
87            Self::Decode(e) => e.severity(),
88            Self::Filter(e) => e.severity(),
89            Self::Encode(e) => e.severity(),
90            Self::Cancelled | Self::FrameNotAvailable => ErrorSeverity::Other,
91            Self::NoInput | Self::NoOutput | Self::SecondaryInputWithoutFilter | Self::Io(_) => {
92                ErrorSeverity::Fatal
93            }
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::error::Error;
101
102    use ff_format::MediaError;
103
104    use super::PipelineError;
105
106    // --- Display messages: unit variants ---
107
108    #[test]
109    fn no_input_should_display_correct_message() {
110        let err = PipelineError::NoInput;
111        assert_eq!(err.to_string(), "no input specified");
112    }
113
114    #[test]
115    fn no_output_should_display_correct_message() {
116        let err = PipelineError::NoOutput;
117        assert_eq!(err.to_string(), "no output specified");
118    }
119
120    #[test]
121    fn cancelled_should_display_correct_message() {
122        let err = PipelineError::Cancelled;
123        assert_eq!(err.to_string(), "pipeline cancelled by caller");
124    }
125
126    // --- Display messages: wrapping variants ---
127
128    #[test]
129    fn decode_should_prefix_inner_message() {
130        let err = PipelineError::Decode(ff_decode::DecodeError::decoding_failed("test error"));
131        assert!(err.to_string().starts_with("decode failed:"));
132    }
133
134    #[test]
135    fn filter_should_prefix_inner_message() {
136        let err = PipelineError::Filter(ff_filter::FilterError::BuildFailed);
137        assert_eq!(
138            err.to_string(),
139            "filter failed: failed to build filter graph"
140        );
141    }
142
143    #[test]
144    fn encode_should_prefix_inner_message() {
145        let err = PipelineError::Encode(ff_encode::EncodeError::Cancelled);
146        assert_eq!(err.to_string(), "encode failed: Encoding cancelled by user");
147    }
148
149    // --- From conversions ---
150
151    #[test]
152    fn decode_error_should_convert_into_pipeline_error() {
153        let inner = ff_decode::DecodeError::decoding_failed("test error");
154        let err: PipelineError = inner.into();
155        assert!(matches!(err, PipelineError::Decode(_)));
156    }
157
158    #[test]
159    fn filter_error_should_convert_into_pipeline_error() {
160        let inner = ff_filter::FilterError::BuildFailed;
161        let err: PipelineError = inner.into();
162        assert!(matches!(err, PipelineError::Filter(_)));
163    }
164
165    #[test]
166    fn encode_error_should_convert_into_pipeline_error() {
167        let inner = ff_encode::EncodeError::Cancelled;
168        let err: PipelineError = inner.into();
169        assert!(matches!(err, PipelineError::Encode(_)));
170    }
171
172    // --- std::error::Error::source() ---
173
174    #[test]
175    fn decode_should_expose_source() {
176        let err = PipelineError::Decode(ff_decode::DecodeError::decoding_failed("test error"));
177        assert!(err.source().is_some());
178    }
179
180    #[test]
181    fn filter_should_expose_source() {
182        let err = PipelineError::Filter(ff_filter::FilterError::BuildFailed);
183        assert!(err.source().is_some());
184    }
185
186    #[test]
187    fn encode_should_expose_source() {
188        let err = PipelineError::Encode(ff_encode::EncodeError::Cancelled);
189        assert!(err.source().is_some());
190    }
191
192    #[test]
193    fn unit_variants_should_have_no_source() {
194        assert!(PipelineError::NoInput.source().is_none());
195        assert!(PipelineError::NoOutput.source().is_none());
196        assert!(PipelineError::Cancelled.source().is_none());
197    }
198
199    // --- Io variant ---
200
201    #[test]
202    fn io_error_should_convert_into_pipeline_error() {
203        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
204        let err: PipelineError = inner.into();
205        assert!(matches!(err, PipelineError::Io(_)));
206    }
207
208    #[test]
209    fn io_error_should_display_correct_message() {
210        let inner = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
211        let err: PipelineError = inner.into();
212        assert_eq!(err.to_string(), "i/o error: access denied");
213    }
214
215    #[test]
216    fn io_error_should_expose_source() {
217        let inner = std::io::Error::new(std::io::ErrorKind::Other, "some error");
218        let err: PipelineError = inner.into();
219        assert!(err.source().is_some());
220    }
221
222    #[test]
223    fn pipeline_io_should_be_fatal() {
224        let e: PipelineError = std::io::Error::other("x").into();
225        assert!(e.is_fatal() && !e.is_recoverable());
226    }
227
228    #[test]
229    fn pipeline_cancelled_should_be_other() {
230        let e = PipelineError::Cancelled;
231        assert!(!e.is_fatal() && !e.is_recoverable());
232    }
233
234    #[test]
235    fn pipeline_decode_should_delegate_recoverable() {
236        // A recoverable inner DecodeError must remain recoverable through the wrapper.
237        let e = PipelineError::Decode(ff_decode::DecodeError::decoding_failed("x"));
238        assert!(e.is_recoverable() && !e.is_fatal());
239    }
240}