Skip to main content

ani_lib/
error.rs

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    // Network / Provider errors (ACL-1xxx)
10    ProviderUnavailable,
11    ProviderRequestFailed,
12    ProviderInvalidResponse,
13    ProviderRateLimited,
14    ProviderCatalogError,
15    ProviderUrlValidationFailed,
16    ProviderResponseSizeExceeded,
17
18    // Search / Anime / Episode errors (ACL-2xxx)
19    NoSearchResults,
20    EmptySearchQuery,
21    NoEpisodesAvailable,
22    InvalidEpisodeSelection,
23    SelectionOutOfRange,
24    CommandRequiresQuery,
25
26    // Streaming / Source errors (ACL-3xxx)
27    NoPlayableSources,
28    SourceResolutionFailed,
29    StreamUnavailable,
30    UnsupportedEmbedHost,
31    NoNativeStreams,
32
33    // Download errors (ACL-4xxx)
34    DownloadFailed,
35    NoDownloadTool,
36    HlsDownloadFailed,
37    SubtitleDownloadFailed,
38    SubtitleSizeExceeded,
39    DownloadOutputError,
40
41    // Playback errors (ACL-5xxx)
42    PlayerNotFound,
43    PlayerLaunchFailed,
44    PlayerExitedWithError,
45    AndroidTerminalRequired,
46    GeneralPlayerError,
47
48    // Filesystem / Configuration errors (ACL-6xxx)
49    IoError,
50    HistoryStateDirectoryError,
51    HistoryOperationFailed,
52    InvalidHistoryEntry,
53
54    // Authentication / External services errors (ACL-7xxx)
55    UpdateCheckFailed,
56    InvalidReleaseTag,
57    InstallerExecutionFailed,
58    PlatformNotSupported,
59
60    // Internal / Unexpected errors (ACL-9xxx)
61    InternalError,
62    JsonParsingError,
63    UrlParsingError,
64    InvalidInput,
65}
66
67impl ErrorCode {
68    pub const fn id(self) -> &'static str {
69        match self {
70            // Network / Provider errors (ACL-1xxx)
71            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            // Search / Anime / Episode errors (ACL-2xxx)
80            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            // Streaming / Source errors (ACL-3xxx)
88            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            // Download errors (ACL-4xxx)
95            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            // Playback errors (ACL-5xxx)
103            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            // Filesystem / Configuration errors (ACL-6xxx)
110            Self::IoError => "ACL-6001",
111            Self::HistoryStateDirectoryError => "ACL-6002",
112            Self::HistoryOperationFailed => "ACL-6003",
113            Self::InvalidHistoryEntry => "ACL-6004",
114
115            // Authentication / External services errors (ACL-7xxx)
116            Self::UpdateCheckFailed => "ACL-7001",
117            Self::InvalidReleaseTag => "ACL-7002",
118            Self::InstallerExecutionFailed => "ACL-7003",
119            Self::PlatformNotSupported => "ACL-7004",
120
121            // Internal / Unexpected errors (ACL-9xxx)
122            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            // Network / Provider errors (ACL-1xxx)
132            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            // Search / Anime / Episode errors (ACL-2xxx)
141            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            // Streaming / Source errors (ACL-3xxx)
149            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            // Download errors (ACL-4xxx)
156            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            // Playback errors (ACL-5xxx)
164            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            // Filesystem / Configuration errors (ACL-6xxx)
171            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            // Authentication / External services errors (ACL-7xxx)
177            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            // Internal / Unexpected errors (ACL-9xxx)
183            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    // Network / Provider errors
194    #[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    // Search / Anime / Episode errors
241    #[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    // Streaming / Source errors
260    #[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    // Download errors
280    #[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    // Playback errors
303    #[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    // Filesystem / Configuration errors
322    #[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    // Authentication / External services errors
339    #[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    // Internal / Unexpected errors
352    #[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    // Legacy variants for backward compatibility (will be deprecated)
373    #[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    // Specific user-facing error variants
398    #[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            // New structured errors
432            // Network / Provider errors (ACL-1xxx)
433            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            // Search / Anime / Episode errors (ACL-2xxx)
442            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            // Streaming / Source errors (ACL-3xxx)
450            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            // Download errors (ACL-4xxx)
457            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            // Playback errors (ACL-5xxx)
465            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            // Filesystem / Configuration errors (ACL-6xxx)
474            Self::IoError { .. } => ErrorCode::IoError,
475            Self::HistoryStateDirectoryError => ErrorCode::HistoryStateDirectoryError,
476            Self::HistoryOperationFailed { .. } => ErrorCode::HistoryOperationFailed,
477            Self::InvalidHistoryEntry { .. } => ErrorCode::InvalidHistoryEntry,
478
479            // Authentication / External services errors (ACL-7xxx)
480            Self::UpdateCheckFailed { .. } => ErrorCode::UpdateCheckFailed,
481            Self::InvalidReleaseTag { .. } => ErrorCode::InvalidReleaseTag,
482            Self::InstallerExecutionFailed { .. } => ErrorCode::InstallerExecutionFailed,
483            Self::PlatformNotSupported { .. } => ErrorCode::PlatformNotSupported,
484
485            // Internal / Unexpected errors (ACL-9xxx)
486            Self::InternalError { .. } => ErrorCode::InternalError,
487            Self::JsonParsingError { .. } => ErrorCode::JsonParsingError,
488            Self::UrlParsingError { .. } => ErrorCode::UrlParsingError,
489            Self::InvalidInput { .. } => ErrorCode::InvalidInput,
490
491            // Legacy variants for backward compatibility
492            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            // New structured errors
522            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            // Legacy variants for backward compatibility
568            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
611// Additional conversions for structured errors
612impl 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        // Extract context from error
733        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        // Add context information
1005        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        // Add error chain
1041        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        // Add detailed context
1066        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        // Add full error chain
1088        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}