Skip to main content

ff_analysis/
error.rs

1//! Error type for media-analysis operations.
2
3use ff_decode::DecodeError;
4use ff_format::{ErrorSeverity, MediaError};
5use thiserror::Error;
6
7/// Errors that can occur during media analysis (scene / silence / BPM /
8/// histogram / keyframe / black-frame / waveform).
9#[derive(Error, Debug)]
10pub enum AnalysisError {
11    /// An analysis operation failed for a structural reason (e.g. a zero
12    /// interval, a missing stream, or an unsupported format).
13    #[error("analysis failed: {reason}")]
14    Failed {
15        /// Human-readable description of why the analysis failed.
16        reason: String,
17    },
18
19    /// BPM detection failed for a structural reason (e.g. no audio stream, or a
20    /// clip too short to analyse).
21    ///
22    /// Reserved for the planned tempo detector (spectral flux + autocorrelation).
23    #[error("BPM detection failed: {reason}")]
24    BpmDetectionFailed {
25        /// Human-readable description of why BPM detection failed.
26        reason: String,
27    },
28
29    /// An error propagated from the underlying decoder.
30    #[error(transparent)]
31    Decode(#[from] DecodeError),
32}
33
34impl MediaError for AnalysisError {
35    /// Decoder-propagated errors keep the decoder's own classification; the
36    /// analysis-specific failures are fatal (the operation cannot proceed).
37    fn severity(&self) -> ErrorSeverity {
38        match self {
39            Self::Decode(e) => e.severity(),
40            Self::Failed { .. } | Self::BpmDetectionFailed { .. } => ErrorSeverity::Fatal,
41        }
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn failed_should_display_reason() {
51        let e = AnalysisError::Failed {
52            reason: "interval must be non-zero".to_string(),
53        };
54        let msg = e.to_string();
55        assert!(msg.contains("analysis failed"), "unexpected message: {msg}");
56        assert!(
57            msg.contains("interval must be non-zero"),
58            "expected reason in message: {msg}"
59        );
60    }
61
62    #[test]
63    fn failed_should_be_fatal_and_not_recoverable() {
64        let e = AnalysisError::Failed {
65            reason: "zero interval".to_string(),
66        };
67        assert!(e.is_fatal());
68        assert!(!e.is_recoverable());
69    }
70
71    #[test]
72    fn bpm_detection_failed_should_display_reason() {
73        let e = AnalysisError::BpmDetectionFailed {
74            reason: "no audio stream".to_string(),
75        };
76        let msg = e.to_string();
77        assert!(
78            msg.contains("BPM detection failed"),
79            "unexpected message: {msg}"
80        );
81        assert!(
82            msg.contains("no audio stream"),
83            "expected reason in message: {msg}"
84        );
85    }
86
87    #[test]
88    fn bpm_detection_failed_should_be_fatal_and_not_recoverable() {
89        let e = AnalysisError::BpmDetectionFailed {
90            reason: "clip too short".to_string(),
91        };
92        assert!(e.is_fatal());
93        assert!(!e.is_recoverable());
94    }
95
96    #[test]
97    fn decode_variant_should_delegate_severity_to_inner() {
98        // A recoverable decoder error must remain recoverable through the wrapper.
99        let inner = DecodeError::decoding_failed("corrupt frame");
100        let recoverable = inner.is_recoverable();
101        let e = AnalysisError::Decode(inner);
102        assert_eq!(e.is_recoverable(), recoverable);
103        assert!(e.is_recoverable());
104    }
105
106    #[test]
107    fn decode_variant_should_convert_via_from() {
108        let e: AnalysisError = DecodeError::FileNotFound {
109            path: std::path::PathBuf::from("missing.mp4"),
110        }
111        .into();
112        assert!(matches!(e, AnalysisError::Decode(_)));
113        assert!(e.is_fatal());
114    }
115}