Skip to main content

usenet_dl/
error.rs

1//! Error types for usenet-dl
2//!
3//! This module provides comprehensive error handling for the library, including:
4//! - Domain-specific error types (Download, PostProcess, Config, etc.)
5//! - HTTP status code mapping for API integration
6//! - Structured error responses with machine-readable error codes
7//! - Context information (stage, file path, download ID, etc.)
8
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use thiserror::Error;
12use utoipa::ToSchema;
13
14/// Result type alias for usenet-dl operations
15pub type Result<T> = std::result::Result<T, Error>;
16
17/// Main error type for usenet-dl
18///
19/// This is the primary error type used throughout the library. Each variant includes
20/// contextual information to help diagnose issues.
21#[derive(Debug, Error)]
22pub enum Error {
23    /// Configuration error with context about which setting is invalid
24    #[error("configuration error: {message}")]
25    Config {
26        /// Human-readable error message describing the configuration issue
27        message: String,
28        /// The configuration key that caused the error (e.g., "download_dir")
29        key: Option<String>,
30    },
31
32    /// Database operation failed
33    #[error("database error: {0}")]
34    Database(#[from] DatabaseError),
35
36    /// SQLx database error
37    #[error("database error: {0}")]
38    Sqlx(#[from] sqlx::Error),
39
40    /// NNTP protocol or connection error
41    #[error("NNTP error: {0}")]
42    Nntp(String),
43
44    /// Download-related error
45    #[error("download error: {0}")]
46    Download(#[from] DownloadError),
47
48    /// Post-processing error (verify, repair, extract, etc.)
49    #[error("post-processing error: {0}")]
50    PostProcess(#[from] PostProcessError),
51
52    /// Invalid NZB file
53    #[error("invalid NZB: {0}")]
54    InvalidNzb(String),
55
56    /// I/O error
57    #[error("I/O error: {0}")]
58    Io(#[from] std::io::Error),
59
60    /// Download not found
61    #[error("download not found: {0}")]
62    NotFound(String),
63
64    /// Shutdown in progress - not accepting new downloads
65    #[error("shutdown in progress: not accepting new downloads")]
66    ShuttingDown,
67
68    /// Network error
69    #[error("network error: {0}")]
70    Network(#[from] reqwest::Error),
71
72    /// Serialization error
73    #[error("serialization error: {0}")]
74    Serialization(#[from] serde_json::Error),
75
76    /// API server error
77    #[error("API server error: {0}")]
78    ApiServerError(String),
79
80    /// Folder watching error
81    #[error("folder watch error: {0}")]
82    FolderWatch(String),
83
84    /// Duplicate download detected
85    #[error("duplicate download: {0}")]
86    Duplicate(String),
87
88    /// Insufficient disk space
89    #[error("insufficient disk space: need {required} bytes, have {available} bytes")]
90    InsufficientSpace {
91        /// Number of bytes required for the operation
92        required: u64,
93        /// Number of bytes currently available on disk
94        available: u64,
95    },
96
97    /// Failed to check disk space
98    #[error("failed to check disk space: {0}")]
99    DiskSpaceCheckFailed(String),
100
101    /// External tool execution failed (par2, unrar, etc.)
102    #[error("external tool error: {0}")]
103    ExternalTool(String),
104
105    /// Operation not supported (missing binary, not implemented, etc.)
106    #[error("not supported: {0}")]
107    NotSupported(String),
108
109    /// Other error
110    #[error("{0}")]
111    Other(String),
112}
113
114/// Database-related errors
115#[derive(Debug, Error)]
116pub enum DatabaseError {
117    /// Failed to connect to database
118    #[error("failed to connect to database: {0}")]
119    ConnectionFailed(String),
120
121    /// Failed to run migrations
122    #[error("failed to run migrations: {0}")]
123    MigrationFailed(String),
124
125    /// Query failed
126    #[error("query failed: {0}")]
127    QueryFailed(String),
128
129    /// Record not found
130    #[error("record not found: {0}")]
131    NotFound(String),
132
133    /// Constraint violation (e.g., duplicate key)
134    #[error("constraint violation: {0}")]
135    ConstraintViolation(String),
136}
137
138/// Download-related errors
139#[derive(Debug, Error)]
140pub enum DownloadError {
141    /// Download not found in queue or database
142    #[error("download {id} not found")]
143    NotFound {
144        /// The download ID that was not found
145        id: i64,
146    },
147
148    /// Download files not found on disk
149    #[error("download {id} files not found at {path}")]
150    FilesNotFound {
151        /// The download ID whose files were not found
152        id: i64,
153        /// The path where the files were expected to be
154        path: PathBuf,
155    },
156
157    /// Download already in requested state
158    #[error("download {id} is already {state}")]
159    AlreadyInState {
160        /// The download ID that is already in the requested state
161        id: i64,
162        /// The current state (e.g., "paused", "completed")
163        state: String,
164    },
165
166    /// Cannot perform operation in current state
167    #[error("cannot {operation} download {id} in state {current_state}")]
168    InvalidState {
169        /// The download ID that is in an invalid state for the operation
170        id: i64,
171        /// The operation that was attempted (e.g., "pause", "resume", "retry")
172        operation: String,
173        /// The current state that prevents the operation (e.g., "downloading", "completed")
174        current_state: String,
175    },
176
177    /// Insufficient disk space to start download
178    #[error("insufficient disk space: need {required} bytes, have {available} bytes")]
179    InsufficientSpace {
180        /// Number of bytes required for the download
181        required: u64,
182        /// Number of bytes currently available on disk
183        available: u64,
184    },
185}
186
187/// Post-processing errors (PAR2 verify, repair, extraction, etc.)
188#[derive(Debug, Error)]
189pub enum PostProcessError {
190    /// PAR2 verification failed
191    #[error("PAR2 verification failed for download {id}: {reason}")]
192    VerificationFailed {
193        /// The download ID for which verification failed
194        id: i64,
195        /// The reason verification failed
196        reason: String,
197    },
198
199    /// PAR2 repair failed
200    #[error("PAR2 repair failed for download {id}: {reason}")]
201    RepairFailed {
202        /// The download ID for which repair failed
203        id: i64,
204        /// The reason repair failed
205        reason: String,
206    },
207
208    /// Archive extraction failed
209    #[error("extraction failed for {archive}: {reason}")]
210    ExtractionFailed {
211        /// The archive file that failed to extract
212        archive: PathBuf,
213        /// The reason extraction failed
214        reason: String,
215    },
216
217    /// Wrong password for encrypted archive
218    #[error("wrong password for encrypted archive {archive}")]
219    WrongPassword {
220        /// The encrypted archive that could not be opened
221        archive: PathBuf,
222    },
223
224    /// All passwords failed for archive extraction
225    #[error("all {count} passwords failed for archive {archive}")]
226    AllPasswordsFailed {
227        /// The encrypted archive that could not be opened
228        archive: PathBuf,
229        /// The number of passwords that were tried
230        count: usize,
231    },
232
233    /// No passwords available for encrypted archive
234    #[error("no passwords available for encrypted archive {archive}")]
235    NoPasswordsAvailable {
236        /// The encrypted archive that requires a password
237        archive: PathBuf,
238    },
239
240    /// File move/rename failed
241    #[error("failed to move {source_path} to {dest_path}: {reason}")]
242    MoveFailed {
243        /// The source path of the file being moved
244        source_path: PathBuf,
245        /// The destination path where the file should be moved
246        dest_path: PathBuf,
247        /// The reason the move failed
248        reason: String,
249    },
250
251    /// File collision at destination
252    #[error("file collision at {path}: {reason}")]
253    FileCollision {
254        /// The path where the collision occurred
255        path: PathBuf,
256        /// The reason for the collision (e.g., "file already exists")
257        reason: String,
258    },
259
260    /// Cleanup failed (non-fatal, usually logged as warning)
261    #[error("cleanup failed for download {id}: {reason}")]
262    CleanupFailed {
263        /// The download ID for which cleanup failed
264        id: i64,
265        /// The reason cleanup failed
266        reason: String,
267    },
268
269    /// Invalid path encountered during post-processing
270    #[error("invalid path {path}: {reason}")]
271    InvalidPath {
272        /// The invalid path that was encountered
273        path: PathBuf,
274        /// The reason the path is invalid
275        reason: String,
276    },
277
278    /// DirectUnpack failed during download
279    #[error("DirectUnpack failed for download {id}: {reason}")]
280    DirectUnpackFailed {
281        /// The download ID for which DirectUnpack failed
282        id: i64,
283        /// The reason DirectUnpack failed
284        reason: String,
285    },
286
287    /// DirectRename failed during download
288    #[error("DirectRename failed for download {id}: {reason}")]
289    DirectRenameFailed {
290        /// The download ID for which DirectRename failed
291        id: i64,
292        /// The reason DirectRename failed
293        reason: String,
294    },
295}
296
297/// API error response format
298///
299/// This structure is returned by API endpoints when an error occurs.
300/// It follows a standard format with machine-readable error codes,
301/// human-readable messages, and optional contextual details.
302///
303/// # Example JSON Response
304///
305/// ```json
306/// {
307///   "error": {
308///     "code": "not_found",
309///     "message": "Download 123 not found",
310///     "details": {
311///       "download_id": 123
312///     }
313///   }
314/// }
315/// ```
316#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
317pub struct ApiError {
318    /// The error details
319    pub error: ErrorDetail,
320}
321
322/// Detailed error information for API responses
323#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
324pub struct ErrorDetail {
325    /// Machine-readable error code (e.g., "not_found", "validation_error")
326    ///
327    /// Clients can use this for programmatic error handling.
328    pub code: String,
329
330    /// Human-readable error message
331    ///
332    /// This is suitable for displaying to end users.
333    pub message: String,
334
335    /// Optional additional context about the error
336    ///
337    /// This can include fields like download_id, file paths, validation errors, etc.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub details: Option<serde_json::Value>,
340}
341
342impl ApiError {
343    /// Create a new API error with code and message
344    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
345        Self {
346            error: ErrorDetail {
347                code: code.into(),
348                message: message.into(),
349                details: None,
350            },
351        }
352    }
353
354    /// Create an API error with additional details
355    pub fn with_details(
356        code: impl Into<String>,
357        message: impl Into<String>,
358        details: serde_json::Value,
359    ) -> Self {
360        Self {
361            error: ErrorDetail {
362                code: code.into(),
363                message: message.into(),
364                details: Some(details),
365            },
366        }
367    }
368
369    /// Create a "not found" error
370    pub fn not_found(resource: impl Into<String>) -> Self {
371        Self::new("not_found", format!("{} not found", resource.into()))
372    }
373
374    /// Create a "validation error" error
375    pub fn validation(message: impl Into<String>) -> Self {
376        Self::new("validation_error", message)
377    }
378
379    /// Create a "conflict" error
380    pub fn conflict(message: impl Into<String>) -> Self {
381        Self::new("conflict", message)
382    }
383
384    /// Create an "internal server error"
385    pub fn internal(message: impl Into<String>) -> Self {
386        Self::new("internal_error", message)
387    }
388
389    /// Create an "unauthorized" error
390    pub fn unauthorized(message: impl Into<String>) -> Self {
391        Self::new("unauthorized", message)
392    }
393
394    /// Create a "service unavailable" error
395    pub fn service_unavailable(message: impl Into<String>) -> Self {
396        Self::new("service_unavailable", message)
397    }
398}
399
400/// Convert errors to HTTP status codes for API responses
401///
402/// This trait maps domain errors to appropriate HTTP status codes.
403pub trait ToHttpStatus {
404    /// Get the HTTP status code for this error
405    fn status_code(&self) -> u16;
406
407    /// Get the machine-readable error code
408    fn error_code(&self) -> &str;
409}
410
411impl ToHttpStatus for Error {
412    fn status_code(&self) -> u16 {
413        match self {
414            // 400 Bad Request - Client error (invalid input)
415            Error::Config { .. } => 400,
416            Error::InvalidNzb(_) => 422, // Unprocessable Entity
417            Error::Duplicate(_) => 409,  // Conflict
418
419            // 404 Not Found
420            Error::NotFound(_) => 404,
421            Error::Download(DownloadError::NotFound { .. }) => 404,
422            Error::Download(DownloadError::FilesNotFound { .. }) => 404,
423
424            // 409 Conflict - Resource already in desired state
425            Error::Download(DownloadError::AlreadyInState { .. }) => 409,
426            Error::Download(DownloadError::InvalidState { .. }) => 409,
427
428            // 422 Unprocessable Entity - Semantic errors
429            Error::PostProcess(_) => 422,
430            Error::Download(DownloadError::InsufficientSpace { .. }) => 422,
431            Error::InsufficientSpace { .. } => 422,
432
433            // 500 Internal Server Error - Server-side issues
434            Error::Database(_) => 500,
435            Error::Sqlx(_) => 500,
436            Error::Io(_) => 500,
437            Error::ApiServerError(_) => 500,
438            Error::FolderWatch(_) => 500,
439            Error::DiskSpaceCheckFailed(_) => 500,
440            Error::Other(_) => 500,
441
442            // 502 Bad Gateway - External service errors
443            Error::Nntp(_) => 502,
444            Error::Network(_) => 502,
445
446            // 503 Service Unavailable
447            Error::ShuttingDown => 503,
448            Error::ExternalTool(_) => 503,
449
450            // 501 Not Implemented - Feature not supported
451            Error::NotSupported(_) => 501,
452
453            // 500 for serialization errors
454            Error::Serialization(_) => 500,
455        }
456    }
457
458    fn error_code(&self) -> &str {
459        match self {
460            Error::Config { .. } => "config_error",
461            Error::Database(_) => "database_error",
462            Error::Sqlx(_) => "database_error",
463            Error::Nntp(_) => "nntp_error",
464            Error::Download(e) => match e {
465                DownloadError::NotFound { .. } => "download_not_found",
466                DownloadError::FilesNotFound { .. } => "files_not_found",
467                DownloadError::AlreadyInState { .. } => "already_in_state",
468                DownloadError::InvalidState { .. } => "invalid_state",
469                DownloadError::InsufficientSpace { .. } => "insufficient_space",
470            },
471            Error::PostProcess(e) => match e {
472                PostProcessError::VerificationFailed { .. } => "verification_failed",
473                PostProcessError::RepairFailed { .. } => "repair_failed",
474                PostProcessError::ExtractionFailed { .. } => "extraction_failed",
475                PostProcessError::WrongPassword { .. } => "wrong_password",
476                PostProcessError::AllPasswordsFailed { .. } => "all_passwords_failed",
477                PostProcessError::NoPasswordsAvailable { .. } => "no_passwords_available",
478                PostProcessError::MoveFailed { .. } => "move_failed",
479                PostProcessError::FileCollision { .. } => "file_collision",
480                PostProcessError::CleanupFailed { .. } => "cleanup_failed",
481                PostProcessError::InvalidPath { .. } => "invalid_path",
482                PostProcessError::DirectUnpackFailed { .. } => "direct_unpack_failed",
483                PostProcessError::DirectRenameFailed { .. } => "direct_rename_failed",
484            },
485            Error::InvalidNzb(_) => "invalid_nzb",
486            Error::Io(_) => "io_error",
487            Error::NotFound(_) => "not_found",
488            Error::ShuttingDown => "shutting_down",
489            Error::Network(_) => "network_error",
490            Error::Serialization(_) => "serialization_error",
491            Error::ApiServerError(_) => "api_server_error",
492            Error::FolderWatch(_) => "folder_watch_error",
493            Error::Duplicate(_) => "duplicate",
494            Error::InsufficientSpace { .. } => "insufficient_space",
495            Error::DiskSpaceCheckFailed(_) => "disk_space_check_failed",
496            Error::ExternalTool(_) => "external_tool_error",
497            Error::NotSupported(_) => "not_supported",
498            Error::Other(_) => "internal_error",
499        }
500    }
501}
502
503impl From<Error> for ApiError {
504    fn from(error: Error) -> Self {
505        let code = error.error_code().to_string();
506        let message = error.to_string();
507
508        // Add contextual details for specific error types
509        let details = match &error {
510            Error::Download(DownloadError::NotFound { id }) => Some(serde_json::json!({
511                "download_id": id,
512            })),
513            Error::Download(DownloadError::FilesNotFound { id, path }) => Some(serde_json::json!({
514                "download_id": id,
515                "path": path,
516            })),
517            Error::Download(DownloadError::AlreadyInState { id, state }) => {
518                Some(serde_json::json!({
519                    "download_id": id,
520                    "state": state,
521                }))
522            }
523            Error::Download(DownloadError::InvalidState {
524                id,
525                operation,
526                current_state,
527            }) => Some(serde_json::json!({
528                "download_id": id,
529                "operation": operation,
530                "current_state": current_state,
531            })),
532            Error::Download(DownloadError::InsufficientSpace {
533                required,
534                available,
535            }) => Some(serde_json::json!({
536                "required_bytes": required,
537                "available_bytes": available,
538            })),
539            Error::InsufficientSpace {
540                required,
541                available,
542            } => Some(serde_json::json!({
543                "required_bytes": required,
544                "available_bytes": available,
545            })),
546            Error::PostProcess(PostProcessError::FileCollision { path, .. }) => {
547                Some(serde_json::json!({
548                    "path": path,
549                }))
550            }
551            Error::PostProcess(PostProcessError::WrongPassword { archive }) => {
552                Some(serde_json::json!({
553                    "archive": archive,
554                }))
555            }
556            Error::PostProcess(PostProcessError::AllPasswordsFailed { archive, count }) => {
557                Some(serde_json::json!({
558                    "archive": archive,
559                    "password_count": count,
560                }))
561            }
562            _ => None,
563        };
564
565        ApiError {
566            error: ErrorDetail {
567                code,
568                message,
569                details,
570            },
571        }
572    }
573}
574
575#[allow(clippy::unwrap_used, clippy::expect_used)]
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    // -----------------------------------------------------------------------
581    // Helpers: construct every Error variant for status/error_code tests
582    // -----------------------------------------------------------------------
583
584    /// Returns a vec of (Error, expected_status_code, expected_error_code) for
585    /// every reachable match arm in ToHttpStatus.
586    fn all_error_variants() -> Vec<(Error, u16, &'static str)> {
587        vec![
588            // Top-level variants
589            (
590                Error::Config {
591                    message: "bad value".into(),
592                    key: Some("download_dir".into()),
593                },
594                400,
595                "config_error",
596            ),
597            (
598                Error::InvalidNzb("missing segments".into()),
599                422,
600                "invalid_nzb",
601            ),
602            (Error::Duplicate("already queued".into()), 409, "duplicate"),
603            (Error::NotFound("download 99".into()), 404, "not_found"),
604            (
605                Error::Database(DatabaseError::QueryFailed("timeout".into())),
606                500,
607                "database_error",
608            ),
609            (
610                Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")),
611                500,
612                "io_error",
613            ),
614            (
615                Error::ApiServerError("bind failed".into()),
616                500,
617                "api_server_error",
618            ),
619            (
620                Error::FolderWatch("inotify error".into()),
621                500,
622                "folder_watch_error",
623            ),
624            (
625                Error::DiskSpaceCheckFailed("statvfs failed".into()),
626                500,
627                "disk_space_check_failed",
628            ),
629            (Error::Other("unknown".into()), 500, "internal_error"),
630            (Error::Nntp("connection reset".into()), 502, "nntp_error"),
631            (Error::ShuttingDown, 503, "shutting_down"),
632            (
633                Error::ExternalTool("par2 not found".into()),
634                503,
635                "external_tool_error",
636            ),
637            (
638                Error::NotSupported("par2 binary missing".into()),
639                501,
640                "not_supported",
641            ),
642            (
643                Error::InsufficientSpace {
644                    required: 1_000_000,
645                    available: 500,
646                },
647                422,
648                "insufficient_space",
649            ),
650            // DownloadError variants
651            (
652                Error::Download(DownloadError::NotFound { id: 42 }),
653                404,
654                "download_not_found",
655            ),
656            (
657                Error::Download(DownloadError::FilesNotFound {
658                    id: 42,
659                    path: PathBuf::from("/tmp/dl"),
660                }),
661                404,
662                "files_not_found",
663            ),
664            (
665                Error::Download(DownloadError::AlreadyInState {
666                    id: 42,
667                    state: "paused".into(),
668                }),
669                409,
670                "already_in_state",
671            ),
672            (
673                Error::Download(DownloadError::InvalidState {
674                    id: 42,
675                    operation: "pause".into(),
676                    current_state: "completed".into(),
677                }),
678                409,
679                "invalid_state",
680            ),
681            (
682                Error::Download(DownloadError::InsufficientSpace {
683                    required: 2_000_000,
684                    available: 1_000,
685                }),
686                422,
687                "insufficient_space",
688            ),
689            // PostProcessError variants
690            (
691                Error::PostProcess(PostProcessError::VerificationFailed {
692                    id: 1,
693                    reason: "corrupt".into(),
694                }),
695                422,
696                "verification_failed",
697            ),
698            (
699                Error::PostProcess(PostProcessError::RepairFailed {
700                    id: 1,
701                    reason: "too many missing".into(),
702                }),
703                422,
704                "repair_failed",
705            ),
706            (
707                Error::PostProcess(PostProcessError::ExtractionFailed {
708                    archive: PathBuf::from("test.rar"),
709                    reason: "crc error".into(),
710                }),
711                422,
712                "extraction_failed",
713            ),
714            (
715                Error::PostProcess(PostProcessError::WrongPassword {
716                    archive: PathBuf::from("secret.rar"),
717                }),
718                422,
719                "wrong_password",
720            ),
721            (
722                Error::PostProcess(PostProcessError::AllPasswordsFailed {
723                    archive: PathBuf::from("secret.rar"),
724                    count: 5,
725                }),
726                422,
727                "all_passwords_failed",
728            ),
729            (
730                Error::PostProcess(PostProcessError::NoPasswordsAvailable {
731                    archive: PathBuf::from("secret.rar"),
732                }),
733                422,
734                "no_passwords_available",
735            ),
736            (
737                Error::PostProcess(PostProcessError::MoveFailed {
738                    source_path: PathBuf::from("/tmp/a"),
739                    dest_path: PathBuf::from("/tmp/b"),
740                    reason: "permission denied".into(),
741                }),
742                422,
743                "move_failed",
744            ),
745            (
746                Error::PostProcess(PostProcessError::FileCollision {
747                    path: PathBuf::from("/dest/file.mkv"),
748                    reason: "file already exists".into(),
749                }),
750                422,
751                "file_collision",
752            ),
753            (
754                Error::PostProcess(PostProcessError::CleanupFailed {
755                    id: 1,
756                    reason: "directory not empty".into(),
757                }),
758                422,
759                "cleanup_failed",
760            ),
761            (
762                Error::PostProcess(PostProcessError::InvalidPath {
763                    path: PathBuf::from("../escape"),
764                    reason: "path traversal".into(),
765                }),
766                422,
767                "invalid_path",
768            ),
769            (
770                Error::PostProcess(PostProcessError::DirectUnpackFailed {
771                    id: 1,
772                    reason: "extraction error".into(),
773                }),
774                422,
775                "direct_unpack_failed",
776            ),
777            (
778                Error::PostProcess(PostProcessError::DirectRenameFailed {
779                    id: 1,
780                    reason: "rename error".into(),
781                }),
782                422,
783                "direct_rename_failed",
784            ),
785        ]
786    }
787
788    // -----------------------------------------------------------------------
789    // 1. Every Error variant -> correct HTTP status code
790    // -----------------------------------------------------------------------
791
792    #[test]
793    fn every_variant_maps_to_expected_status_code() {
794        for (error, expected_status, expected_code) in all_error_variants() {
795            let actual_status = error.status_code();
796            assert_eq!(
797                actual_status, expected_status,
798                "Error variant with error_code={expected_code} returned status {actual_status}, expected {expected_status}"
799            );
800        }
801    }
802
803    // -----------------------------------------------------------------------
804    // 2. Every Error variant -> correct machine-readable error code
805    // -----------------------------------------------------------------------
806
807    #[test]
808    fn every_variant_maps_to_expected_error_code() {
809        for (error, expected_status, expected_code) in all_error_variants() {
810            let actual_code = error.error_code();
811            assert_eq!(
812                actual_code, expected_code,
813                "Error variant with expected status={expected_status} returned error_code={actual_code}, expected {expected_code}"
814            );
815        }
816    }
817
818    // -----------------------------------------------------------------------
819    // Targeted status code tests for boundary categories to catch regressions
820    // if someone moves a variant between match arms.
821    // -----------------------------------------------------------------------
822
823    #[test]
824    fn config_error_is_400_not_500() {
825        let err = Error::Config {
826            message: "bad".into(),
827            key: None,
828        };
829        assert_eq!(err.status_code(), 400);
830    }
831
832    #[test]
833    fn invalid_nzb_is_422_not_400() {
834        let err = Error::InvalidNzb("bad xml".into());
835        assert_eq!(err.status_code(), 422);
836    }
837
838    #[test]
839    fn duplicate_is_409_conflict() {
840        let err = Error::Duplicate("same hash".into());
841        assert_eq!(err.status_code(), 409);
842    }
843
844    #[test]
845    fn download_not_found_is_404() {
846        let err = Error::Download(DownloadError::NotFound { id: 1 });
847        assert_eq!(err.status_code(), 404);
848    }
849
850    #[test]
851    fn download_files_not_found_is_404() {
852        let err = Error::Download(DownloadError::FilesNotFound {
853            id: 1,
854            path: PathBuf::from("/tmp"),
855        });
856        assert_eq!(err.status_code(), 404);
857    }
858
859    #[test]
860    fn download_already_in_state_is_409() {
861        let err = Error::Download(DownloadError::AlreadyInState {
862            id: 1,
863            state: "paused".into(),
864        });
865        assert_eq!(err.status_code(), 409);
866    }
867
868    #[test]
869    fn download_invalid_state_is_409() {
870        let err = Error::Download(DownloadError::InvalidState {
871            id: 1,
872            operation: "resume".into(),
873            current_state: "completed".into(),
874        });
875        assert_eq!(err.status_code(), 409);
876    }
877
878    #[test]
879    fn nntp_error_is_502_bad_gateway() {
880        let err = Error::Nntp("connection refused".into());
881        assert_eq!(err.status_code(), 502);
882    }
883
884    #[test]
885    fn shutting_down_is_503() {
886        assert_eq!(Error::ShuttingDown.status_code(), 503);
887    }
888
889    #[test]
890    fn not_supported_is_501() {
891        let err = Error::NotSupported("feature X".into());
892        assert_eq!(err.status_code(), 501);
893    }
894
895    // -----------------------------------------------------------------------
896    // 3. Error -> ApiError preserves structured details
897    // -----------------------------------------------------------------------
898
899    #[test]
900    fn api_error_from_download_not_found_has_download_id() {
901        let err = Error::Download(DownloadError::NotFound { id: 42 });
902        let api: ApiError = err.into();
903
904        assert_eq!(api.error.code, "download_not_found");
905        let details = api.error.details.expect("should have details");
906        assert_eq!(details["download_id"], 42);
907    }
908
909    #[test]
910    fn api_error_from_download_files_not_found_has_id_and_path() {
911        let err = Error::Download(DownloadError::FilesNotFound {
912            id: 7,
913            path: PathBuf::from("/data/downloads/test"),
914        });
915        let api: ApiError = err.into();
916
917        assert_eq!(api.error.code, "files_not_found");
918        let details = api.error.details.expect("should have details");
919        assert_eq!(details["download_id"], 7);
920        assert_eq!(details["path"], "/data/downloads/test");
921    }
922
923    #[test]
924    fn api_error_from_already_in_state_has_id_and_state() {
925        let err = Error::Download(DownloadError::AlreadyInState {
926            id: 10,
927            state: "paused".into(),
928        });
929        let api: ApiError = err.into();
930
931        assert_eq!(api.error.code, "already_in_state");
932        let details = api.error.details.expect("should have details");
933        assert_eq!(details["download_id"], 10);
934        assert_eq!(details["state"], "paused");
935    }
936
937    #[test]
938    fn api_error_from_invalid_state_has_operation_and_current_state() {
939        let err = Error::Download(DownloadError::InvalidState {
940            id: 3,
941            operation: "pause".into(),
942            current_state: "completed".into(),
943        });
944        let api: ApiError = err.into();
945
946        assert_eq!(api.error.code, "invalid_state");
947        let details = api.error.details.expect("should have details");
948        assert_eq!(details["download_id"], 3);
949        assert_eq!(details["operation"], "pause");
950        assert_eq!(details["current_state"], "completed");
951    }
952
953    #[test]
954    fn api_error_from_download_insufficient_space_has_byte_counts() {
955        let err = Error::Download(DownloadError::InsufficientSpace {
956            required: 5_000_000,
957            available: 1_000,
958        });
959        let api: ApiError = err.into();
960
961        assert_eq!(api.error.code, "insufficient_space");
962        let details = api.error.details.expect("should have details");
963        assert_eq!(details["required_bytes"], 5_000_000_u64);
964        assert_eq!(details["available_bytes"], 1_000_u64);
965    }
966
967    #[test]
968    fn api_error_from_top_level_insufficient_space_has_byte_counts() {
969        let err = Error::InsufficientSpace {
970            required: 9_999_999,
971            available: 42,
972        };
973        let api: ApiError = err.into();
974
975        assert_eq!(api.error.code, "insufficient_space");
976        let details = api.error.details.expect("should have details");
977        assert_eq!(details["required_bytes"], 9_999_999_u64);
978        assert_eq!(details["available_bytes"], 42_u64);
979    }
980
981    #[test]
982    fn api_error_from_all_passwords_failed_has_archive_and_count() {
983        let err = Error::PostProcess(PostProcessError::AllPasswordsFailed {
984            archive: PathBuf::from("/tmp/secret.rar"),
985            count: 15,
986        });
987        let api: ApiError = err.into();
988
989        assert_eq!(api.error.code, "all_passwords_failed");
990        let details = api.error.details.expect("should have details");
991        assert_eq!(details["archive"], "/tmp/secret.rar");
992        assert_eq!(details["password_count"], 15);
993    }
994
995    #[test]
996    fn api_error_from_wrong_password_has_archive() {
997        let err = Error::PostProcess(PostProcessError::WrongPassword {
998            archive: PathBuf::from("/tmp/encrypted.rar"),
999        });
1000        let api: ApiError = err.into();
1001
1002        assert_eq!(api.error.code, "wrong_password");
1003        let details = api.error.details.expect("should have details");
1004        assert_eq!(details["archive"], "/tmp/encrypted.rar");
1005    }
1006
1007    #[test]
1008    fn api_error_from_file_collision_has_path() {
1009        let err = Error::PostProcess(PostProcessError::FileCollision {
1010            path: PathBuf::from("/dest/movie.mkv"),
1011            reason: "file already exists".into(),
1012        });
1013        let api: ApiError = err.into();
1014
1015        assert_eq!(api.error.code, "file_collision");
1016        let details = api.error.details.expect("should have details");
1017        assert_eq!(details["path"], "/dest/movie.mkv");
1018    }
1019
1020    // -----------------------------------------------------------------------
1021    // 4. Error -> ApiError produces None details for context-free variants
1022    // -----------------------------------------------------------------------
1023
1024    #[test]
1025    fn api_error_from_io_has_no_details() {
1026        let err = Error::Io(std::io::Error::other("disk fail"));
1027        let api: ApiError = err.into();
1028
1029        assert_eq!(api.error.code, "io_error");
1030        assert!(
1031            api.error.details.is_none(),
1032            "Io errors should not have structured details"
1033        );
1034    }
1035
1036    #[test]
1037    fn api_error_from_nntp_has_no_details() {
1038        let err = Error::Nntp("timeout".into());
1039        let api: ApiError = err.into();
1040
1041        assert_eq!(api.error.code, "nntp_error");
1042        assert!(
1043            api.error.details.is_none(),
1044            "NNTP errors should not have structured details"
1045        );
1046    }
1047
1048    #[test]
1049    fn api_error_from_shutting_down_has_no_details() {
1050        let api: ApiError = Error::ShuttingDown.into();
1051
1052        assert_eq!(api.error.code, "shutting_down");
1053        assert!(
1054            api.error.details.is_none(),
1055            "ShuttingDown should not have structured details"
1056        );
1057    }
1058
1059    #[test]
1060    fn api_error_from_config_has_no_details() {
1061        let err = Error::Config {
1062            message: "invalid port".into(),
1063            key: Some("server.port".into()),
1064        };
1065        let api: ApiError = err.into();
1066
1067        assert_eq!(api.error.code, "config_error");
1068        assert!(
1069            api.error.details.is_none(),
1070            "Config errors should not have structured details"
1071        );
1072    }
1073
1074    #[test]
1075    fn api_error_from_database_has_no_details() {
1076        let err = Error::Database(DatabaseError::ConnectionFailed("refused".into()));
1077        let api: ApiError = err.into();
1078
1079        assert_eq!(api.error.code, "database_error");
1080        assert!(
1081            api.error.details.is_none(),
1082            "Database errors should not have structured details"
1083        );
1084    }
1085
1086    #[test]
1087    fn api_error_from_not_found_string_has_no_details() {
1088        let err = Error::NotFound("download 99".into());
1089        let api: ApiError = err.into();
1090
1091        assert_eq!(api.error.code, "not_found");
1092        assert!(
1093            api.error.details.is_none(),
1094            "Top-level NotFound(String) should not have structured details"
1095        );
1096    }
1097
1098    #[test]
1099    fn api_error_from_other_has_no_details() {
1100        let err = Error::Other("something went wrong".into());
1101        let api: ApiError = err.into();
1102
1103        assert_eq!(api.error.code, "internal_error");
1104        assert!(api.error.details.is_none());
1105    }
1106
1107    #[test]
1108    fn api_error_from_external_tool_has_no_details() {
1109        let err = Error::ExternalTool("unrar not found".into());
1110        let api: ApiError = err.into();
1111
1112        assert_eq!(api.error.code, "external_tool_error");
1113        assert!(api.error.details.is_none());
1114    }
1115
1116    #[test]
1117    fn api_error_from_postprocess_without_details_has_none() {
1118        // PostProcessError variants NOT in the details match arm
1119        let variants: Vec<Error> = vec![
1120            Error::PostProcess(PostProcessError::VerificationFailed {
1121                id: 1,
1122                reason: "corrupt".into(),
1123            }),
1124            Error::PostProcess(PostProcessError::RepairFailed {
1125                id: 1,
1126                reason: "too damaged".into(),
1127            }),
1128            Error::PostProcess(PostProcessError::ExtractionFailed {
1129                archive: PathBuf::from("test.rar"),
1130                reason: "crc error".into(),
1131            }),
1132            Error::PostProcess(PostProcessError::NoPasswordsAvailable {
1133                archive: PathBuf::from("secret.rar"),
1134            }),
1135            Error::PostProcess(PostProcessError::MoveFailed {
1136                source_path: PathBuf::from("/a"),
1137                dest_path: PathBuf::from("/b"),
1138                reason: "denied".into(),
1139            }),
1140            Error::PostProcess(PostProcessError::CleanupFailed {
1141                id: 1,
1142                reason: "locked".into(),
1143            }),
1144            Error::PostProcess(PostProcessError::InvalidPath {
1145                path: PathBuf::from("../bad"),
1146                reason: "traversal".into(),
1147            }),
1148        ];
1149
1150        for err in variants {
1151            let code = err.error_code().to_string();
1152            let api: ApiError = err.into();
1153            assert!(
1154                api.error.details.is_none(),
1155                "PostProcessError with code={code} should not have structured details"
1156            );
1157        }
1158    }
1159
1160    // -----------------------------------------------------------------------
1161    // 5. ApiError factory methods produce correct codes and messages
1162    // -----------------------------------------------------------------------
1163
1164    #[test]
1165    fn api_error_not_found_factory() {
1166        let api = ApiError::not_found("Download 123");
1167
1168        assert_eq!(api.error.code, "not_found");
1169        assert_eq!(api.error.message, "Download 123 not found");
1170        assert!(api.error.details.is_none());
1171    }
1172
1173    #[test]
1174    fn api_error_validation_factory() {
1175        let api = ApiError::validation("name is required");
1176
1177        assert_eq!(api.error.code, "validation_error");
1178        assert_eq!(api.error.message, "name is required");
1179        assert!(api.error.details.is_none());
1180    }
1181
1182    #[test]
1183    fn api_error_conflict_factory() {
1184        let api = ApiError::conflict("download already exists");
1185
1186        assert_eq!(api.error.code, "conflict");
1187        assert_eq!(api.error.message, "download already exists");
1188        assert!(api.error.details.is_none());
1189    }
1190
1191    #[test]
1192    fn api_error_internal_factory() {
1193        let api = ApiError::internal("unexpected failure");
1194
1195        assert_eq!(api.error.code, "internal_error");
1196        assert_eq!(api.error.message, "unexpected failure");
1197        assert!(api.error.details.is_none());
1198    }
1199
1200    #[test]
1201    fn api_error_unauthorized_factory() {
1202        let api = ApiError::unauthorized("invalid token");
1203
1204        assert_eq!(api.error.code, "unauthorized");
1205        assert_eq!(api.error.message, "invalid token");
1206        assert!(api.error.details.is_none());
1207    }
1208
1209    #[test]
1210    fn api_error_service_unavailable_factory() {
1211        let api = ApiError::service_unavailable("server overloaded");
1212
1213        assert_eq!(api.error.code, "service_unavailable");
1214        assert_eq!(api.error.message, "server overloaded");
1215        assert!(api.error.details.is_none());
1216    }
1217
1218    // -----------------------------------------------------------------------
1219    // 6. ApiError::with_details serializes details correctly
1220    // -----------------------------------------------------------------------
1221
1222    #[test]
1223    fn with_details_preserves_json_object() {
1224        let details = serde_json::json!({
1225            "download_id": 42,
1226            "path": "/tmp/test",
1227            "retries": 3,
1228        });
1229        let api = ApiError::with_details("custom_error", "something broke", details.clone());
1230
1231        assert_eq!(api.error.code, "custom_error");
1232        assert_eq!(api.error.message, "something broke");
1233        let actual_details = api.error.details.expect("details should be present");
1234        assert_eq!(actual_details, details);
1235    }
1236
1237    #[test]
1238    fn with_details_serializes_to_json_with_details_field() {
1239        let api = ApiError::with_details(
1240            "test_code",
1241            "test message",
1242            serde_json::json!({"key": "value"}),
1243        );
1244
1245        let json_str = serde_json::to_string(&api).unwrap();
1246        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1247
1248        assert_eq!(parsed["error"]["code"], "test_code");
1249        assert_eq!(parsed["error"]["message"], "test message");
1250        assert_eq!(parsed["error"]["details"]["key"], "value");
1251    }
1252
1253    #[test]
1254    fn api_error_without_details_omits_details_in_json() {
1255        let api = ApiError::new("test_code", "test message");
1256
1257        let json_str = serde_json::to_string(&api).unwrap();
1258        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1259
1260        assert_eq!(parsed["error"]["code"], "test_code");
1261        assert_eq!(parsed["error"]["message"], "test message");
1262        // skip_serializing_if = "Option::is_none" should omit the field entirely
1263        assert!(
1264            parsed["error"].get("details").is_none(),
1265            "details field should be omitted from JSON when None"
1266        );
1267    }
1268
1269    #[test]
1270    fn api_error_round_trips_through_json() {
1271        let original = ApiError::with_details(
1272            "not_found",
1273            "Download 42 not found",
1274            serde_json::json!({"download_id": 42}),
1275        );
1276
1277        let json_str = serde_json::to_string(&original).unwrap();
1278        let deserialized: ApiError = serde_json::from_str(&json_str).unwrap();
1279
1280        assert_eq!(deserialized.error.code, original.error.code);
1281        assert_eq!(deserialized.error.message, original.error.message);
1282        assert_eq!(deserialized.error.details, original.error.details);
1283    }
1284
1285    // -----------------------------------------------------------------------
1286    // Verify that Error -> ApiError preserves the Display message
1287    // -----------------------------------------------------------------------
1288
1289    #[test]
1290    fn api_error_message_matches_error_display() {
1291        let err = Error::Download(DownloadError::InvalidState {
1292            id: 5,
1293            operation: "resume".into(),
1294            current_state: "completed".into(),
1295        });
1296        let display_msg = err.to_string();
1297        let api: ApiError = err.into();
1298
1299        assert_eq!(
1300            api.error.message, display_msg,
1301            "ApiError message should match the Error's Display output"
1302        );
1303    }
1304
1305    #[test]
1306    fn api_error_from_nntp_preserves_display_message_and_maps_to_502() {
1307        let err = Error::Nntp("connection reset by peer".into());
1308        let display_msg = err.to_string();
1309        let status = err.status_code();
1310        let api: ApiError = err.into();
1311
1312        assert_eq!(status, 502, "NNTP errors must map to 502 Bad Gateway");
1313        assert_eq!(api.error.code, "nntp_error");
1314        assert_eq!(
1315            api.error.message, display_msg,
1316            "ApiError message must match Error::Nntp Display output"
1317        );
1318        assert!(
1319            api.error.message.contains("connection reset by peer"),
1320            "ApiError message must contain the original NNTP error string"
1321        );
1322        assert!(
1323            api.error.details.is_none(),
1324            "NNTP errors should not have structured details"
1325        );
1326    }
1327
1328    #[test]
1329    fn api_error_message_for_insufficient_space_includes_byte_counts() {
1330        let err = Error::InsufficientSpace {
1331            required: 1_048_576,
1332            available: 512,
1333        };
1334        let api: ApiError = err.into();
1335
1336        assert!(
1337            api.error.message.contains("1048576"),
1338            "message should contain the required bytes"
1339        );
1340        assert!(
1341            api.error.message.contains("512"),
1342            "message should contain the available bytes"
1343        );
1344    }
1345}