Skip to main content

mfp_core/
error.rs

1//! The crate error type and the stable machine-readable codes clients branch on.
2//!
3//! Every variant maps to exactly one [`ErrorCode`]. The codes are wire contract and stable
4//! across releases; the human-readable messages are not, and must never be parsed.
5
6use serde::{Deserialize, Serialize};
7
8/// A stable machine-readable error code.
9///
10/// Any condition not covered by a more specific code uses [`ErrorCode::Internal`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum ErrorCode {
15    /// The request line was not valid JSON, was not an object, or lacked `id` or `cmd`.
16    InvalidRequest,
17    /// The `cmd.type` field named a command the daemon does not implement.
18    UnknownCommand,
19    /// The arguments parsed but fall outside their permitted domain.
20    InvalidParams,
21    /// No catalog episode has the requested identifier.
22    UnknownEpisode,
23    /// The command needs a loaded episode and none is loaded.
24    NotPlaying,
25    /// The current audio source cannot seek.
26    SeekUnsupported,
27    /// Audio output, decoding, or the audio transfer failed.
28    PlaybackFailed,
29    /// The catalog could not be served from cache or network.
30    CatalogUnavailable,
31    /// A download transfer failed or its verification did not pass.
32    DownloadFailed,
33    /// No download is running for the named episode.
34    DownloadNotActive,
35    /// The cache filesystem has less free space than the download requires.
36    InsufficientSpace,
37    /// Anything not covered by a more specific code.
38    Internal,
39    /// A spelling this release does not know, which a newer daemon sent.
40    ///
41    /// The protocol names no version, so an added code costs a client that one code rather
42    /// than the whole response line.
43    #[serde(other)]
44    Unknown,
45}
46
47impl ErrorCode {
48    /// The code's wire spelling.
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::InvalidRequest => "invalid_request",
52            Self::UnknownCommand => "unknown_command",
53            Self::InvalidParams => "invalid_params",
54            Self::UnknownEpisode => "unknown_episode",
55            Self::NotPlaying => "not_playing",
56            Self::SeekUnsupported => "seek_unsupported",
57            Self::PlaybackFailed => "playback_failed",
58            Self::CatalogUnavailable => "catalog_unavailable",
59            Self::DownloadFailed => "download_failed",
60            Self::DownloadNotActive => "download_not_active",
61            Self::InsufficientSpace => "insufficient_space",
62            Self::Internal => "internal",
63            Self::Unknown => "unknown",
64        }
65    }
66}
67
68impl std::fmt::Display for ErrorCode {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74/// Any failure the player can report.
75#[derive(Debug, thiserror::Error)]
76#[non_exhaustive]
77pub enum Error {
78    #[error("invalid request: {0}")]
79    InvalidRequest(String),
80
81    #[error("unknown command: {0}")]
82    UnknownCommand(String),
83
84    #[error("invalid parameters: {0}")]
85    InvalidParams(String),
86
87    #[error("no episode with identifier {0}")]
88    UnknownEpisode(String),
89
90    #[error("nothing is loaded")]
91    NotPlaying,
92
93    #[error("the current source does not support seeking")]
94    SeekUnsupported,
95
96    #[error("playback failed: {0}")]
97    PlaybackFailed(String),
98
99    #[error("catalog unavailable: {0}")]
100    CatalogUnavailable(String),
101
102    #[error("download failed: {0}")]
103    DownloadFailed(String),
104
105    #[error("no download in progress for {0}")]
106    DownloadNotActive(String),
107
108    #[error("{required_bytes} bytes are required but only {available_bytes} are free")]
109    InsufficientSpace {
110        required_bytes: u64,
111        available_bytes: u64,
112    },
113
114    #[error("{0}")]
115    Internal(String),
116
117    /// An I/O failure, kept whole so a caller can still read its [`std::io::ErrorKind`].
118    #[error("{0}")]
119    Io(#[from] std::io::Error),
120}
121
122impl Error {
123    /// The stable code for this failure.
124    pub fn code(&self) -> ErrorCode {
125        match self {
126            Self::InvalidRequest(_) => ErrorCode::InvalidRequest,
127            Self::UnknownCommand(_) => ErrorCode::UnknownCommand,
128            Self::InvalidParams(_) => ErrorCode::InvalidParams,
129            Self::UnknownEpisode(_) => ErrorCode::UnknownEpisode,
130            Self::NotPlaying => ErrorCode::NotPlaying,
131            Self::SeekUnsupported => ErrorCode::SeekUnsupported,
132            Self::PlaybackFailed(_) => ErrorCode::PlaybackFailed,
133            Self::CatalogUnavailable(_) => ErrorCode::CatalogUnavailable,
134            Self::DownloadFailed(_) => ErrorCode::DownloadFailed,
135            Self::DownloadNotActive(_) => ErrorCode::DownloadNotActive,
136            Self::InsufficientSpace { .. } => ErrorCode::InsufficientSpace,
137            Self::Internal(_) | Self::Io(_) => ErrorCode::Internal,
138        }
139    }
140}
141
142pub type Result<T> = std::result::Result<T, Error>;
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn every_variant_maps_to_its_spec_code() {
150        let cases: Vec<(Error, &str)> = vec![
151            (Error::InvalidRequest("x".into()), "invalid_request"),
152            (Error::UnknownCommand("x".into()), "unknown_command"),
153            (Error::InvalidParams("x".into()), "invalid_params"),
154            (Error::UnknownEpisode("x".into()), "unknown_episode"),
155            (Error::NotPlaying, "not_playing"),
156            (Error::SeekUnsupported, "seek_unsupported"),
157            (Error::PlaybackFailed("x".into()), "playback_failed"),
158            (Error::CatalogUnavailable("x".into()), "catalog_unavailable"),
159            (Error::DownloadFailed("x".into()), "download_failed"),
160            (Error::DownloadNotActive("x".into()), "download_not_active"),
161            (
162                Error::InsufficientSpace {
163                    required_bytes: 2,
164                    available_bytes: 1,
165                },
166                "insufficient_space",
167            ),
168            (Error::Internal("x".into()), "internal"),
169            (
170                Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
171                "internal",
172            ),
173        ];
174        for (error, code) in cases {
175            assert_eq!(error.code().as_str(), code);
176        }
177    }
178
179    #[test]
180    fn codes_serialise_to_their_wire_spelling() {
181        for code in [
182            ErrorCode::InvalidRequest,
183            ErrorCode::UnknownCommand,
184            ErrorCode::InvalidParams,
185            ErrorCode::UnknownEpisode,
186            ErrorCode::NotPlaying,
187            ErrorCode::SeekUnsupported,
188            ErrorCode::PlaybackFailed,
189            ErrorCode::CatalogUnavailable,
190            ErrorCode::DownloadFailed,
191            ErrorCode::DownloadNotActive,
192            ErrorCode::InsufficientSpace,
193            ErrorCode::Internal,
194            ErrorCode::Unknown,
195        ] {
196            let json = serde_json::to_string(&code).unwrap();
197            assert_eq!(json, format!("\"{}\"", code.as_str()));
198            assert_eq!(serde_json::from_str::<ErrorCode>(&json).unwrap(), code);
199        }
200    }
201
202    /// A newer daemon may name a code this release has never heard of, and the protocol
203    /// carries no version to negotiate it away.
204    #[test]
205    fn a_code_this_release_does_not_know_reads_as_unknown() {
206        assert_eq!(
207            serde_json::from_str::<ErrorCode>(r#""a_code_from_a_later_release""#).unwrap(),
208            ErrorCode::Unknown
209        );
210    }
211
212    #[test]
213    fn an_io_failure_keeps_its_kind_and_its_source() {
214        let error = Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
215
216        let Error::Io(source) = &error else {
217            panic!("{error:?} is not an I/O failure");
218        };
219        assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
220        assert!(std::error::Error::source(&error).is_some());
221    }
222}