Skip to main content

usenet_dl/
types.rs

1//! Core types for usenet-dl
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::Duration;
7use utoipa::ToSchema;
8
9use crate::config::{DuplicateMethod, PostProcess};
10
11/// Unique identifier for a download
12#[derive(
13    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ToSchema,
14)]
15#[serde(transparent)]
16pub struct DownloadId(pub i64);
17
18impl DownloadId {
19    /// Create a new DownloadId
20    pub fn new(id: i64) -> Self {
21        Self(id)
22    }
23
24    /// Get the inner i64 value
25    pub fn get(&self) -> i64 {
26        self.0
27    }
28}
29
30impl From<i64> for DownloadId {
31    fn from(id: i64) -> Self {
32        Self(id)
33    }
34}
35
36impl From<DownloadId> for i64 {
37    fn from(id: DownloadId) -> Self {
38        id.0
39    }
40}
41
42impl PartialEq<i64> for DownloadId {
43    fn eq(&self, other: &i64) -> bool {
44        self.0 == *other
45    }
46}
47
48impl PartialEq<DownloadId> for i64 {
49    fn eq(&self, other: &DownloadId) -> bool {
50        *self == other.0
51    }
52}
53
54impl std::fmt::Display for DownloadId {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}", self.0)
57    }
58}
59
60impl std::str::FromStr for DownloadId {
61    type Err = std::num::ParseIntError;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        Ok(Self(s.parse()?))
65    }
66}
67
68// Implement sqlx Type, Encode, and Decode for database operations
69impl sqlx::Type<sqlx::Sqlite> for DownloadId {
70    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
71        <i64 as sqlx::Type<sqlx::Sqlite>>::type_info()
72    }
73
74    fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
75        <i64 as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
76    }
77}
78
79impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for DownloadId {
80    fn encode_by_ref(
81        &self,
82        buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
83    ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
84        sqlx::Encode::<sqlx::Sqlite>::encode_by_ref(&self.0, buf)
85    }
86}
87
88impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for DownloadId {
89    fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
90        let id = <i64 as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
91        Ok(Self(id))
92    }
93}
94
95/// Download status
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
97#[serde(rename_all = "lowercase")]
98pub enum Status {
99    /// Queued and waiting to start
100    Queued,
101    /// Currently downloading
102    Downloading,
103    /// Paused by user
104    Paused,
105    /// Post-processing (verify/repair/extract)
106    Processing,
107    /// Successfully completed
108    Complete,
109    /// Failed with error
110    Failed,
111}
112
113impl Status {
114    /// Convert integer status code to Status enum
115    pub fn from_i32(status: i32) -> Self {
116        match status {
117            0 => Status::Queued,
118            1 => Status::Downloading,
119            2 => Status::Paused,
120            3 => Status::Processing,
121            4 => Status::Complete,
122            5 => Status::Failed,
123            _ => Status::Failed, // Default to Failed for unknown status
124        }
125    }
126
127    /// Convert Status enum to integer status code
128    pub fn to_i32(&self) -> i32 {
129        match self {
130            Status::Queued => 0,
131            Status::Downloading => 1,
132            Status::Paused => 2,
133            Status::Processing => 3,
134            Status::Complete => 4,
135            Status::Failed => 5,
136        }
137    }
138}
139
140/// Download priority
141#[derive(
142    Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema,
143)]
144#[serde(rename_all = "lowercase")]
145pub enum Priority {
146    /// Low priority (-1)
147    Low = -1,
148    /// Normal priority (0)
149    #[default]
150    Normal = 0,
151    /// High priority (1)
152    High = 1,
153    /// Force start immediately (2)
154    Force = 2,
155}
156
157impl Priority {
158    /// Convert integer priority code to Priority enum
159    pub fn from_i32(priority: i32) -> Self {
160        match priority {
161            -1 => Priority::Low,
162            0 => Priority::Normal,
163            1 => Priority::High,
164            2 => Priority::Force,
165            _ => Priority::Normal, // Default to Normal for unknown priority
166        }
167    }
168}
169
170/// Post-processing stage
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
172#[serde(rename_all = "lowercase")]
173pub enum Stage {
174    /// Download stage
175    Download,
176    /// PAR2 verification
177    Verify,
178    /// PAR2 repair
179    Repair,
180    /// Archive extraction
181    Extract,
182    /// Move to final destination
183    Move,
184    /// Cleanup intermediate files
185    Cleanup,
186    /// DirectUnpack (extraction during download)
187    DirectUnpack,
188}
189
190/// Archive type detected by file extension
191#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
192#[serde(rename_all = "lowercase")]
193pub enum ArchiveType {
194    /// RAR archive (.rar, .r00, .r01, etc.)
195    Rar,
196    /// 7-Zip archive (.7z)
197    SevenZip,
198    /// ZIP archive (.zip)
199    Zip,
200}
201
202/// Event emitted during download lifecycle
203#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
204#[serde(tag = "type", rename_all = "snake_case")]
205pub enum Event {
206    /// Download added to queue
207    Queued {
208        /// Download ID
209        id: DownloadId,
210        /// Download name
211        name: String,
212    },
213
214    /// Download removed from queue
215    Removed {
216        /// Download ID
217        id: DownloadId,
218    },
219
220    /// Download progress update
221    Downloading {
222        /// Download ID
223        id: DownloadId,
224        /// Progress percentage (0.0 to 100.0)
225        percent: f32,
226        /// Current speed in bytes per second
227        speed_bps: u64,
228        /// Number of articles that have failed so far
229        #[serde(skip_serializing_if = "Option::is_none")]
230        failed_articles: Option<u64>,
231        /// Total number of articles in the download
232        #[serde(skip_serializing_if = "Option::is_none")]
233        total_articles: Option<u64>,
234        /// Download health percentage (100.0 = no failures, 0.0 = all failed)
235        #[serde(skip_serializing_if = "Option::is_none")]
236        health_percent: Option<f32>,
237    },
238
239    /// Download completed (starting post-processing)
240    DownloadComplete {
241        /// Download ID
242        id: DownloadId,
243        /// Number of articles that failed during download
244        #[serde(skip_serializing_if = "Option::is_none")]
245        articles_failed: Option<u64>,
246        /// Total number of articles in the download
247        #[serde(skip_serializing_if = "Option::is_none")]
248        articles_total: Option<u64>,
249    },
250
251    /// Download failed
252    DownloadFailed {
253        /// Download ID
254        id: DownloadId,
255        /// Error message
256        error: String,
257        /// Number of articles that succeeded before failure
258        #[serde(skip_serializing_if = "Option::is_none")]
259        articles_succeeded: Option<u64>,
260        /// Number of articles that failed
261        #[serde(skip_serializing_if = "Option::is_none")]
262        articles_failed: Option<u64>,
263        /// Total number of articles in the download
264        #[serde(skip_serializing_if = "Option::is_none")]
265        articles_total: Option<u64>,
266    },
267
268    /// PAR2 verification started
269    Verifying {
270        /// Download ID
271        id: DownloadId,
272    },
273
274    /// PAR2 verification completed
275    VerifyComplete {
276        /// Download ID
277        id: DownloadId,
278        /// Whether files are damaged
279        damaged: bool,
280    },
281
282    /// PAR2 repair started
283    Repairing {
284        /// Download ID
285        id: DownloadId,
286        /// Blocks needed for repair
287        blocks_needed: u32,
288        /// Blocks available
289        blocks_available: u32,
290    },
291
292    /// PAR2 repair completed
293    RepairComplete {
294        /// Download ID
295        id: DownloadId,
296        /// Whether repair was successful
297        success: bool,
298    },
299
300    /// PAR2 repair skipped (not supported or not needed)
301    RepairSkipped {
302        /// Download ID
303        id: DownloadId,
304        /// Reason for skipping
305        reason: String,
306    },
307
308    /// Archive extraction started
309    Extracting {
310        /// Download ID
311        id: DownloadId,
312        /// Archive filename
313        archive: String,
314        /// Extraction progress (0.0 to 100.0)
315        percent: f32,
316    },
317
318    /// Archive extraction completed
319    ExtractComplete {
320        /// Download ID
321        id: DownloadId,
322    },
323
324    /// Moving files to destination
325    Moving {
326        /// Download ID
327        id: DownloadId,
328        /// Destination path
329        destination: PathBuf,
330    },
331
332    /// Cleaning up intermediate files
333    Cleaning {
334        /// Download ID
335        id: DownloadId,
336    },
337
338    /// Download fully complete
339    Complete {
340        /// Download ID
341        id: DownloadId,
342        /// Final path
343        path: PathBuf,
344    },
345
346    /// Download failed at some stage
347    Failed {
348        /// Download ID
349        id: DownloadId,
350        /// Stage where failure occurred
351        stage: Stage,
352        /// Error message
353        error: String,
354        /// Whether files were kept
355        files_kept: bool,
356    },
357
358    /// Speed limit changed
359    SpeedLimitChanged {
360        /// New limit in bytes per second (None = unlimited)
361        limit_bps: Option<u64>,
362    },
363
364    /// Queue paused
365    QueuePaused,
366
367    /// Queue resumed
368    QueueResumed,
369
370    /// Webhook delivery failed
371    WebhookFailed {
372        /// Webhook URL
373        url: String,
374        /// Error message
375        error: String,
376    },
377
378    /// Script execution failed
379    ScriptFailed {
380        /// Script path
381        script: PathBuf,
382        /// Exit code (if available)
383        exit_code: Option<i32>,
384    },
385
386    /// Duplicate download detected
387    DuplicateDetected {
388        /// Existing download ID that matches
389        id: DownloadId,
390        /// Name of the new download attempt
391        name: String,
392        /// Detection method used
393        method: DuplicateMethod,
394        /// Name of the existing download
395        existing_name: String,
396    },
397
398    /// DirectUnpack coordinator started for a download
399    DirectUnpackStarted {
400        /// Download ID
401        id: DownloadId,
402    },
403
404    /// An individual file within a download has completed (all segments downloaded)
405    FileCompleted {
406        /// Download ID
407        id: DownloadId,
408        /// File index within the NZB
409        file_index: i32,
410        /// Filename
411        filename: String,
412    },
413
414    /// DirectUnpack is extracting a completed RAR archive
415    DirectUnpackExtracting {
416        /// Download ID
417        id: DownloadId,
418        /// Archive filename being extracted
419        filename: String,
420    },
421
422    /// DirectUnpack successfully extracted an archive
423    DirectUnpackExtracted {
424        /// Download ID
425        id: DownloadId,
426        /// Archive filename that was extracted
427        filename: String,
428        /// List of extracted file paths
429        extracted_files: Vec<String>,
430    },
431
432    /// DirectUnpack was cancelled (typically due to article failures)
433    DirectUnpackCancelled {
434        /// Download ID
435        id: DownloadId,
436        /// Reason for cancellation
437        reason: String,
438    },
439
440    /// DirectUnpack completed successfully for a download
441    DirectUnpackComplete {
442        /// Download ID
443        id: DownloadId,
444    },
445
446    /// DirectRename renamed a file using PAR2 metadata
447    DirectRenamed {
448        /// Download ID
449        id: DownloadId,
450        /// Original (obfuscated) filename
451        old_name: String,
452        /// New (correct) filename from PAR2 metadata
453        new_name: String,
454    },
455
456    /// Graceful shutdown initiated
457    Shutdown,
458}
459
460/// Information about a download in the queue
461#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
462pub struct DownloadInfo {
463    /// Unique download identifier
464    pub id: DownloadId,
465
466    /// Download name (from NZB filename)
467    pub name: String,
468
469    /// Category (if assigned)
470    pub category: Option<String>,
471
472    /// Current status
473    pub status: Status,
474
475    /// Progress percentage (0.0 to 100.0)
476    pub progress: f32,
477
478    /// Current download speed in bytes per second
479    pub speed_bps: u64,
480
481    /// Total size in bytes
482    pub size_bytes: u64,
483
484    /// Downloaded bytes so far
485    pub downloaded_bytes: u64,
486
487    /// Estimated time to completion in seconds (None if unknown)
488    pub eta_seconds: Option<u64>,
489
490    /// Download priority
491    pub priority: Priority,
492
493    /// When the download was added to the queue
494    pub created_at: DateTime<Utc>,
495
496    /// When the download started (None if not started yet)
497    pub started_at: Option<DateTime<Utc>>,
498}
499
500/// Options for adding a download to the queue
501#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
502pub struct DownloadOptions {
503    /// Category to assign (None = use default category)
504    #[serde(default)]
505    pub category: Option<String>,
506
507    /// Override default destination directory
508    #[serde(default)]
509    pub destination: Option<PathBuf>,
510
511    /// Override default post-processing mode
512    #[serde(default)]
513    pub post_process: Option<PostProcess>,
514
515    /// Download priority
516    #[serde(default)]
517    pub priority: Priority,
518
519    /// Password for this specific download (high priority)
520    #[serde(default)]
521    pub password: Option<String>,
522}
523
524/// Historical download record
525#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
526pub struct HistoryEntry {
527    /// Unique download identifier
528    pub id: i64,
529
530    /// Download name
531    pub name: String,
532
533    /// Category (if assigned)
534    pub category: Option<String>,
535
536    /// Final destination path (if completed successfully)
537    pub destination: Option<PathBuf>,
538
539    /// Final status (Complete or Failed)
540    pub status: Status,
541
542    /// Total size in bytes
543    pub size_bytes: u64,
544
545    /// Time spent downloading (not including queue wait time)
546    pub download_time: Duration,
547
548    /// When the download completed (successfully or failed)
549    pub completed_at: DateTime<Utc>,
550}
551
552/// Queue statistics
553#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
554pub struct QueueStats {
555    /// Total number of downloads in queue
556    pub total: usize,
557
558    /// Number of queued downloads (waiting to start)
559    pub queued: usize,
560
561    /// Number of actively downloading
562    pub downloading: usize,
563
564    /// Number of paused downloads
565    pub paused: usize,
566
567    /// Number of downloads in post-processing
568    pub processing: usize,
569
570    /// Total download speed across all active downloads (bytes per second)
571    pub total_speed_bps: u64,
572
573    /// Total size of all downloads in queue (bytes)
574    pub total_size_bytes: u64,
575
576    /// Total downloaded bytes across all downloads
577    pub downloaded_bytes: u64,
578
579    /// Overall queue progress (0.0 to 100.0)
580    pub overall_progress: f32,
581
582    /// Current speed limit (None = unlimited)
583    pub speed_limit_bps: Option<u64>,
584
585    /// Whether queue is accepting new downloads
586    pub accepting_new: bool,
587}
588
589/// Information about a detected duplicate download
590#[derive(Clone, Debug)]
591pub struct DuplicateInfo {
592    /// Detection method that found the duplicate
593    pub method: crate::config::DuplicateMethod,
594
595    /// ID of the existing download
596    pub existing_id: DownloadId,
597
598    /// Name of the existing download
599    pub existing_name: String,
600}
601
602/// Payload sent to webhooks
603#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
604pub struct WebhookPayload {
605    /// Event type (complete, failed, queued)
606    pub event: String,
607
608    /// Download ID
609    pub download_id: DownloadId,
610
611    /// Download name
612    pub name: String,
613
614    /// Category (if any)
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub category: Option<String>,
617
618    /// Download status
619    pub status: String,
620
621    /// Final destination path (for complete downloads)
622    #[serde(skip_serializing_if = "Option::is_none")]
623    pub destination: Option<PathBuf>,
624
625    /// Error message (for failed downloads)
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub error: Option<String>,
628
629    /// Timestamp of the event (Unix timestamp in seconds)
630    pub timestamp: i64,
631}
632
633// unwrap/expect are acceptable in tests for concise failure-on-error assertions
634#[allow(clippy::unwrap_used, clippy::expect_used)]
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use std::str::FromStr;
639
640    // --- Status integer encoding ---
641
642    #[test]
643    fn status_round_trips_through_i32_for_all_variants() {
644        let cases = [
645            (Status::Queued, 0),
646            (Status::Downloading, 1),
647            (Status::Paused, 2),
648            (Status::Processing, 3),
649            (Status::Complete, 4),
650            (Status::Failed, 5),
651        ];
652
653        for (variant, expected_int) in cases {
654            assert_eq!(
655                variant.to_i32(),
656                expected_int,
657                "{variant:?} should encode to {expected_int}"
658            );
659            assert_eq!(
660                Status::from_i32(expected_int),
661                variant,
662                "{expected_int} should decode to {variant:?}"
663            );
664        }
665    }
666
667    #[test]
668    fn status_from_unknown_positive_integer_defaults_to_failed() {
669        assert_eq!(
670            Status::from_i32(99),
671            Status::Failed,
672            "unknown status 99 must fall back to Failed so corrupted DB rows surface visibly"
673        );
674    }
675
676    #[test]
677    fn status_from_negative_integer_defaults_to_failed() {
678        assert_eq!(
679            Status::from_i32(-1),
680            Status::Failed,
681            "negative status must fall back to Failed — not silently become Queued"
682        );
683    }
684
685    // --- Priority integer encoding ---
686
687    #[test]
688    fn priority_round_trips_through_i32_for_all_variants() {
689        let cases = [
690            (Priority::Low, -1),
691            (Priority::Normal, 0),
692            (Priority::High, 1),
693            (Priority::Force, 2),
694        ];
695
696        for (variant, expected_int) in cases {
697            // from_i32 → variant
698            assert_eq!(
699                Priority::from_i32(expected_int),
700                variant,
701                "{expected_int} should decode to {variant:?}"
702            );
703            // variant discriminant → expected_int
704            assert_eq!(
705                variant as i32, expected_int,
706                "{variant:?} discriminant should be {expected_int}"
707            );
708        }
709    }
710
711    #[test]
712    fn priority_from_unknown_integer_defaults_to_normal() {
713        assert_eq!(
714            Priority::from_i32(99),
715            Priority::Normal,
716            "unknown priority must default to Normal, not High or Force"
717        );
718        assert_eq!(
719            Priority::from_i32(-100),
720            Priority::Normal,
721            "large negative priority must default to Normal"
722        );
723    }
724
725    // --- DownloadId conversions ---
726
727    #[test]
728    fn download_id_from_i64_and_back() {
729        let id = DownloadId::from(42_i64);
730        let raw: i64 = id.into();
731        assert_eq!(
732            raw, 42,
733            "round-trip through From<i64>/Into<i64> must preserve value"
734        );
735    }
736
737    #[test]
738    fn download_id_from_str_parses_valid_integer() {
739        let id = DownloadId::from_str("123").unwrap();
740        assert_eq!(id.get(), 123);
741    }
742
743    #[test]
744    fn download_id_from_str_parses_negative_integer() {
745        let id = DownloadId::from_str("-7").unwrap();
746        assert_eq!(
747            id.get(),
748            -7,
749            "DownloadId wraps i64 and must accept negatives"
750        );
751    }
752
753    #[test]
754    fn download_id_from_str_rejects_non_numeric() {
755        let result = DownloadId::from_str("abc");
756        assert!(result.is_err(), "non-numeric string must fail to parse");
757        // Verify the error is actually a ParseIntError (not some other error)
758        let err = result.unwrap_err();
759        // ParseIntError's Display always contains the failing input context
760        let msg = err.to_string();
761        assert!(
762            !msg.is_empty(),
763            "ParseIntError should have a descriptive message, got empty"
764        );
765    }
766
767    #[test]
768    fn download_id_from_str_rejects_empty_string() {
769        assert!(
770            DownloadId::from_str("").is_err(),
771            "empty string must not parse to a DownloadId"
772        );
773    }
774
775    #[test]
776    fn download_id_from_str_rejects_float() {
777        assert!(
778            DownloadId::from_str("3.14").is_err(),
779            "float string must not parse as DownloadId"
780        );
781    }
782
783    #[test]
784    fn download_id_display_matches_inner_value() {
785        let id = DownloadId::new(999);
786        assert_eq!(
787            id.to_string(),
788            "999",
789            "Display should produce the raw i64 value"
790        );
791    }
792
793    #[test]
794    fn download_id_display_for_negative() {
795        let id = DownloadId::new(-42);
796        assert_eq!(
797            id.to_string(),
798            "-42",
799            "Display must include the minus sign for negatives"
800        );
801    }
802
803    #[test]
804    fn download_id_partial_eq_with_i64() {
805        let id = DownloadId::new(10);
806        assert!(id == 10_i64, "DownloadId should equal matching i64");
807        assert!(
808            10_i64 == id,
809            "i64 should equal matching DownloadId (symmetric)"
810        );
811        assert!(id != 11_i64, "DownloadId should not equal different i64");
812    }
813
814    // --- DownloadId parsing edge cases ---
815
816    #[test]
817    fn download_id_from_str_rejects_whitespace_padded_input() {
818        // i64::from_str is strict and does not trim — verify DownloadId inherits this
819        assert!(
820            DownloadId::from_str(" 123 ").is_err(),
821            "whitespace-padded string must not parse — API callers must trim before parsing"
822        );
823        assert!(
824            DownloadId::from_str(" 123").is_err(),
825            "leading whitespace must be rejected"
826        );
827        assert!(
828            DownloadId::from_str("123 ").is_err(),
829            "trailing whitespace must be rejected"
830        );
831    }
832
833    #[test]
834    fn download_id_from_str_parses_leading_zeros_as_decimal() {
835        // i64::from_str treats leading zeros as plain decimal (not octal)
836        let id = DownloadId::from_str("0000123").unwrap();
837        assert_eq!(
838            id.get(),
839            123,
840            "leading zeros should parse as decimal 123, not be rejected or treated as octal"
841        );
842    }
843
844    #[test]
845    fn download_id_from_str_rejects_i64_overflow_without_panic() {
846        // i64::MAX = 9223372036854775807, so i64::MAX + 1 must fail gracefully
847        let result = DownloadId::from_str("9223372036854775808");
848        assert!(
849            result.is_err(),
850            "i64::MAX + 1 must produce an error, not wrap or panic"
851        );
852        // Verify the error is a ParseIntError with a meaningful message
853        let err = result.unwrap_err();
854        let msg = err.to_string();
855        assert!(
856            msg.contains("too large") || msg.contains("overflow") || msg.contains("number"),
857            "error message should indicate overflow, got: {msg}"
858        );
859    }
860
861    #[test]
862    fn download_id_from_str_rejects_negative_overflow_without_panic() {
863        // i64::MIN = -9223372036854775808, so i64::MIN - 1 must fail gracefully
864        let result = DownloadId::from_str("-9223372036854775809");
865        assert!(
866            result.is_err(),
867            "i64::MIN - 1 must produce an error, not wrap or panic"
868        );
869    }
870}
871
872/// Result of a server connectivity test
873#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
874pub struct ServerTestResult {
875    /// Whether the test was successful
876    pub success: bool,
877
878    /// Latency to connect and authenticate (if successful)
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub latency: Option<Duration>,
881
882    /// Error message (if failed)
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub error: Option<String>,
885
886    /// Server capabilities (if successful)
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub capabilities: Option<ServerCapabilities>,
889}
890
891/// NNTP server capabilities discovered during testing
892#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
893pub struct ServerCapabilities {
894    /// Whether posting is allowed
895    pub posting_allowed: bool,
896
897    /// Maximum number of connections (if advertised by server)
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub max_connections: Option<u32>,
900
901    /// Whether compression is supported (e.g., XZVER)
902    pub compression: bool,
903}
904
905/// Overall system capabilities for post-processing features
906///
907/// This struct provides information about what features are available
908/// based on the current configuration and available external tools.
909#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
910pub struct Capabilities {
911    /// PAR2 parity checking and repair capabilities
912    pub parity: ParityCapabilitiesInfo,
913}
914
915/// Information about PAR2 parity capabilities
916///
917/// This struct wraps the core parity capabilities with additional
918/// metadata for API consumers.
919#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
920pub struct ParityCapabilitiesInfo {
921    /// Whether PAR2 verification is available
922    pub can_verify: bool,
923
924    /// Whether PAR2 repair is available
925    pub can_repair: bool,
926
927    /// Name of the parity handler implementation in use
928    pub handler: String,
929}