1use std::error::Error as StdError;
2
3use thiserror::Error;
4
5pub type Result<T> = std::result::Result<T, AniError>;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ErrorCode {
9 ProviderUnavailable,
11 ProviderRequestFailed,
12 ProviderInvalidResponse,
13 ProviderRateLimited,
14 ProviderCatalogError,
15 ProviderUrlValidationFailed,
16 ProviderResponseSizeExceeded,
17
18 NoSearchResults,
20 EmptySearchQuery,
21 NoEpisodesAvailable,
22 InvalidEpisodeSelection,
23 SelectionOutOfRange,
24 CommandRequiresQuery,
25
26 NoPlayableSources,
28 SourceResolutionFailed,
29 StreamUnavailable,
30 UnsupportedEmbedHost,
31 NoNativeStreams,
32
33 DownloadFailed,
35 NoDownloadTool,
36 HlsDownloadFailed,
37 SubtitleDownloadFailed,
38 SubtitleSizeExceeded,
39 DownloadOutputError,
40
41 PlayerNotFound,
43 PlayerLaunchFailed,
44 PlayerExitedWithError,
45 AndroidTerminalRequired,
46 GeneralPlayerError,
47
48 IoError,
50 HistoryStateDirectoryError,
51 HistoryOperationFailed,
52 InvalidHistoryEntry,
53
54 UpdateCheckFailed,
56 InvalidReleaseTag,
57 InstallerExecutionFailed,
58 PlatformNotSupported,
59
60 InternalError,
62 JsonParsingError,
63 UrlParsingError,
64 InvalidInput,
65}
66
67impl ErrorCode {
68 pub const fn id(self) -> &'static str {
69 match self {
70 Self::ProviderUnavailable => "ACL-1001",
72 Self::ProviderRequestFailed => "ACL-1002",
73 Self::ProviderInvalidResponse => "ACL-1003",
74 Self::ProviderRateLimited => "ACL-1004",
75 Self::ProviderCatalogError => "ACL-1005",
76 Self::ProviderUrlValidationFailed => "ACL-1006",
77 Self::ProviderResponseSizeExceeded => "ACL-1007",
78
79 Self::NoSearchResults => "ACL-2001",
81 Self::EmptySearchQuery => "ACL-2002",
82 Self::NoEpisodesAvailable => "ACL-2003",
83 Self::InvalidEpisodeSelection => "ACL-2004",
84 Self::SelectionOutOfRange => "ACL-2005",
85 Self::CommandRequiresQuery => "ACL-2006",
86
87 Self::NoPlayableSources => "ACL-3001",
89 Self::SourceResolutionFailed => "ACL-3002",
90 Self::StreamUnavailable => "ACL-3003",
91 Self::UnsupportedEmbedHost => "ACL-3004",
92 Self::NoNativeStreams => "ACL-3005",
93
94 Self::DownloadFailed => "ACL-4001",
96 Self::NoDownloadTool => "ACL-4002",
97 Self::HlsDownloadFailed => "ACL-4003",
98 Self::SubtitleDownloadFailed => "ACL-4004",
99 Self::SubtitleSizeExceeded => "ACL-4005",
100 Self::DownloadOutputError => "ACL-4006",
101
102 Self::PlayerNotFound => "ACL-5001",
104 Self::PlayerLaunchFailed => "ACL-5002",
105 Self::PlayerExitedWithError => "ACL-5003",
106 Self::AndroidTerminalRequired => "ACL-5004",
107 Self::GeneralPlayerError => "ACL-5005",
108
109 Self::IoError => "ACL-6001",
111 Self::HistoryStateDirectoryError => "ACL-6002",
112 Self::HistoryOperationFailed => "ACL-6003",
113 Self::InvalidHistoryEntry => "ACL-6004",
114
115 Self::UpdateCheckFailed => "ACL-7001",
117 Self::InvalidReleaseTag => "ACL-7002",
118 Self::InstallerExecutionFailed => "ACL-7003",
119 Self::PlatformNotSupported => "ACL-7004",
120
121 Self::InternalError => "ACL-9001",
123 Self::JsonParsingError => "ACL-9002",
124 Self::UrlParsingError => "ACL-9003",
125 Self::InvalidInput => "ACL-9004",
126 }
127 }
128
129 pub const fn slug(self) -> &'static str {
130 match self {
131 Self::ProviderUnavailable => "provider-unavailable",
133 Self::ProviderRequestFailed => "provider-request-failed",
134 Self::ProviderInvalidResponse => "provider-invalid-response",
135 Self::ProviderRateLimited => "provider-rate-limited",
136 Self::ProviderCatalogError => "provider-catalog-error",
137 Self::ProviderUrlValidationFailed => "provider-url-validation-failed",
138 Self::ProviderResponseSizeExceeded => "provider-response-size-exceeded",
139
140 Self::NoSearchResults => "no-search-results",
142 Self::EmptySearchQuery => "empty-search-query",
143 Self::NoEpisodesAvailable => "no-episodes-available",
144 Self::InvalidEpisodeSelection => "invalid-episode-selection",
145 Self::SelectionOutOfRange => "selection-out-of-range",
146 Self::CommandRequiresQuery => "command-requires-query",
147
148 Self::NoPlayableSources => "no-playable-sources",
150 Self::SourceResolutionFailed => "source-resolution-failed",
151 Self::StreamUnavailable => "stream-unavailable",
152 Self::UnsupportedEmbedHost => "unsupported-embed-host",
153 Self::NoNativeStreams => "no-native-streams",
154
155 Self::DownloadFailed => "download-failed",
157 Self::NoDownloadTool => "no-download-tool",
158 Self::HlsDownloadFailed => "hls-download-failed",
159 Self::SubtitleDownloadFailed => "subtitle-download-failed",
160 Self::SubtitleSizeExceeded => "subtitle-size-exceeded",
161 Self::DownloadOutputError => "download-output-error",
162
163 Self::PlayerNotFound => "player-not-found",
165 Self::PlayerLaunchFailed => "player-launch-failed",
166 Self::PlayerExitedWithError => "player-exited-with-error",
167 Self::AndroidTerminalRequired => "android-terminal-required",
168 Self::GeneralPlayerError => "general-player-error",
169
170 Self::IoError => "io-error",
172 Self::HistoryStateDirectoryError => "history-state-directory-error",
173 Self::HistoryOperationFailed => "history-operation-failed",
174 Self::InvalidHistoryEntry => "invalid-history-entry",
175
176 Self::UpdateCheckFailed => "update-check-failed",
178 Self::InvalidReleaseTag => "invalid-release-tag",
179 Self::InstallerExecutionFailed => "installer-execution-failed",
180 Self::PlatformNotSupported => "platform-not-supported",
181
182 Self::InternalError => "internal-error",
184 Self::JsonParsingError => "json-parsing-error",
185 Self::UrlParsingError => "url-parsing-error",
186 Self::InvalidInput => "invalid-input",
187 }
188 }
189}
190
191#[derive(Debug, Error)]
192pub enum AniError {
193 #[error("provider unavailable: {provider}")]
195 ProviderUnavailable {
196 provider: String,
197 #[source]
198 source: Option<reqwest::Error>,
199 },
200
201 #[error("provider request failed: {provider}")]
202 ProviderRequestFailed {
203 provider: String,
204 #[source]
205 source: reqwest::Error,
206 },
207
208 #[error("provider returned invalid response: {provider}")]
209 ProviderInvalidResponse {
210 provider: String,
211 message: String,
212 #[source]
213 source: Option<Box<dyn StdError + Send + Sync>>,
214 },
215
216 #[error("provider rate limited: {provider}")]
217 ProviderRateLimited {
218 provider: String,
219 retry_after_seconds: u64,
220 },
221
222 #[error("provider catalog error: {provider}")]
223 ProviderCatalogError {
224 provider: String,
225 message: String,
226 #[source]
227 source: Option<reqwest::Error>,
228 },
229
230 #[error("provider URL validation failed: {reason}")]
231 ProviderUrlValidationFailed { reason: String },
232
233 #[error("provider response size exceeded")]
234 ProviderResponseSizeExceeded {
235 provider: String,
236 size: usize,
237 limit: usize,
238 },
239
240 #[error("no search results found")]
242 NoSearchResults,
243
244 #[error("empty search query")]
245 EmptySearchQuery,
246
247 #[error("no episodes available")]
248 NoEpisodesAvailable { anime: Option<String> },
249
250 #[error("invalid episode selection")]
251 InvalidEpisodeSelection { episode: String, reason: String },
252
253 #[error("selection out of range")]
254 SelectionOutOfRange { max: usize, selected: Option<usize> },
255
256 #[error("command requires query")]
257 CommandRequiresQuery,
258
259 #[error("no playable sources found")]
261 NoPlayableSources {
262 anime: Option<String>,
263 episode: Option<String>,
264 mode: Option<String>,
265 },
266
267 #[error("source resolution failed")]
268 SourceResolutionFailed { provider: String, reason: String },
269
270 #[error("stream unavailable")]
271 StreamUnavailable { reason: String },
272
273 #[error("unsupported embed host: {host}")]
274 UnsupportedEmbedHost { host: String },
275
276 #[error("no native streams available")]
277 NoNativeStreams { provider: String },
278
279 #[error("download failed")]
281 DownloadFailed {
282 reason: String,
283 #[source]
284 source: Option<Box<dyn StdError + Send + Sync>>,
285 },
286
287 #[error("no download tool available")]
288 NoDownloadTool,
289
290 #[error("HLS download failed")]
291 HlsDownloadFailed { reason: String },
292
293 #[error("subtitle download failed")]
294 SubtitleDownloadFailed { track: String, reason: String },
295
296 #[error("subtitle size exceeded")]
297 SubtitleSizeExceeded { size: usize, limit: usize },
298
299 #[error("download output error")]
300 DownloadOutputError { path: String, reason: String },
301
302 #[error("player not found")]
304 PlayerNotFound { executable: String },
305
306 #[error("player launch failed")]
307 PlayerLaunchFailed { executable: String, reason: String },
308
309 #[error("player exited with error")]
310 PlayerExitedWithError {
311 executable: String,
312 exit_code: Option<i32>,
313 },
314
315 #[error("Android playback requires interactive terminal")]
316 AndroidTerminalRequired,
317
318 #[error("general player error")]
319 GeneralPlayerError { reason: String },
320
321 #[error("I/O error")]
323 IoError {
324 #[source]
325 source: std::io::Error,
326 context: String,
327 },
328
329 #[error("history state directory error")]
330 HistoryStateDirectoryError,
331
332 #[error("history operation failed")]
333 HistoryOperationFailed { operation: String, reason: String },
334
335 #[error("invalid history entry")]
336 InvalidHistoryEntry { reason: String },
337
338 #[error("update check failed")]
340 UpdateCheckFailed { reason: String },
341
342 #[error("invalid release tag")]
343 InvalidReleaseTag { tag: String },
344
345 #[error("installer execution failed")]
346 InstallerExecutionFailed { exit_code: Option<i32> },
347
348 #[error("platform not supported")]
349 PlatformNotSupported { platform: String },
350
351 #[error("internal error")]
353 InternalError { message: String },
354
355 #[error("JSON parsing error")]
356 JsonParsingError {
357 #[source]
358 source: serde_json::Error,
359 context: String,
360 },
361
362 #[error("URL parsing error")]
363 UrlParsingError {
364 #[source]
365 source: url::ParseError,
366 context: String,
367 },
368
369 #[error("invalid input")]
370 InvalidInput { input: String, reason: String },
371
372 #[error("network request failed")]
374 Network(String),
375 #[error("malformed provider data")]
376 Provider(String),
377 #[error("catalog error")]
378 Catalog { provider: String, message: String },
379 #[error("episode unavailable")]
380 Unavailable(String),
381 #[error("player failed")]
382 Player(String),
383 #[error("download failed")]
384 Download(String),
385 #[error("history operation failed")]
386 History(String),
387 #[error("update failed")]
388 Update(String),
389 #[error("invalid input")]
390 Input(String),
391 #[error("I/O error")]
392 Io(#[from] std::io::Error),
393 #[error("JSON error")]
394 Json(#[from] serde_json::Error),
395 #[error("URL error")]
396 Url(#[from] url::ParseError),
397 #[error("HLS downloads require yt-dlp or FFmpeg")]
399 DownloadNoDownloader,
400 #[error("HLS download failed")]
401 HlsDownloadFailedLegacy,
402 #[error("could not determine state directory")]
403 HistoryStateDirectory,
404 #[error("player executable not found")]
405 PlayerNotFoundLegacy,
406 #[error("player launch failed")]
407 PlayerLaunchFailedLegacy,
408 #[error("player exited with error")]
409 PlayerExitFailed,
410 #[error("Android playback requires interactive terminal")]
411 PlayerAndroidTerminalRequired,
412 #[error("selection out of range")]
413 InputSelectionOutOfRange,
414 #[error("empty search query")]
415 InputEmptyQuery,
416 #[error("episode selection invalid")]
417 InputInvalidEpisode,
418 #[error("command requires query")]
419 InputRequiresQuery,
420 #[error("no results found")]
421 UnavailableNoResults,
422 #[error("no streams available")]
423 UnavailableNoStreams,
424 #[error("no episodes available")]
425 UnavailableNoEpisodes,
426}
427
428impl AniError {
429 pub fn code(&self) -> ErrorCode {
430 match self {
431 Self::ProviderUnavailable { .. } => ErrorCode::ProviderUnavailable,
434 Self::ProviderRequestFailed { .. } => ErrorCode::ProviderRequestFailed,
435 Self::ProviderInvalidResponse { .. } => ErrorCode::ProviderInvalidResponse,
436 Self::ProviderRateLimited { .. } => ErrorCode::ProviderRateLimited,
437 Self::ProviderCatalogError { .. } => ErrorCode::ProviderCatalogError,
438 Self::ProviderUrlValidationFailed { .. } => ErrorCode::ProviderUrlValidationFailed,
439 Self::ProviderResponseSizeExceeded { .. } => ErrorCode::ProviderResponseSizeExceeded,
440
441 Self::NoSearchResults => ErrorCode::NoSearchResults,
443 Self::EmptySearchQuery => ErrorCode::EmptySearchQuery,
444 Self::NoEpisodesAvailable { .. } => ErrorCode::NoEpisodesAvailable,
445 Self::InvalidEpisodeSelection { .. } => ErrorCode::InvalidEpisodeSelection,
446 Self::SelectionOutOfRange { .. } => ErrorCode::SelectionOutOfRange,
447 Self::CommandRequiresQuery => ErrorCode::CommandRequiresQuery,
448
449 Self::NoPlayableSources { .. } => ErrorCode::NoPlayableSources,
451 Self::SourceResolutionFailed { .. } => ErrorCode::SourceResolutionFailed,
452 Self::StreamUnavailable { .. } => ErrorCode::StreamUnavailable,
453 Self::UnsupportedEmbedHost { .. } => ErrorCode::UnsupportedEmbedHost,
454 Self::NoNativeStreams { .. } => ErrorCode::NoNativeStreams,
455
456 Self::DownloadFailed { .. } => ErrorCode::DownloadFailed,
458 Self::NoDownloadTool => ErrorCode::NoDownloadTool,
459 Self::HlsDownloadFailed { .. } => ErrorCode::HlsDownloadFailed,
460 Self::SubtitleDownloadFailed { .. } => ErrorCode::SubtitleDownloadFailed,
461 Self::SubtitleSizeExceeded { .. } => ErrorCode::SubtitleSizeExceeded,
462 Self::DownloadOutputError { .. } => ErrorCode::DownloadOutputError,
463
464 Self::PlayerNotFound { .. } => ErrorCode::PlayerNotFound,
466 Self::PlayerLaunchFailed { .. } => ErrorCode::PlayerLaunchFailed,
467 Self::PlayerExitedWithError { .. } => ErrorCode::PlayerExitedWithError,
468 Self::PlayerNotFoundLegacy => ErrorCode::PlayerNotFound,
469 Self::PlayerLaunchFailedLegacy => ErrorCode::PlayerLaunchFailed,
470 Self::AndroidTerminalRequired => ErrorCode::AndroidTerminalRequired,
471 Self::GeneralPlayerError { .. } => ErrorCode::GeneralPlayerError,
472
473 Self::IoError { .. } => ErrorCode::IoError,
475 Self::HistoryStateDirectoryError => ErrorCode::HistoryStateDirectoryError,
476 Self::HistoryOperationFailed { .. } => ErrorCode::HistoryOperationFailed,
477 Self::InvalidHistoryEntry { .. } => ErrorCode::InvalidHistoryEntry,
478
479 Self::UpdateCheckFailed { .. } => ErrorCode::UpdateCheckFailed,
481 Self::InvalidReleaseTag { .. } => ErrorCode::InvalidReleaseTag,
482 Self::InstallerExecutionFailed { .. } => ErrorCode::InstallerExecutionFailed,
483 Self::PlatformNotSupported { .. } => ErrorCode::PlatformNotSupported,
484
485 Self::InternalError { .. } => ErrorCode::InternalError,
487 Self::JsonParsingError { .. } => ErrorCode::JsonParsingError,
488 Self::UrlParsingError { .. } => ErrorCode::UrlParsingError,
489 Self::InvalidInput { .. } => ErrorCode::InvalidInput,
490
491 Self::Network(_) => ErrorCode::ProviderUnavailable,
493 Self::Provider(_) => ErrorCode::ProviderInvalidResponse,
494 Self::Catalog { .. } => ErrorCode::ProviderCatalogError,
495 Self::UnavailableNoResults => ErrorCode::NoSearchResults,
496 Self::InputEmptyQuery => ErrorCode::EmptySearchQuery,
497 Self::UnavailableNoEpisodes => ErrorCode::NoEpisodesAvailable,
498 Self::InputInvalidEpisode => ErrorCode::InvalidEpisodeSelection,
499 Self::InputSelectionOutOfRange => ErrorCode::SelectionOutOfRange,
500 Self::InputRequiresQuery => ErrorCode::CommandRequiresQuery,
501 Self::UnavailableNoStreams => ErrorCode::NoPlayableSources,
502 Self::Unavailable(_) => ErrorCode::StreamUnavailable,
503 Self::Download(_) => ErrorCode::DownloadFailed,
504 Self::DownloadNoDownloader => ErrorCode::NoDownloadTool,
505 Self::HlsDownloadFailedLegacy => ErrorCode::HlsDownloadFailed,
506 Self::PlayerExitFailed => ErrorCode::PlayerExitedWithError,
507 Self::PlayerAndroidTerminalRequired => ErrorCode::AndroidTerminalRequired,
508 Self::Player(_) => ErrorCode::GeneralPlayerError,
509 Self::Io(_) => ErrorCode::IoError,
510 Self::HistoryStateDirectory => ErrorCode::HistoryStateDirectoryError,
511 Self::History(_) => ErrorCode::HistoryOperationFailed,
512 Self::Update(_) => ErrorCode::UpdateCheckFailed,
513 Self::Input(_) => ErrorCode::InvalidInput,
514 Self::Json(_) => ErrorCode::JsonParsingError,
515 Self::Url(_) => ErrorCode::UrlParsingError,
516 }
517 }
518
519 pub fn key(&self) -> &'static str {
520 match self {
521 Self::ProviderUnavailable { .. } => "errors.provider.unavailable",
523 Self::ProviderRequestFailed { .. } => "errors.provider.request_failed",
524 Self::ProviderInvalidResponse { .. } => "errors.provider.invalid_response",
525 Self::ProviderRateLimited { .. } => "errors.provider.rate_limited",
526 Self::ProviderCatalogError { .. } => "errors.provider.catalog_error",
527 Self::ProviderUrlValidationFailed { .. } => "errors.provider.url_validation_failed",
528 Self::ProviderResponseSizeExceeded { .. } => "errors.provider.response_size_exceeded",
529 Self::NoSearchResults => "errors.search.no_results",
530 Self::EmptySearchQuery => "errors.search.empty_query",
531 Self::NoEpisodesAvailable { .. } => "errors.search.no_episodes",
532 Self::InvalidEpisodeSelection { .. } => "errors.search.invalid_episode",
533 Self::SelectionOutOfRange { .. } => "errors.search.selection_out_of_range",
534 Self::CommandRequiresQuery => "errors.search.requires_query",
535 Self::NoPlayableSources { .. } => "errors.streaming.no_sources",
536 Self::SourceResolutionFailed { .. } => "errors.streaming.resolution_failed",
537 Self::StreamUnavailable { .. } => "errors.streaming.unavailable",
538 Self::UnsupportedEmbedHost { .. } => "errors.streaming.unsupported_host",
539 Self::NoNativeStreams { .. } => "errors.streaming.no_native_streams",
540 Self::DownloadFailed { .. } => "errors.download.failed",
541 Self::NoDownloadTool => "errors.download.no_tool",
542 Self::HlsDownloadFailed { .. } => "errors.download.hls_failed",
543 Self::HlsDownloadFailedLegacy => "errors.download.failed",
544 Self::SubtitleDownloadFailed { .. } => "errors.download.subtitle_failed",
545 Self::SubtitleSizeExceeded { .. } => "errors.download.subtitle_size_exceeded",
546 Self::DownloadOutputError { .. } => "errors.download.output_error",
547 Self::PlayerNotFound { .. } => "errors.player.not_found",
548 Self::PlayerLaunchFailed { .. } => "errors.player.launch_failed",
549 Self::PlayerNotFoundLegacy => "errors.player.not_found",
550 Self::PlayerLaunchFailedLegacy => "errors.player.launch_failed",
551 Self::PlayerExitedWithError { .. } => "errors.player.exited_with_error",
552 Self::AndroidTerminalRequired => "errors.player.android_terminal_required",
553 Self::GeneralPlayerError { .. } => "errors.player.general_error",
554 Self::IoError { .. } => "errors.filesystem.io_error",
555 Self::HistoryStateDirectoryError => "errors.filesystem.history_state_directory",
556 Self::HistoryOperationFailed { .. } => "errors.filesystem.history_operation_failed",
557 Self::InvalidHistoryEntry { .. } => "errors.filesystem.invalid_history_entry",
558 Self::UpdateCheckFailed { .. } => "errors.external.update_check_failed",
559 Self::InvalidReleaseTag { .. } => "errors.external.invalid_release_tag",
560 Self::InstallerExecutionFailed { .. } => "errors.external.installer_execution_failed",
561 Self::PlatformNotSupported { .. } => "errors.external.platform_not_supported",
562 Self::InternalError { .. } => "errors.internal.error",
563 Self::JsonParsingError { .. } => "errors.internal.json_parsing_error",
564 Self::UrlParsingError { .. } => "errors.internal.url_parsing_error",
565 Self::InvalidInput { .. } => "errors.internal.invalid_input",
566
567 Self::Network(_) => "errors.network",
569 Self::Provider(_) => "errors.provider",
570 Self::Catalog { .. } => "errors.catalog",
571 Self::Unavailable(_) => "errors.unavailable",
572 Self::Player(_) => "errors.player",
573 Self::Download(_) => "errors.download",
574 Self::History(_) => "errors.history",
575 Self::Update(_) => "errors.update",
576 Self::Input(_) => "errors.input",
577 Self::Io(_) => "errors.io",
578 Self::Json(_) => "errors.json",
579 Self::Url(_) => "errors.url",
580 Self::DownloadNoDownloader => "errors.download.no_downloader",
581 Self::HistoryStateDirectory => "errors.history.state_directory",
582 Self::PlayerExitFailed => "errors.player.exit_failed",
583 Self::PlayerAndroidTerminalRequired => "errors.player.android_terminal_required",
584 Self::InputSelectionOutOfRange => "errors.input.selection_out_of_range",
585 Self::InputEmptyQuery => "errors.input.empty_query",
586 Self::InputInvalidEpisode => "errors.input.invalid_episode",
587 Self::InputRequiresQuery => "errors.input.requires_query",
588 Self::UnavailableNoResults => "errors.unavailable.no_results",
589 Self::UnavailableNoStreams => "errors.unavailable.no_streams",
590 Self::UnavailableNoEpisodes => "errors.unavailable.no_episodes",
591 }
592 }
593}
594
595impl From<reqwest::Error> for AniError {
596 fn from(error: reqwest::Error) -> Self {
597 let mut message = error.to_string();
598 let mut source = error.source();
599 while let Some(cause) = source {
600 let cause_message = cause.to_string();
601 if !message.ends_with(&cause_message) {
602 message.push_str(": ");
603 message.push_str(&cause_message);
604 }
605 source = cause.source();
606 }
607 Self::Network(message)
608 }
609}
610
611impl AniError {
613 pub fn io_error(source: std::io::Error, context: impl Into<String>) -> Self {
614 Self::IoError {
615 source,
616 context: context.into(),
617 }
618 }
619
620 pub fn json_error(source: serde_json::Error, context: impl Into<String>) -> Self {
621 Self::JsonParsingError {
622 source,
623 context: context.into(),
624 }
625 }
626
627 pub fn url_error(source: url::ParseError, context: impl Into<String>) -> Self {
628 Self::UrlParsingError {
629 source,
630 context: context.into(),
631 }
632 }
633}
634
635#[derive(Clone, Copy, PartialEq, Eq)]
636pub enum ErrorVerbosity {
637 Normal,
638 Verbose,
639 Debug,
640}
641
642pub struct ErrorReport<'a> {
643 pub code: ErrorCode,
644 pub title: &'static str,
645 pub message: String,
646 pub help: Option<String>,
647 pub docs: Option<&'static str>,
648 pub source: Option<&'a (dyn StdError + 'static)>,
649 pub context: Option<ErrorContext>,
650}
651
652#[derive(Clone)]
653pub struct ErrorContext {
654 pub provider: Option<String>,
655 pub anime: Option<String>,
656 pub episode: Option<String>,
657 pub mode: Option<String>,
658 pub additional_fields: Vec<(String, String)>,
659}
660
661impl<'a> ErrorReport<'a> {
662 pub fn from_error(error: &'a AniError) -> Self {
663 let code = error.code();
664 let title = match code {
665 ErrorCode::ProviderUnavailable => "Provider unavailable",
666 ErrorCode::ProviderRequestFailed => "Provider request failed",
667 ErrorCode::ProviderInvalidResponse => "Provider returned invalid response",
668 ErrorCode::ProviderRateLimited => "Provider rate limited",
669 ErrorCode::ProviderCatalogError => "Provider catalog error",
670 ErrorCode::ProviderUrlValidationFailed => "Provider URL validation failed",
671 ErrorCode::ProviderResponseSizeExceeded => "Provider response size exceeded",
672
673 ErrorCode::NoSearchResults => "No search results found",
674 ErrorCode::EmptySearchQuery => "Empty search query",
675 ErrorCode::NoEpisodesAvailable => "No episodes available",
676 ErrorCode::InvalidEpisodeSelection => "Invalid episode selection",
677 ErrorCode::SelectionOutOfRange => "Selection out of range",
678 ErrorCode::CommandRequiresQuery => "Command requires query",
679
680 ErrorCode::NoPlayableSources => "No playable sources found",
681 ErrorCode::SourceResolutionFailed => "Source resolution failed",
682 ErrorCode::StreamUnavailable => "Stream unavailable",
683 ErrorCode::UnsupportedEmbedHost => "Unsupported embed host",
684 ErrorCode::NoNativeStreams => "No native streams available",
685
686 ErrorCode::DownloadFailed => "Download failed",
687 ErrorCode::NoDownloadTool => "No download tool available",
688 ErrorCode::HlsDownloadFailed => "HLS download failed",
689 ErrorCode::SubtitleDownloadFailed => "Subtitle download failed",
690 ErrorCode::SubtitleSizeExceeded => "Subtitle size exceeded",
691 ErrorCode::DownloadOutputError => "Download output error",
692
693 ErrorCode::PlayerNotFound => "Player not found",
694 ErrorCode::PlayerLaunchFailed => "Player launch failed",
695 ErrorCode::PlayerExitedWithError => "Player exited with error",
696 ErrorCode::AndroidTerminalRequired => "Android terminal required",
697 ErrorCode::GeneralPlayerError => "General player error",
698
699 ErrorCode::IoError => "I/O error",
700 ErrorCode::HistoryStateDirectoryError => "History state directory error",
701 ErrorCode::HistoryOperationFailed => "History operation failed",
702 ErrorCode::InvalidHistoryEntry => "Invalid history entry",
703
704 ErrorCode::UpdateCheckFailed => "Update check failed",
705 ErrorCode::InvalidReleaseTag => "Invalid release tag",
706 ErrorCode::InstallerExecutionFailed => "Installer execution failed",
707 ErrorCode::PlatformNotSupported => "Platform not supported",
708
709 ErrorCode::InternalError => "Internal error",
710 ErrorCode::JsonParsingError => "JSON parsing error",
711 ErrorCode::UrlParsingError => "URL parsing error",
712 ErrorCode::InvalidInput => "Invalid input",
713 };
714
715 let message = Self::message(error);
716 let help = match code {
717 ErrorCode::ProviderUnavailable => Some("Check your internet connection, try again later, or try another provider.".to_string()),
718 ErrorCode::ProviderRateLimited => Some("Wait a few minutes and try again, or try another provider.".to_string()),
719 ErrorCode::NoSearchResults => Some("Check your search query spelling, try a different search term, or try another provider.".to_string()),
720 ErrorCode::EmptySearchQuery => Some("Provide a search query or use interactive mode if available.".to_string()),
721 ErrorCode::NoEpisodesAvailable => Some("Try another provider, check if the anime is released, or try a different anime.".to_string()),
722 ErrorCode::NoPlayableSources => Some("Try another provider, try subtitle/dub mode toggle, or try a different episode.".to_string()),
723 ErrorCode::NoDownloadTool => Some("Install yt-dlp or FFmpeg and ensure they are in your PATH.".to_string()),
724 ErrorCode::PlayerNotFound => Some("Install the player, check player installation, or update player path in configuration.".to_string()),
725 ErrorCode::AndroidTerminalRequired => Some("Use interactive terminal on Android or use download mode instead.".to_string()),
726 ErrorCode::InternalError => Some("Run again with --verbose flag and report the issue with full error details.".to_string()),
727 _ => None,
728 };
729
730 let docs = Some("https://vorlie.github.io/ani-cli-rs/errors");
731
732 let context = Self::extract_context(error);
734
735 Self {
736 code,
737 title,
738 message,
739 help,
740 docs,
741 source: error.source(),
742 context,
743 }
744 }
745
746 fn extract_context(error: &AniError) -> Option<ErrorContext> {
747 let mut ctx = ErrorContext {
748 provider: None,
749 anime: None,
750 episode: None,
751 mode: None,
752 additional_fields: Vec::new(),
753 };
754
755 match error {
756 AniError::ProviderUnavailable { provider, .. } => {
757 ctx.provider = Some(provider.clone());
758 }
759 AniError::ProviderRequestFailed { provider, .. } => {
760 ctx.provider = Some(provider.clone());
761 }
762 AniError::ProviderInvalidResponse { provider, .. } => {
763 ctx.provider = Some(provider.clone());
764 }
765 AniError::ProviderRateLimited {
766 provider,
767 retry_after_seconds,
768 } => {
769 ctx.provider = Some(provider.clone());
770 ctx.additional_fields
771 .push(("retry_after".to_string(), format!("{retry_after_seconds}s")));
772 }
773 AniError::ProviderCatalogError { provider, .. } => {
774 ctx.provider = Some(provider.clone());
775 }
776 AniError::NoPlayableSources {
777 anime,
778 episode,
779 mode,
780 } => {
781 ctx.anime = anime.clone();
782 ctx.episode = episode.clone();
783 ctx.mode = mode.clone();
784 }
785 AniError::PlayerNotFound { executable } => {
786 ctx.additional_fields
787 .push(("executable".to_string(), executable.clone()));
788 }
789 AniError::PlayerLaunchFailed { executable, .. } => {
790 ctx.additional_fields
791 .push(("executable".to_string(), executable.clone()));
792 }
793 AniError::PlayerExitedWithError {
794 executable,
795 exit_code,
796 } => {
797 ctx.additional_fields
798 .push(("executable".to_string(), executable.clone()));
799 if let Some(code) = exit_code {
800 ctx.additional_fields
801 .push(("exit_code".to_string(), code.to_string()));
802 }
803 }
804 AniError::SubtitleSizeExceeded { size, limit } => {
805 ctx.additional_fields
806 .push(("size".to_string(), format!("{} bytes", size)));
807 ctx.additional_fields
808 .push(("limit".to_string(), format!("{} bytes", limit)));
809 }
810 AniError::ProviderResponseSizeExceeded {
811 provider,
812 size,
813 limit,
814 } => {
815 ctx.provider = Some(provider.clone());
816 ctx.additional_fields
817 .push(("size".to_string(), format!("{} bytes", size)));
818 ctx.additional_fields
819 .push(("limit".to_string(), format!("{} bytes", limit)));
820 }
821 _ => {}
822 }
823
824 if ctx.provider.is_some() || ctx.anime.is_some() || !ctx.additional_fields.is_empty() {
825 Some(ctx)
826 } else {
827 None
828 }
829 }
830
831 fn message(error: &AniError) -> String {
832 match error {
833 AniError::ProviderUnavailable { provider, .. } => {
834 format!("{provider} could not be reached.")
835 }
836 AniError::ProviderRequestFailed { provider, .. } => {
837 format!("A request to {provider} failed.")
838 }
839 AniError::ProviderInvalidResponse {
840 provider, message, ..
841 } => format!("{provider} returned data ani-cli-rs could not understand: {message}"),
842 AniError::ProviderRateLimited {
843 provider,
844 retry_after_seconds,
845 } => format!("{provider} asked us to retry after {retry_after_seconds} seconds."),
846 AniError::ProviderCatalogError {
847 provider, message, ..
848 } => format!("{provider}'s catalog request failed: {message}"),
849 AniError::ProviderUrlValidationFailed { reason } => {
850 format!("A provider URL failed validation: {reason}")
851 }
852 AniError::ProviderResponseSizeExceeded {
853 provider,
854 size,
855 limit,
856 } => format!("{provider} returned {size} bytes, exceeding the {limit}-byte limit."),
857 AniError::NoEpisodesAvailable { anime: Some(anime) } => {
858 format!("No episodes are available for {anime}.")
859 }
860 AniError::InvalidEpisodeSelection { episode, reason } => {
861 format!("Episode selection {episode:?} is invalid: {reason}")
862 }
863 AniError::SelectionOutOfRange { max, selected } => match selected {
864 Some(selected) => {
865 format!("Selection {selected} is outside the available range (1–{max}).")
866 }
867 None => format!("The selection is outside the available range (1–{max})."),
868 },
869 AniError::SourceResolutionFailed { provider, reason } => {
870 format!("Could not resolve a playable source from {provider}: {reason}")
871 }
872 AniError::StreamUnavailable { reason } => {
873 format!("The selected stream is unavailable: {reason}")
874 }
875 AniError::UnsupportedEmbedHost { host } => {
876 format!("The provider uses unsupported embed host {host}.")
877 }
878 AniError::NoNativeStreams { provider } => {
879 format!("{provider} did not provide a supported native stream.")
880 }
881 AniError::NoPlayableSources {
882 anime,
883 episode,
884 mode,
885 } => {
886 let context = match (anime, episode, mode) {
887 (Some(anim), Some(epi), Some(m)) => {
888 format!(" for {anim} — Episode {epi} ({m})")
889 }
890 (Some(anim), Some(epi), None) => {
891 format!(" for {anim} — Episode {epi}")
892 }
893 (Some(anim), None, Some(m)) => {
894 format!(" for {anim} ({m})")
895 }
896 (None, Some(epi), Some(m)) => {
897 format!(" for episode {epi} ({m})")
898 }
899 (Some(anim), None, None) => {
900 format!(" for {anim}")
901 }
902 (None, Some(epi), None) => {
903 format!(" for episode {epi}")
904 }
905 (None, None, Some(m)) => {
906 format!(" ({m})")
907 }
908 (None, None, None) => String::new(),
909 };
910 format!("No playable sources were found{context}.")
911 }
912 AniError::DownloadFailed { reason, .. } | AniError::HlsDownloadFailed { reason } => {
913 format!("Download failed: {reason}")
914 }
915 AniError::SubtitleDownloadFailed { track, reason } => {
916 format!("Could not download subtitle track {track:?}: {reason}")
917 }
918 AniError::SubtitleSizeExceeded { size, limit } => {
919 format!("The subtitle track is {size} bytes, exceeding the {limit}-byte limit.")
920 }
921 AniError::DownloadOutputError { path, reason } => {
922 format!("Could not write download output to {path}: {reason}")
923 }
924 AniError::PlayerNotFound { executable } => {
925 format!("Player executable {executable:?} was not found.")
926 }
927 AniError::PlayerLaunchFailed { executable, reason } => {
928 format!("Could not launch {executable:?}: {reason}")
929 }
930 AniError::PlayerExitedWithError {
931 executable,
932 exit_code,
933 } => match exit_code {
934 Some(code) => format!("{executable:?} exited with status {code}."),
935 None => format!("{executable:?} exited unsuccessfully."),
936 },
937 AniError::GeneralPlayerError { reason } => format!("Player failed: {reason}"),
938 AniError::IoError { source, context } => format!("Could not {context}: {source}"),
939 AniError::HistoryOperationFailed { operation, reason } => {
940 format!("Could not {operation} history: {reason}")
941 }
942 AniError::InvalidHistoryEntry { reason } => format!("Invalid history entry: {reason}"),
943 AniError::UpdateCheckFailed { reason } => {
944 format!("Could not check for updates: {reason}")
945 }
946 AniError::InvalidReleaseTag { tag } => {
947 format!("GitHub returned an invalid release tag: {tag}")
948 }
949 AniError::InstallerExecutionFailed { exit_code } => match exit_code {
950 Some(code) => format!("The installer exited with status {code}."),
951 None => "Could not start the installer.".to_string(),
952 },
953 AniError::PlatformNotSupported { platform } => {
954 format!("Automatic updates are not supported on {platform}.")
955 }
956 AniError::InternalError { message } => message.clone(),
957 AniError::JsonParsingError { context, .. } => {
958 format!("Could not parse JSON while {context}.")
959 }
960 AniError::UrlParsingError { context, .. } => {
961 format!("Could not parse a URL while {context}.")
962 }
963 AniError::InvalidInput { input, reason } => {
964 format!("Invalid input {input:?}: {reason}")
965 }
966 _ => error.to_string(),
967 }
968 }
969
970 pub fn render(&self, verbosity: ErrorVerbosity) -> String {
971 match verbosity {
972 ErrorVerbosity::Normal => self.render_normal(),
973 ErrorVerbosity::Verbose => self.render_verbose(),
974 ErrorVerbosity::Debug => self.render_debug(),
975 }
976 }
977
978 fn render_normal(&self) -> String {
979 let mut output = String::new();
980 output.push_str(&format!("error[{}]: {}\n", self.code.id(), self.title));
981 output.push('\n');
982 output.push_str(&self.message);
983 output.push('\n');
984
985 if let Some(help) = &self.help {
986 output.push('\n');
987 output.push_str(&format!("help: {}", help));
988 }
989
990 if let Some(docs) = self.docs {
991 output.push_str(&format!("\ndocs: {docs}#{}", self.code.id()));
992 }
993
994 output
995 }
996
997 fn render_verbose(&self) -> String {
998 let mut output = String::new();
999 output.push_str(&format!("error[{}]: {}\n", self.code.id(), self.title));
1000 output.push('\n');
1001 output.push_str(&self.message);
1002 output.push('\n');
1003
1004 if let Some(ctx) = &self.context {
1006 let mut context_lines = Vec::new();
1007 if let Some(provider) = &ctx.provider {
1008 context_lines.push(format!("Provider: {}", provider));
1009 }
1010 if let Some(anime) = &ctx.anime {
1011 context_lines.push(format!("Anime: {}", anime));
1012 }
1013 if let Some(episode) = &ctx.episode {
1014 context_lines.push(format!("Episode: {}", episode));
1015 }
1016 if let Some(mode) = &ctx.mode {
1017 context_lines.push(format!("Mode: {}", mode));
1018 }
1019 for (key, value) in &ctx.additional_fields {
1020 context_lines.push(format!("{}: {}", key, value));
1021 }
1022
1023 if !context_lines.is_empty() {
1024 output.push('\n');
1025 for line in context_lines {
1026 output.push_str(&format!("{}\n", line));
1027 }
1028 }
1029 }
1030
1031 if let Some(help) = &self.help {
1032 output.push('\n');
1033 output.push_str(&format!("help: {}", help));
1034 }
1035
1036 if let Some(docs) = self.docs {
1037 output.push_str(&format!("\ndocs: {docs}#{}", self.code.id()));
1038 }
1039
1040 if let Some(source) = self.source {
1042 output.push_str("\n\nCaused by:\n");
1043 let mut current = Some(source);
1044 let mut indent = 0;
1045 while let Some(err) = current {
1046 output.push_str(&format!(" {}: {}\n", " ".repeat(indent), err));
1047 current = err.source();
1048 indent += 1;
1049 if indent > 5 {
1050 output.push_str(" ...\n");
1051 break;
1052 }
1053 }
1054 }
1055
1056 output
1057 }
1058
1059 fn render_debug(&self) -> String {
1060 let mut output = String::new();
1061 output.push_str(&format!("error[{}]\n", self.code.id()));
1062 output.push('\n');
1063 output.push_str("Debug information:\n");
1064
1065 if let Some(ctx) = &self.context {
1067 if let Some(provider) = &ctx.provider {
1068 output.push_str(&format!(" provider = {}\n", provider));
1069 }
1070 if let Some(anime) = &ctx.anime {
1071 output.push_str(&format!(" anime = {}\n", anime));
1072 }
1073 if let Some(episode) = &ctx.episode {
1074 output.push_str(&format!(" episode = {}\n", episode));
1075 }
1076 if let Some(mode) = &ctx.mode {
1077 output.push_str(&format!(" mode = {}\n", mode));
1078 }
1079 for (key, value) in &ctx.additional_fields {
1080 output.push_str(&format!(" {} = {}\n", key, value));
1081 }
1082 }
1083
1084 output.push_str(&format!(" error_code = {}\n", self.code.id()));
1085 output.push_str(&format!(" error_slug = {}\n", self.code.slug()));
1086
1087 if let Some(source) = self.source {
1089 output.push_str("\nError chain:\n");
1090 let mut current = Some(source);
1091 let mut indent = 0;
1092 while let Some(err) = current {
1093 output.push_str(&format!(" [{}]: {}\n", indent, err));
1094 current = err.source();
1095 indent += 1;
1096 if indent > 10 {
1097 output.push_str(" [...]\n");
1098 break;
1099 }
1100 }
1101 }
1102
1103 output.push_str("\nUser message:\n");
1104 output.push_str(&format!(" {}\n", self.title));
1105 output.push_str(&format!(" {}\n", self.message));
1106
1107 if let Some(help) = &self.help {
1108 output.push_str("\nHelp:\n");
1109 output.push_str(&format!(" {}\n", help));
1110 }
1111
1112 if let Some(docs) = self.docs {
1113 output.push_str(&format!("\nDocumentation: {docs}#{}\n", self.code.id()));
1114 }
1115
1116 output
1117 }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::{AniError, ErrorCode, ErrorReport, ErrorVerbosity};
1123
1124 #[test]
1125 fn public_error_code_ids_are_stable() {
1126 assert_eq!(ErrorCode::ProviderUnavailable.id(), "ACL-1001");
1127 assert_eq!(ErrorCode::NoSearchResults.id(), "ACL-2001");
1128 assert_eq!(ErrorCode::NoPlayableSources.id(), "ACL-3001");
1129 assert_eq!(ErrorCode::DownloadFailed.id(), "ACL-4001");
1130 assert_eq!(ErrorCode::PlayerNotFound.id(), "ACL-5001");
1131 assert_eq!(ErrorCode::IoError.id(), "ACL-6001");
1132 assert_eq!(ErrorCode::UpdateCheckFailed.id(), "ACL-7001");
1133 assert_eq!(ErrorCode::InternalError.id(), "ACL-9001");
1134 }
1135
1136 #[test]
1137 fn verbose_rate_limit_report_includes_provider_and_retry_after() {
1138 let error = AniError::ProviderRateLimited {
1139 provider: "Anikoto.cz".to_string(),
1140 retry_after_seconds: 120,
1141 };
1142
1143 let output = ErrorReport::from_error(&error).render(ErrorVerbosity::Verbose);
1144
1145 assert!(output.contains("error[ACL-1004]: Provider rate limited"));
1146 assert!(output.contains("Provider: Anikoto.cz"));
1147 assert!(output.contains("retry_after: 120s"));
1148 assert!(output.contains("docs: https://vorlie.github.io/ani-cli-rs/errors#ACL-1004"));
1149 }
1150
1151 #[test]
1152 fn normal_report_is_compact_and_actionable() {
1153 let error = AniError::NoPlayableSources {
1154 anime: Some("One Piece".to_string()),
1155 episode: Some("1132".to_string()),
1156 mode: Some("sub".to_string()),
1157 };
1158
1159 let output = ErrorReport::from_error(&error).render(ErrorVerbosity::Normal);
1160
1161 assert!(output.contains("error[ACL-3001]: No playable sources found"));
1162 assert!(output.contains("help: Try another provider"));
1163 assert!(!output.contains("Provider:"));
1164 }
1165}