1use std::io::Error as IoError;
4
5use {reqwest::Error as ReqwestError, thiserror::Error};
6
7#[derive(Error, Debug)]
11pub enum QobuzApiError {
12 #[error("Authentication failed: {message}")]
14 AuthenticationError {
15 message: String,
17 },
18
19 #[error("HTTP request failed: {0}")]
21 HttpError(#[from] ReqwestError),
22
23 #[error("I/O error: {0}")]
25 IoError(#[from] IoError),
26
27 #[error("API error {code}: {message}")]
29 ApiErrorResponse {
30 code: i32,
32 message: String,
34 status: String,
36 },
37
38 #[error("Failed to parse API response: {content}")]
40 ApiResponseParseError {
41 content: String,
43 source_info: Option<String>,
45 },
46
47 #[error("Service initialization failed: {message}")]
49 InitializationError {
50 message: String,
52 },
53
54 #[error("Invalid credentials: {message}")]
56 CredentialsError {
57 message: String,
59 },
60
61 #[error("Download failed: {message}")]
63 DownloadError {
64 message: String,
66 },
67
68 #[error("Metadata error: {0}")]
70 MetadataError(String),
71
72 #[error("{resource_type} not found: {resource_id}")]
74 ResourceNotFoundError {
75 resource_type: String,
77 resource_id: String,
79 },
80
81 #[error("Rate limited: {message}")]
83 RateLimitError {
84 message: String,
86 },
87
88 #[error("Invalid parameter: {message}")]
90 InvalidParameterError {
91 message: String,
93 },
94
95 #[error("Unexpected API response: {message}")]
97 UnexpectedApiResponseError {
98 message: String,
100 },
101
102 #[error("Download cancelled")]
104 Canceled,
105}
106
107#[cfg(test)]
108mod tests {
109 use std::io::{Error, ErrorKind};
110
111 use {reqwest::Client, tokio::runtime::Runtime};
112
113 use crate::errors::QobuzApiError::{
114 self, ApiErrorResponse, ApiResponseParseError, AuthenticationError, Canceled,
115 CredentialsError, DownloadError, HttpError, InitializationError, InvalidParameterError,
116 IoError, MetadataError, RateLimitError, ResourceNotFoundError, UnexpectedApiResponseError,
117 };
118
119 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
120
121 #[test]
122 fn all_variants_satisfy_send_sync_static() {
123 assert_send_sync_static::<QobuzApiError>();
124 }
125
126 #[test]
127 fn authentication_error_display() {
128 let err = AuthenticationError {
129 message: "bad token".into(),
130 };
131 let msg = format!("{err}");
132 assert!(msg.contains("Authentication failed"));
133 assert!(msg.contains("bad token"));
134 }
135
136 #[test]
137 fn http_error_display() {
138 let Ok(rt) = Runtime::new() else {
139 return;
140 };
141 let result = rt.block_on(async { Client::new().get("http://localhost:1").send().await });
142 let Err(req_err) = result else { return };
143 let err = HttpError(req_err);
144 let msg = format!("{err}");
145 assert!(msg.contains("HTTP request failed"));
146 }
147
148 #[test]
149 fn io_error_display() {
150 let err = IoError(Error::new(ErrorKind::NotFound, "file missing"));
151 let msg = format!("{err}");
152 assert!(msg.contains("I/O error"));
153 }
154
155 #[test]
156 fn api_error_response_display() {
157 let err = ApiErrorResponse {
158 code: 403,
159 message: "Forbidden".into(),
160 status: "403".into(),
161 };
162 let msg = format!("{err}");
163 assert!(msg.contains("API error 403"));
164 assert!(msg.contains("Forbidden"));
165 }
166
167 #[test]
168 fn api_response_parse_error_display() {
169 let err = ApiResponseParseError {
170 content: "not json".into(),
171 source_info: Some("expected `{`".into()),
172 };
173 let msg = format!("{err}");
174 assert!(msg.contains("Failed to parse API response"));
175 assert!(msg.contains("not json"));
176 }
177
178 #[test]
179 fn initialization_error_display() {
180 let err = InitializationError {
181 message: "no app_id".into(),
182 };
183 let msg = format!("{err}");
184 assert!(msg.contains("Service initialization failed"));
185 }
186
187 #[test]
188 fn credentials_error_display() {
189 let err = CredentialsError {
190 message: "missing email".into(),
191 };
192 let msg = format!("{err}");
193 assert!(msg.contains("Invalid credentials"));
194 }
195
196 #[test]
197 fn download_error_display() {
198 let err = DownloadError {
199 message: "disk full".into(),
200 };
201 let msg = format!("{err}");
202 assert!(msg.contains("Download failed"));
203 }
204
205 #[test]
206 fn metadata_error_display() {
207 let err = MetadataError("tag write failed".into());
208 let msg = format!("{err}");
209 assert!(msg.contains("Metadata error"));
210 }
211
212 #[test]
213 fn resource_not_found_error_display() {
214 let err = ResourceNotFoundError {
215 resource_type: "album".into(),
216 resource_id: "123".into(),
217 };
218 let msg = format!("{err}");
219 assert!(msg.contains("album not found"));
220 assert!(msg.contains("123"));
221 }
222
223 #[test]
224 fn rate_limit_error_display() {
225 let err = RateLimitError {
226 message: "too many requests".into(),
227 };
228 let msg = format!("{err}");
229 assert!(msg.contains("Rate limited"));
230 }
231
232 #[test]
233 fn invalid_parameter_error_display() {
234 let err = InvalidParameterError {
235 message: "negative id".into(),
236 };
237 let msg = format!("{err}");
238 assert!(msg.contains("Invalid parameter"));
239 }
240
241 #[test]
242 fn unexpected_api_response_error_has_remediation() {
243 let err = UnexpectedApiResponseError {
244 message: "missing field".into(),
245 };
246 let msg = format!("{err}").to_lowercase();
247 assert!(msg.contains("unexpected") || msg.contains("response") || msg.contains("missing"));
248 }
249
250 #[test]
251 fn canceled_error_display() {
252 let err = Canceled;
253 let msg = format!("{err}");
254 assert_eq!(msg, "Download cancelled");
255 }
256
257 #[test]
258 fn sc006_authentication_error_has_remediation() {
259 let err = AuthenticationError {
260 message: "Invalid credentials. Check your QOBUZ_EMAIL and QOBUZ_PASSWORD environment \
261 variables"
262 .into(),
263 };
264 let msg = format!("{err}").to_lowercase();
265 assert!(msg.contains("credentials") || msg.contains("check") || msg.contains("verify"));
266 }
267
268 #[test]
269 fn sc006_credentials_error_has_remediation() {
270 let err = CredentialsError {
271 message: "Failed to extract credentials from web player. Configure QOBUZ_APP_ID and \
272 QOBUZ_APP_SECRET manually"
273 .into(),
274 };
275 let msg = format!("{err}").to_lowercase();
276 assert!(msg.contains("configure") || msg.contains("manual") || msg.contains("set"));
277 }
278
279 #[test]
280 fn sc006_resource_not_found_error_has_remediation() {
281 let err = ResourceNotFoundError {
282 resource_type: "album".into(),
283 resource_id: "xyz".into(),
284 };
285 let msg = format!("{err}").to_lowercase();
286 assert!(msg.contains("not found"));
287 }
288
289 #[test]
290 fn sc006_download_error_has_remediation() {
291 let err = DownloadError {
292 message: "Failed to write file. Check disk space and permissions".into(),
293 };
294 let msg = format!("{err}").to_lowercase();
295 assert!(msg.contains("check") || msg.contains("disk") || msg.contains("permission"));
296 }
297
298 #[test]
299 fn sc006_initialization_error_has_remediation() {
300 let err = InitializationError {
301 message: "app_id and app_secret must be non-empty. Verify your credentials \
302 configuration"
303 .into(),
304 };
305 let msg = format!("{err}").to_lowercase();
306 assert!(msg.contains("verify") || msg.contains("check") || msg.contains("must"));
307 }
308
309 #[test]
310 fn sc006_rate_limit_error_has_remediation() {
311 let err = RateLimitError {
312 message: "Rate limited, retry 3/3. Wait before retrying".into(),
313 };
314 let msg = format!("{err}").to_lowercase();
315 assert!(msg.contains("wait") || msg.contains("retry") || msg.contains("limited"));
316 }
317
318 #[test]
319 fn sc006_metadata_error_has_remediation() {
320 let err = MetadataError(
321 "Probe open failed. Verify the file exists and is a valid audio format".into(),
322 );
323 let msg = format!("{err}").to_lowercase();
324 assert!(msg.contains("verify") || msg.contains("valid") || msg.contains("check"));
325 }
326}