Skip to main content

usenet_dl/
config.rs

1//! Configuration types for usenet-dl
2
3use crate::types::Priority;
4use serde::{Deserialize, Serialize};
5use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration};
6use utoipa::ToSchema;
7
8/// Download behavior configuration (directories, concurrency, post-processing)
9///
10/// Groups settings related to how downloads are fetched, stored, and processed.
11/// Used as a nested sub-config within [`Config`].
12#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
13pub struct DownloadConfig {
14    /// Download directory (default: "./downloads")
15    #[serde(default = "default_download_dir")]
16    pub download_dir: PathBuf,
17
18    /// Temporary directory (default: "./temp")
19    #[serde(default = "default_temp_dir")]
20    pub temp_dir: PathBuf,
21
22    /// Maximum concurrent downloads (default: 3)
23    #[serde(default = "default_max_concurrent")]
24    pub max_concurrent_downloads: usize,
25
26    /// Speed limit in bytes per second (None = unlimited)
27    #[serde(default)]
28    pub speed_limit_bps: Option<u64>,
29
30    /// Default post-processing mode
31    #[serde(default)]
32    pub default_post_process: PostProcess,
33
34    /// Delete sample files/folders
35    #[serde(default = "default_true")]
36    pub delete_samples: bool,
37
38    /// File collision handling
39    #[serde(default)]
40    pub file_collision: FileCollisionAction,
41
42    /// Maximum article failure ratio before considering a download failed (default: 0.5 = 50%)
43    ///
44    /// When the ratio of failed articles to total articles exceeds this threshold,
45    /// the download is marked as failed. Otherwise, post-processing (PAR2 repair)
46    /// is attempted.
47    #[serde(default = "default_max_failure_ratio")]
48    pub max_failure_ratio: f64,
49
50    /// Fast-fail threshold — abort early if this fraction of a sample is missing (default: 0.8 = 80%)
51    ///
52    /// After `fast_fail_sample_size` articles have been attempted, if the failure ratio
53    /// meets or exceeds this threshold, the download is cancelled immediately to avoid
54    /// wasting bandwidth on mostly-expired NZBs.
55    #[serde(default = "default_fast_fail_threshold")]
56    pub fast_fail_threshold: f64,
57
58    /// Number of articles to sample before evaluating the fast-fail heuristic (default: 10)
59    #[serde(default = "default_fast_fail_sample_size")]
60    pub fast_fail_sample_size: usize,
61}
62
63impl Default for DownloadConfig {
64    fn default() -> Self {
65        Self {
66            download_dir: default_download_dir(),
67            temp_dir: default_temp_dir(),
68            max_concurrent_downloads: default_max_concurrent(),
69            speed_limit_bps: None,
70            default_post_process: PostProcess::default(),
71            delete_samples: true,
72            file_collision: FileCollisionAction::default(),
73            max_failure_ratio: default_max_failure_ratio(),
74            fast_fail_threshold: default_fast_fail_threshold(),
75            fast_fail_sample_size: default_fast_fail_sample_size(),
76        }
77    }
78}
79
80/// External tool paths (unrar, 7z, par2) and password configuration
81///
82/// Groups settings for external binaries and password handling.
83/// Used as a nested sub-config within [`Config`].
84///
85/// ## Custom parity handler
86///
87/// Callers can inject their own [`ParityHandler`](crate::parity::ParityHandler)
88/// via [`parity_handler`](Self::parity_handler). When set, it takes priority
89/// over `par2_path` and PATH discovery. When `None` (the default), the
90/// existing auto-detection logic applies.
91#[derive(Clone, Serialize, Deserialize, ToSchema)]
92pub struct ToolsConfig {
93    /// Path to global password file (one password per line)
94    #[serde(default)]
95    pub password_file: Option<PathBuf>,
96
97    /// Try empty password as fallback
98    #[serde(default = "default_true")]
99    pub try_empty_password: bool,
100
101    /// Path to unrar executable (auto-detected if None)
102    #[serde(default)]
103    pub unrar_path: Option<PathBuf>,
104
105    /// Path to 7z executable (auto-detected if None)
106    #[serde(default)]
107    pub sevenzip_path: Option<PathBuf>,
108
109    /// Path to par2 executable (auto-detected if None)
110    #[serde(default)]
111    pub par2_path: Option<PathBuf>,
112
113    /// Whether to search PATH for external binaries if explicit paths not set (default: true)
114    #[serde(default = "default_true")]
115    pub search_path: bool,
116
117    /// Optional pre-built parity handler to use instead of auto-detection.
118    ///
119    /// When `Some`, this handler is used directly and `par2_path` / PATH
120    /// discovery are skipped. When `None`, the downloader falls back to its
121    /// existing auto-detection logic.
122    #[serde(skip)]
123    #[schema(ignore)]
124    pub parity_handler: Option<std::sync::Arc<dyn crate::parity::ParityHandler>>,
125}
126
127impl std::fmt::Debug for ToolsConfig {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("ToolsConfig")
130            .field("password_file", &self.password_file)
131            .field("try_empty_password", &self.try_empty_password)
132            .field("unrar_path", &self.unrar_path)
133            .field("sevenzip_path", &self.sevenzip_path)
134            .field("par2_path", &self.par2_path)
135            .field("search_path", &self.search_path)
136            .field(
137                "parity_handler",
138                &self
139                    .parity_handler
140                    .as_ref()
141                    .map(|h| h.name()),
142            )
143            .finish()
144    }
145}
146
147impl Default for ToolsConfig {
148    fn default() -> Self {
149        Self {
150            password_file: None,
151            try_empty_password: true,
152            unrar_path: None,
153            sevenzip_path: None,
154            par2_path: None,
155            search_path: true,
156            parity_handler: None,
157        }
158    }
159}
160
161/// Notification configuration (webhooks and scripts)
162///
163/// Groups settings for external notifications triggered by download events.
164/// Used as a nested sub-config within [`Config`].
165#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
166pub struct NotificationConfig {
167    /// Webhook configurations
168    #[serde(default)]
169    pub webhooks: Vec<WebhookConfig>,
170
171    /// Script configurations
172    #[serde(default)]
173    pub scripts: Vec<ScriptConfig>,
174}
175
176/// Main configuration for UsenetDownloader
177///
178/// Fields are organized into logical sub-configs for maintainability:
179/// - [`download`](DownloadConfig) — directories, concurrency, post-processing
180/// - [`tools`](ToolsConfig) — external binary paths, password handling
181/// - [`notifications`](NotificationConfig) — webhooks and scripts
182///
183/// All sub-config fields are flattened for backward-compatible serialization,
184/// meaning the JSON/TOML format remains unchanged (no nesting).
185/// Individual fields are also accessible directly on `Config` via `Deref`-style
186/// accessor methods for convenience.
187#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
188pub struct Config {
189    /// NNTP server configurations (at least one required)
190    pub servers: Vec<ServerConfig>,
191
192    /// Download behavior settings (directories, concurrency, post-processing)
193    #[serde(flatten)]
194    pub download: DownloadConfig,
195
196    /// External tool paths and password handling
197    #[serde(flatten)]
198    pub tools: ToolsConfig,
199
200    /// Notification settings (webhooks and scripts)
201    #[serde(flatten)]
202    pub notifications: NotificationConfig,
203
204    /// Content pipeline processing (extraction, cleanup, validation)
205    #[serde(flatten)]
206    pub processing: ProcessingConfig,
207
208    /// Data storage and state management
209    pub persistence: PersistenceConfig,
210
211    /// Automated content discovery and ingestion
212    #[serde(flatten)]
213    pub automation: AutomationConfig,
214
215    /// API and external server integration
216    #[serde(flatten)]
217    pub server: ServerIntegrationConfig,
218}
219
220// Convenience accessors — allow existing code to use `config.download_dir` etc.
221// without changing call sites. These delegate to the sub-config structs.
222impl Config {
223    /// Download directory
224    pub fn download_dir(&self) -> &PathBuf {
225        &self.download.download_dir
226    }
227
228    /// Temporary directory
229    pub fn temp_dir(&self) -> &PathBuf {
230        &self.download.temp_dir
231    }
232}
233
234/// NNTP server configuration
235#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
236pub struct ServerConfig {
237    /// Server hostname
238    pub host: String,
239
240    /// Server port (typically 119 for unencrypted, 563 for TLS)
241    pub port: u16,
242
243    /// Use TLS (implicit TLS, not STARTTLS)
244    pub tls: bool,
245
246    /// Username for authentication
247    pub username: Option<String>,
248
249    /// Password for authentication
250    pub password: Option<String>,
251
252    /// Number of connections to maintain
253    #[serde(default = "default_connections")]
254    pub connections: usize,
255
256    /// Server priority (lower = tried first, for backup servers)
257    #[serde(default)]
258    pub priority: i32,
259
260    /// Number of ARTICLE commands to pipeline per connection (default: 10)
261    ///
262    /// Pipelining sends multiple ARTICLE commands before reading responses,
263    /// reducing round-trip latency. Higher values can improve throughput but
264    /// may increase memory usage. Set to 1 to disable pipelining (sequential mode).
265    ///
266    /// Recommended values: 5-20 depending on network latency and server capabilities.
267    #[serde(default = "default_pipeline_depth")]
268    pub pipeline_depth: usize,
269}
270
271/// Retry configuration for transient failures
272#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
273pub struct RetryConfig {
274    /// Maximum number of retry attempts (default: 5)
275    #[serde(default = "default_max_attempts")]
276    pub max_attempts: u32,
277
278    /// Initial delay before first retry (default: 1 second)
279    #[serde(default = "default_initial_delay", with = "duration_serde")]
280    pub initial_delay: Duration,
281
282    /// Maximum delay between retries (default: 60 seconds)
283    #[serde(default = "default_max_delay", with = "duration_serde")]
284    pub max_delay: Duration,
285
286    /// Multiplier for exponential backoff (default: 2.0)
287    #[serde(default = "default_backoff_multiplier")]
288    pub backoff_multiplier: f64,
289
290    /// Add random jitter to delays (default: true)
291    #[serde(default = "default_true")]
292    pub jitter: bool,
293}
294
295impl Default for RetryConfig {
296    fn default() -> Self {
297        Self {
298            max_attempts: 5,
299            initial_delay: Duration::from_secs(1),
300            max_delay: Duration::from_secs(60),
301            backoff_multiplier: 2.0,
302            jitter: true,
303        }
304    }
305}
306
307/// Post-processing mode
308#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
309#[serde(rename_all = "snake_case")]
310pub enum PostProcess {
311    /// Just download, no post-processing
312    None,
313    /// Download + PAR2 verify
314    Verify,
315    /// Download + PAR2 verify/repair
316    Repair,
317    /// Above + extract archives
318    Unpack,
319    /// Above + remove intermediate files (default)
320    #[default]
321    UnpackAndCleanup,
322}
323
324impl PostProcess {
325    /// Convert PostProcess enum to integer for database storage
326    pub fn to_i32(&self) -> i32 {
327        match self {
328            PostProcess::None => 0,
329            PostProcess::Verify => 1,
330            PostProcess::Repair => 2,
331            PostProcess::Unpack => 3,
332            PostProcess::UnpackAndCleanup => 4,
333        }
334    }
335
336    /// Convert integer from database to PostProcess enum
337    pub fn from_i32(value: i32) -> Self {
338        match value {
339            0 => PostProcess::None,
340            1 => PostProcess::Verify,
341            2 => PostProcess::Repair,
342            3 => PostProcess::Unpack,
343            4 => PostProcess::UnpackAndCleanup,
344            _ => PostProcess::UnpackAndCleanup, // Default
345        }
346    }
347}
348
349/// Archive extraction configuration
350#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
351pub struct ExtractionConfig {
352    /// Maximum depth for nested archive extraction (default: 2)
353    #[serde(default = "default_max_recursion")]
354    pub max_recursion_depth: u32,
355
356    /// File extensions to treat as archives
357    #[serde(default = "default_archive_extensions")]
358    pub archive_extensions: Vec<String>,
359}
360
361impl Default for ExtractionConfig {
362    fn default() -> Self {
363        Self {
364            max_recursion_depth: 2,
365            archive_extensions: default_archive_extensions(),
366        }
367    }
368}
369
370/// File collision handling strategy
371#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum FileCollisionAction {
374    /// Append (1), (2), etc. to filename (default)
375    #[default]
376    Rename,
377    /// Overwrite existing file
378    Overwrite,
379    /// Skip the file, keep existing
380    Skip,
381}
382
383/// Obfuscated filename detection and renaming configuration
384#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
385pub struct DeobfuscationConfig {
386    /// Enable automatic deobfuscation (default: true)
387    #[serde(default = "default_true")]
388    pub enabled: bool,
389
390    /// Minimum filename length to consider for deobfuscation (default: 12)
391    #[serde(default = "default_min_length")]
392    pub min_length: usize,
393}
394
395impl Default for DeobfuscationConfig {
396    fn default() -> Self {
397        Self {
398            enabled: true,
399            min_length: 12,
400        }
401    }
402}
403
404/// Duplicate detection configuration
405#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
406pub struct DuplicateConfig {
407    /// Enable duplicate detection (default: true)
408    #[serde(default = "default_true")]
409    pub enabled: bool,
410
411    /// What to do when duplicate detected
412    #[serde(default)]
413    pub action: DuplicateAction,
414
415    /// Check methods (in order)
416    #[serde(default = "default_duplicate_methods")]
417    pub methods: Vec<DuplicateMethod>,
418}
419
420impl Default for DuplicateConfig {
421    fn default() -> Self {
422        Self {
423            enabled: true,
424            action: DuplicateAction::default(),
425            methods: default_duplicate_methods(),
426        }
427    }
428}
429
430/// Action to take when duplicate detected
431#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
432#[serde(rename_all = "snake_case")]
433pub enum DuplicateAction {
434    /// Block the download entirely
435    Block,
436    /// Allow but emit warning event (default)
437    #[default]
438    Warn,
439    /// Allow silently
440    Allow,
441}
442
443/// Duplicate detection method
444#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
445#[serde(rename_all = "snake_case")]
446pub enum DuplicateMethod {
447    /// NZB content hash (most reliable)
448    NzbHash,
449    /// NZB filename
450    NzbName,
451    /// Extracted job name (deobfuscated)
452    JobName,
453}
454
455/// Disk space checking configuration
456#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
457pub struct DiskSpaceConfig {
458    /// Enable disk space checking (default: true)
459    #[serde(default = "default_true")]
460    pub enabled: bool,
461
462    /// Minimum free space to maintain (default: 1 GB)
463    #[serde(default = "default_min_free_space")]
464    pub min_free_space: u64,
465
466    /// Multiplier for estimated size (default: 2.5)
467    #[serde(default = "default_size_multiplier")]
468    pub size_multiplier: f64,
469}
470
471impl Default for DiskSpaceConfig {
472    fn default() -> Self {
473        Self {
474            enabled: true,
475            min_free_space: 1024 * 1024 * 1024, // 1 GB
476            size_multiplier: 2.5,
477        }
478    }
479}
480
481/// Cleanup configuration for intermediate files
482#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
483pub struct CleanupConfig {
484    /// Enable cleanup of intermediate files (default: true)
485    #[serde(default = "default_true")]
486    pub enabled: bool,
487
488    /// File extensions to remove (.par2, .nzb, .sfv, .srr, etc.)
489    #[serde(default = "default_cleanup_extensions")]
490    pub target_extensions: Vec<String>,
491
492    /// Archive extensions to remove after extraction
493    #[serde(default = "default_archive_extensions")]
494    pub archive_extensions: Vec<String>,
495
496    /// Delete sample folders (default: true from Config.delete_samples)
497    #[serde(default = "default_true")]
498    pub delete_samples: bool,
499
500    /// Sample folder names to detect (case-insensitive)
501    #[serde(default = "default_sample_folder_names")]
502    pub sample_folder_names: Vec<String>,
503}
504
505impl Default for CleanupConfig {
506    fn default() -> Self {
507        Self {
508            enabled: true,
509            target_extensions: default_cleanup_extensions(),
510            archive_extensions: default_archive_extensions(),
511            delete_samples: true,
512            sample_folder_names: default_sample_folder_names(),
513        }
514    }
515}
516
517/// DirectUnpack configuration — extract archives while download is in progress
518///
519/// When enabled, completed RAR files are extracted as soon as all their segments
520/// finish downloading, overlapping extraction with the remaining download.
521/// Combined with DirectRename, which uses PAR2 metadata to fix obfuscated
522/// filenames before extraction.
523#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
524pub struct DirectUnpackConfig {
525    /// Enable DirectUnpack — extract archives during download (default: false)
526    #[serde(default)]
527    pub enabled: bool,
528
529    /// Enable DirectRename — use PAR2 metadata to fix obfuscated filenames (default: false)
530    ///
531    /// Requires PAR2 files to be downloaded early. When enabled, PAR2 file articles
532    /// are prioritized in the download queue.
533    #[serde(default)]
534    pub direct_rename: bool,
535
536    /// How often to poll for newly completed files, in milliseconds (default: 200)
537    #[serde(default = "default_direct_unpack_poll_interval")]
538    pub poll_interval_ms: u64,
539}
540
541impl Default for DirectUnpackConfig {
542    fn default() -> Self {
543        Self {
544            enabled: false,
545            direct_rename: false,
546            poll_interval_ms: default_direct_unpack_poll_interval(),
547        }
548    }
549}
550
551/// Content pipeline processing configuration
552///
553/// Groups settings related to post-download file processing, validation,
554/// and cleanup. All settings in this config are used together during the
555/// post-processing pipeline.
556#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
557pub struct ProcessingConfig {
558    /// Archive extraction configuration
559    #[serde(default)]
560    pub extraction: ExtractionConfig,
561
562    /// Duplicate detection configuration (pre-download validation)
563    #[serde(default)]
564    pub duplicate: DuplicateConfig,
565
566    /// Disk space checking configuration (pre-download validation)
567    #[serde(default)]
568    pub disk_space: DiskSpaceConfig,
569
570    /// Retry configuration for transient failures
571    #[serde(default)]
572    pub retry: RetryConfig,
573
574    /// Cleanup configuration for intermediate files
575    #[serde(default)]
576    pub cleanup: CleanupConfig,
577
578    /// DirectUnpack — extract archives while download is still in progress
579    #[serde(default)]
580    pub direct_unpack: DirectUnpackConfig,
581}
582
583/// Automated content discovery and ingestion configuration
584///
585/// Groups settings related to automated content sources (RSS, watch folders)
586/// and content naming intelligence (deobfuscation).
587#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
588pub struct AutomationConfig {
589    /// RSS feed configurations
590    #[serde(default)]
591    pub rss_feeds: Vec<RssFeedConfig>,
592
593    /// Watch folders for auto-importing NZBs
594    #[serde(default)]
595    pub watch_folders: Vec<WatchFolderConfig>,
596
597    /// Filename deobfuscation configuration
598    #[serde(default)]
599    pub deobfuscation: DeobfuscationConfig,
600}
601
602/// Data storage and state management configuration
603///
604/// Groups settings related to persistence, state, and runtime-mutable
605/// configurations.
606#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
607pub struct PersistenceConfig {
608    /// Database path (default: "./usenet-dl.db")
609    #[serde(default = "default_database_path")]
610    pub database_path: PathBuf,
611
612    /// Schedule rules for time-based speed limits
613    #[serde(default)]
614    pub schedule_rules: Vec<ScheduleRule>,
615
616    /// Category configurations
617    #[serde(default)]
618    pub categories: HashMap<String, CategoryConfig>,
619}
620
621impl Default for PersistenceConfig {
622    fn default() -> Self {
623        Self {
624            database_path: default_database_path(),
625            schedule_rules: vec![],
626            categories: HashMap::new(),
627        }
628    }
629}
630
631/// API and external server integration configuration
632///
633/// Groups settings for external access and control interfaces.
634#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
635pub struct ServerIntegrationConfig {
636    /// REST API configuration
637    #[serde(default)]
638    pub api: ApiConfig,
639}
640
641/// REST API configuration
642#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
643pub struct ApiConfig {
644    /// Address to bind to (default: 127.0.0.1:6789)
645    #[serde(default = "default_bind_address")]
646    pub bind_address: SocketAddr,
647
648    /// Optional API key for authentication
649    #[serde(default)]
650    pub api_key: Option<String>,
651
652    /// Enable CORS for browser access (default: true)
653    #[serde(default = "default_true")]
654    pub cors_enabled: bool,
655
656    /// Allowed CORS origins (default: ["*"])
657    #[serde(default = "default_cors_origins")]
658    pub cors_origins: Vec<String>,
659
660    /// Enable Swagger UI at /swagger-ui (default: true)
661    #[serde(default = "default_true")]
662    pub swagger_ui: bool,
663
664    /// Rate limiting configuration
665    #[serde(default)]
666    pub rate_limit: RateLimitConfig,
667}
668
669impl Default for ApiConfig {
670    fn default() -> Self {
671        Self {
672            bind_address: default_bind_address(),
673            api_key: None,
674            cors_enabled: true,
675            cors_origins: default_cors_origins(),
676            swagger_ui: true,
677            rate_limit: RateLimitConfig::default(),
678        }
679    }
680}
681
682/// Rate limiting configuration
683#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
684pub struct RateLimitConfig {
685    /// Enable rate limiting (default: false)
686    #[serde(default)]
687    pub enabled: bool,
688
689    /// Requests per second per IP (default: 100)
690    #[serde(default = "default_requests_per_second")]
691    pub requests_per_second: u32,
692
693    /// Burst size (default: 200)
694    #[serde(default = "default_burst_size")]
695    pub burst_size: u32,
696
697    /// Endpoints exempt from rate limiting
698    #[serde(default = "default_exempt_paths")]
699    pub exempt_paths: Vec<String>,
700
701    /// IPs exempt from rate limiting (e.g., localhost)
702    #[serde(default = "default_exempt_ips")]
703    pub exempt_ips: Vec<std::net::IpAddr>,
704}
705
706impl Default for RateLimitConfig {
707    fn default() -> Self {
708        Self {
709            enabled: false,
710            requests_per_second: 100,
711            burst_size: 200,
712            exempt_paths: default_exempt_paths(),
713            exempt_ips: default_exempt_ips(),
714        }
715    }
716}
717
718/// Schedule rule for time-based actions
719#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
720pub struct ScheduleRule {
721    /// Human-readable name
722    pub name: String,
723
724    /// Days this rule applies (empty = all days)
725    #[serde(default)]
726    pub days: Vec<Weekday>,
727
728    /// Start time (HH:MM)
729    pub start_time: String,
730
731    /// End time (HH:MM)
732    pub end_time: String,
733
734    /// Action to take during this window
735    pub action: ScheduleAction,
736
737    /// Whether rule is active
738    #[serde(default = "default_true")]
739    pub enabled: bool,
740}
741
742/// Day of week for schedule rules
743#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
744pub enum Weekday {
745    /// Monday
746    Monday,
747    /// Tuesday
748    Tuesday,
749    /// Wednesday
750    Wednesday,
751    /// Thursday
752    Thursday,
753    /// Friday
754    Friday,
755    /// Saturday
756    Saturday,
757    /// Sunday
758    Sunday,
759}
760
761/// Action to take during scheduled time window
762#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
763#[serde(tag = "type", rename_all = "snake_case")]
764pub enum ScheduleAction {
765    /// Set speed limit (bytes per second)
766    SpeedLimit {
767        /// Speed limit in bytes per second
768        limit_bps: u64,
769    },
770    /// Unlimited speed
771    Unlimited,
772    /// Pause all downloads
773    Pause,
774}
775
776/// Watch folder configuration
777#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
778pub struct WatchFolderConfig {
779    /// Directory to watch for NZB files
780    pub path: PathBuf,
781
782    /// What to do with NZB after adding to queue
783    #[serde(default)]
784    pub after_import: WatchFolderAction,
785
786    /// Category to assign (None = use default)
787    #[serde(default)]
788    pub category: Option<String>,
789
790    /// Scan interval (default: 5 seconds)
791    #[serde(default = "default_scan_interval", with = "duration_serde")]
792    pub scan_interval: Duration,
793}
794
795/// Action to take with NZB file after import
796#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
797#[serde(rename_all = "snake_case")]
798pub enum WatchFolderAction {
799    /// Delete NZB file
800    Delete,
801    /// Move to a 'processed' subfolder (default)
802    #[default]
803    MoveToProcessed,
804    /// Keep in place
805    Keep,
806}
807
808/// Webhook configuration
809#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
810pub struct WebhookConfig {
811    /// URL to POST to
812    pub url: String,
813
814    /// Events that trigger this webhook
815    pub events: Vec<WebhookEvent>,
816
817    /// Optional authentication header value
818    #[serde(default)]
819    pub auth_header: Option<String>,
820
821    /// Timeout for webhook requests (default: 30 seconds)
822    #[serde(default = "default_webhook_timeout", with = "duration_serde")]
823    pub timeout: Duration,
824}
825
826/// Webhook trigger event
827#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
828pub enum WebhookEvent {
829    /// Triggered when a download completes successfully
830    OnComplete,
831    /// Triggered when a download fails
832    OnFailed,
833    /// Triggered when a download is queued
834    OnQueued,
835}
836
837/// Script execution configuration
838#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
839pub struct ScriptConfig {
840    /// Path to script/executable
841    pub path: PathBuf,
842
843    /// Events that trigger this script
844    pub events: Vec<ScriptEvent>,
845
846    /// Timeout for script execution (default: 5 minutes)
847    #[serde(default = "default_script_timeout", with = "duration_serde")]
848    pub timeout: Duration,
849}
850
851/// Script trigger event
852#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
853pub enum ScriptEvent {
854    /// Triggered when a download completes successfully
855    OnComplete,
856    /// Triggered when a download fails
857    OnFailed,
858    /// Triggered when post-processing completes
859    OnPostProcessComplete,
860}
861
862/// RSS feed configuration
863#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
864pub struct RssFeedConfig {
865    /// Feed URL (RSS or Atom)
866    pub url: String,
867
868    /// How often to check the feed (default: 15 minutes)
869    #[serde(default = "default_rss_check_interval", with = "duration_serde")]
870    pub check_interval: Duration,
871
872    /// Category to assign to downloads
873    #[serde(default)]
874    pub category: Option<String>,
875
876    /// Only download items matching these filters
877    #[serde(default)]
878    pub filters: Vec<RssFilter>,
879
880    /// Automatically download matches (vs just notify)
881    #[serde(default = "default_true")]
882    pub auto_download: bool,
883
884    /// Priority for auto-downloaded items
885    #[serde(default)]
886    pub priority: Priority,
887
888    /// Whether feed is active
889    #[serde(default = "default_true")]
890    pub enabled: bool,
891}
892
893/// RSS feed filter
894#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
895pub struct RssFilter {
896    /// Filter name (for UI)
897    pub name: String,
898
899    /// Patterns to include (regex)
900    #[serde(default)]
901    pub include: Vec<String>,
902
903    /// Patterns to exclude (regex)
904    #[serde(default)]
905    pub exclude: Vec<String>,
906
907    /// Minimum size (bytes)
908    #[serde(default)]
909    pub min_size: Option<u64>,
910
911    /// Maximum size (bytes)
912    #[serde(default)]
913    pub max_size: Option<u64>,
914
915    /// Maximum age from publish date (seconds)
916    #[serde(default, with = "optional_duration_serde")]
917    pub max_age: Option<Duration>,
918}
919
920/// Category configuration
921#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
922pub struct CategoryConfig {
923    /// Destination directory for this category
924    pub destination: PathBuf,
925
926    /// Override default post-processing
927    #[serde(default)]
928    pub post_process: Option<PostProcess>,
929
930    /// Category-specific scripts
931    #[serde(default)]
932    pub scripts: Vec<ScriptConfig>,
933}
934
935// Default value functions
936fn default_download_dir() -> PathBuf {
937    PathBuf::from("downloads")
938}
939
940fn default_temp_dir() -> PathBuf {
941    PathBuf::from("temp")
942}
943
944fn default_max_concurrent() -> usize {
945    3
946}
947
948fn default_database_path() -> PathBuf {
949    PathBuf::from("usenet-dl.db")
950}
951
952fn default_connections() -> usize {
953    10
954}
955
956fn default_pipeline_depth() -> usize {
957    10
958}
959
960fn default_true() -> bool {
961    true
962}
963
964fn default_max_failure_ratio() -> f64 {
965    0.5
966}
967
968fn default_fast_fail_threshold() -> f64 {
969    0.8
970}
971
972fn default_fast_fail_sample_size() -> usize {
973    10
974}
975
976fn default_max_attempts() -> u32 {
977    5
978}
979
980fn default_initial_delay() -> Duration {
981    Duration::from_secs(1)
982}
983
984fn default_max_delay() -> Duration {
985    Duration::from_secs(60)
986}
987
988fn default_backoff_multiplier() -> f64 {
989    2.0
990}
991
992fn default_max_recursion() -> u32 {
993    2
994}
995
996fn default_archive_extensions() -> Vec<String> {
997    vec![
998        "rar".into(),
999        "zip".into(),
1000        "7z".into(),
1001        "tar".into(),
1002        "gz".into(),
1003        "bz2".into(),
1004    ]
1005}
1006
1007fn default_min_length() -> usize {
1008    12
1009}
1010
1011fn default_duplicate_methods() -> Vec<DuplicateMethod> {
1012    vec![DuplicateMethod::NzbHash, DuplicateMethod::JobName]
1013}
1014
1015fn default_min_free_space() -> u64 {
1016    1024 * 1024 * 1024 // 1 GB
1017}
1018
1019fn default_size_multiplier() -> f64 {
1020    2.5
1021}
1022
1023fn default_bind_address() -> SocketAddr {
1024    SocketAddr::from(([127, 0, 0, 1], 6789))
1025}
1026
1027fn default_cors_origins() -> Vec<String> {
1028    vec!["*".into()]
1029}
1030
1031fn default_requests_per_second() -> u32 {
1032    100
1033}
1034
1035fn default_burst_size() -> u32 {
1036    200
1037}
1038
1039fn default_exempt_paths() -> Vec<String> {
1040    vec![
1041        "/api/v1/events".to_string(), // SSE is long-lived
1042        "/api/v1/health".to_string(), // Health checks should always work
1043    ]
1044}
1045
1046fn default_exempt_ips() -> Vec<std::net::IpAddr> {
1047    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
1048    vec![
1049        IpAddr::V4(Ipv4Addr::LOCALHOST),
1050        IpAddr::V6(Ipv6Addr::LOCALHOST),
1051    ]
1052}
1053
1054fn default_scan_interval() -> Duration {
1055    Duration::from_secs(5)
1056}
1057
1058fn default_webhook_timeout() -> Duration {
1059    Duration::from_secs(30)
1060}
1061
1062fn default_script_timeout() -> Duration {
1063    Duration::from_secs(300) // 5 minutes
1064}
1065
1066fn default_cleanup_extensions() -> Vec<String> {
1067    vec![
1068        "par2".into(),
1069        "PAR2".into(),
1070        "nzb".into(),
1071        "NZB".into(),
1072        "sfv".into(),
1073        "SFV".into(),
1074        "srr".into(),
1075        "SRR".into(),
1076        "nfo".into(),
1077        "NFO".into(),
1078    ]
1079}
1080
1081fn default_sample_folder_names() -> Vec<String> {
1082    vec![
1083        "sample".into(),
1084        "Sample".into(),
1085        "SAMPLE".into(),
1086        "samples".into(),
1087        "Samples".into(),
1088        "SAMPLES".into(),
1089    ]
1090}
1091
1092fn default_rss_check_interval() -> Duration {
1093    Duration::from_secs(15 * 60) // 15 minutes
1094}
1095
1096fn default_direct_unpack_poll_interval() -> u64 {
1097    200
1098}
1099
1100// Duration serialization helper
1101mod duration_serde {
1102    use serde::{Deserialize, Deserializer, Serializer};
1103    use std::time::Duration;
1104
1105    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
1106    where
1107        S: Serializer,
1108    {
1109        serializer.serialize_u64(duration.as_secs())
1110    }
1111
1112    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
1113    where
1114        D: Deserializer<'de>,
1115    {
1116        let secs = u64::deserialize(deserializer)?;
1117        Ok(Duration::from_secs(secs))
1118    }
1119}
1120
1121// Optional Duration serialization helper
1122mod optional_duration_serde {
1123    use serde::{Deserialize, Deserializer, Serializer};
1124    use std::time::Duration;
1125
1126    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
1127    where
1128        S: Serializer,
1129    {
1130        match duration {
1131            Some(d) => serializer.serialize_some(&d.as_secs()),
1132            None => serializer.serialize_none(),
1133        }
1134    }
1135
1136    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
1137    where
1138        D: Deserializer<'de>,
1139    {
1140        let secs = Option::<u64>::deserialize(deserializer)?;
1141        Ok(secs.map(Duration::from_secs))
1142    }
1143}
1144
1145// Conversion from our ServerConfig to nntp-rs's ServerConfig
1146impl From<ServerConfig> for nntp_rs::ServerConfig {
1147    fn from(config: ServerConfig) -> Self {
1148        nntp_rs::ServerConfig {
1149            host: config.host,
1150            port: config.port,
1151            tls: config.tls,
1152            allow_insecure_tls: false,
1153            username: config.username.unwrap_or_default(),
1154            password: config.password.unwrap_or_default(),
1155        }
1156    }
1157}
1158
1159/// Configuration update for runtime-changeable settings
1160///
1161/// This struct contains only fields that can be safely updated while the downloader is running.
1162/// Fields requiring restart (like database_path, download_dir, servers) are not included.
1163#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
1164pub struct ConfigUpdate {
1165    /// Speed limit in bytes per second (None = unlimited)
1166    #[serde(skip_serializing_if = "Option::is_none")]
1167    pub speed_limit_bps: Option<Option<u64>>,
1168}
1169
1170// unwrap/expect are acceptable in tests for concise failure-on-error assertions
1171#[allow(clippy::unwrap_used, clippy::expect_used)]
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn test_rss_feed_serialization() {
1178        // Test JSON serialization/deserialization
1179        let feed = RssFeedConfig {
1180            url: "https://test.com/rss".to_string(),
1181            check_interval: Duration::from_secs(900),
1182            category: Some("movies".to_string()),
1183            filters: vec![],
1184            auto_download: true,
1185            priority: Priority::Normal,
1186            enabled: true,
1187        };
1188
1189        let json = serde_json::to_string(&feed).expect("serialize failed");
1190        let deserialized: RssFeedConfig = serde_json::from_str(&json).expect("deserialize failed");
1191
1192        assert_eq!(deserialized.url, feed.url);
1193        assert_eq!(deserialized.check_interval, feed.check_interval);
1194        assert_eq!(deserialized.category, feed.category);
1195        assert!(deserialized.auto_download);
1196        assert_eq!(deserialized.priority, feed.priority);
1197        assert!(deserialized.enabled);
1198    }
1199
1200    // --- PostProcess integer encoding ---
1201
1202    #[test]
1203    fn post_process_round_trips_through_i32_for_all_variants() {
1204        let cases = [
1205            (PostProcess::None, 0),
1206            (PostProcess::Verify, 1),
1207            (PostProcess::Repair, 2),
1208            (PostProcess::Unpack, 3),
1209            (PostProcess::UnpackAndCleanup, 4),
1210        ];
1211
1212        for (variant, expected_int) in cases {
1213            assert_eq!(
1214                variant.to_i32(),
1215                expected_int,
1216                "{variant:?} should encode to {expected_int}"
1217            );
1218            assert_eq!(
1219                PostProcess::from_i32(expected_int),
1220                variant,
1221                "{expected_int} should decode to {variant:?}"
1222            );
1223        }
1224    }
1225
1226    #[test]
1227    fn post_process_from_unknown_integer_defaults_to_unpack_and_cleanup() {
1228        assert_eq!(
1229            PostProcess::from_i32(99),
1230            PostProcess::UnpackAndCleanup,
1231            "unknown value must default to the safest full-pipeline mode"
1232        );
1233        assert_eq!(
1234            PostProcess::from_i32(-1),
1235            PostProcess::UnpackAndCleanup,
1236            "negative value must also default to UnpackAndCleanup"
1237        );
1238    }
1239
1240    // --- ServerConfig → nntp_rs::ServerConfig conversion ---
1241
1242    #[test]
1243    fn server_config_converts_with_credentials() {
1244        let our = ServerConfig {
1245            host: "news.example.com".to_string(),
1246            port: 563,
1247            tls: true,
1248            username: Some("user1".to_string()),
1249            password: Some("secret".to_string()),
1250            connections: 10,
1251            priority: 0,
1252            pipeline_depth: 10,
1253        };
1254
1255        let nntp: nntp_rs::ServerConfig = our.into();
1256
1257        assert_eq!(nntp.host, "news.example.com");
1258        assert_eq!(nntp.port, 563);
1259        assert!(nntp.tls, "TLS flag must be forwarded");
1260        assert!(
1261            !nntp.allow_insecure_tls,
1262            "insecure TLS must always be false"
1263        );
1264        assert_eq!(nntp.username, "user1");
1265        assert_eq!(nntp.password, "secret");
1266    }
1267
1268    #[test]
1269    fn server_config_converts_without_credentials_to_empty_strings() {
1270        let our = ServerConfig {
1271            host: "news.free.example".to_string(),
1272            port: 119,
1273            tls: false,
1274            username: None,
1275            password: None,
1276            connections: 5,
1277            priority: 1,
1278            pipeline_depth: 10,
1279        };
1280
1281        let nntp: nntp_rs::ServerConfig = our.into();
1282
1283        assert_eq!(nntp.host, "news.free.example");
1284        assert_eq!(nntp.port, 119);
1285        assert!(!nntp.tls);
1286        assert_eq!(
1287            nntp.username, "",
1288            "None username must become empty string for nntp-rs"
1289        );
1290        assert_eq!(
1291            nntp.password, "",
1292            "None password must become empty string for nntp-rs"
1293        );
1294    }
1295
1296    // --- Config JSON round-trip ---
1297
1298    #[test]
1299    fn config_default_survives_json_round_trip() {
1300        let original = Config::default();
1301
1302        let json = serde_json::to_string(&original).expect("Config must serialize to JSON");
1303        let restored: Config =
1304            serde_json::from_str(&json).expect("Config must deserialize from its own JSON");
1305
1306        // Verify key fields survived — not just "it deserialized"
1307        assert_eq!(
1308            restored.download.download_dir, original.download.download_dir,
1309            "download_dir must survive round-trip"
1310        );
1311        assert_eq!(
1312            restored.download.temp_dir, original.download.temp_dir,
1313            "temp_dir must survive round-trip"
1314        );
1315        assert_eq!(
1316            restored.download.max_concurrent_downloads, original.download.max_concurrent_downloads,
1317            "max_concurrent_downloads must survive round-trip"
1318        );
1319        assert_eq!(
1320            restored.download.speed_limit_bps, original.download.speed_limit_bps,
1321            "speed_limit_bps must survive round-trip"
1322        );
1323        assert_eq!(
1324            restored.download.default_post_process, original.download.default_post_process,
1325            "default_post_process must survive round-trip"
1326        );
1327        assert_eq!(
1328            restored.persistence.database_path, original.persistence.database_path,
1329            "database_path must survive round-trip"
1330        );
1331        assert_eq!(
1332            restored.server.api.bind_address, original.server.api.bind_address,
1333            "api bind_address must survive round-trip"
1334        );
1335        assert_eq!(
1336            restored.processing.retry.max_attempts, original.processing.retry.max_attempts,
1337            "retry max_attempts must survive round-trip"
1338        );
1339        assert_eq!(
1340            restored.processing.retry.initial_delay, original.processing.retry.initial_delay,
1341            "retry initial_delay must survive round-trip"
1342        );
1343    }
1344
1345    // --- Duration serde helpers ---
1346
1347    #[test]
1348    fn duration_serde_serializes_as_seconds() {
1349        let config = RetryConfig {
1350            initial_delay: Duration::from_secs(5),
1351            max_delay: Duration::from_secs(120),
1352            ..RetryConfig::default()
1353        };
1354
1355        let json = serde_json::to_value(&config).expect("serialize failed");
1356
1357        assert_eq!(
1358            json["initial_delay"], 5,
1359            "duration_serde must serialize Duration as integer seconds"
1360        );
1361        assert_eq!(json["max_delay"], 120);
1362    }
1363
1364    #[test]
1365    fn duration_serde_deserializes_from_seconds() {
1366        let json = r#"{"max_attempts":3,"initial_delay":10,"max_delay":300,"backoff_multiplier":2.0,"jitter":false}"#;
1367
1368        let config: RetryConfig = serde_json::from_str(json).expect("deserialize failed");
1369
1370        assert_eq!(
1371            config.initial_delay,
1372            Duration::from_secs(10),
1373            "integer 10 must deserialize to Duration::from_secs(10)"
1374        );
1375        assert_eq!(
1376            config.max_delay,
1377            Duration::from_secs(300),
1378            "integer 300 must deserialize to Duration::from_secs(300)"
1379        );
1380    }
1381
1382    #[test]
1383    fn optional_duration_serde_round_trips_some_value() {
1384        let filter = RssFilter {
1385            name: "test".to_string(),
1386            include: vec![],
1387            exclude: vec![],
1388            min_size: None,
1389            max_size: None,
1390            max_age: Some(Duration::from_secs(3600)),
1391        };
1392
1393        let json = serde_json::to_value(&filter).expect("serialize failed");
1394        assert_eq!(
1395            json["max_age"], 3600,
1396            "Some(Duration) must serialize as integer seconds"
1397        );
1398
1399        let restored: RssFilter = serde_json::from_value(json).expect("deserialize failed");
1400        assert_eq!(restored.max_age, Some(Duration::from_secs(3600)));
1401    }
1402
1403    #[test]
1404    fn optional_duration_serde_round_trips_none() {
1405        let filter = RssFilter {
1406            name: "test".to_string(),
1407            include: vec![],
1408            exclude: vec![],
1409            min_size: None,
1410            max_size: None,
1411            max_age: None,
1412        };
1413
1414        let json = serde_json::to_value(&filter).expect("serialize failed");
1415        assert!(
1416            json["max_age"].is_null(),
1417            "None duration must serialize as null"
1418        );
1419
1420        let restored: RssFilter = serde_json::from_value(json).expect("deserialize failed");
1421        assert_eq!(restored.max_age, None, "null must deserialize back to None");
1422    }
1423
1424    // --- ConfigUpdate serialization ---
1425
1426    #[test]
1427    fn config_update_none_omits_field_entirely() {
1428        let update = ConfigUpdate {
1429            speed_limit_bps: None,
1430        };
1431
1432        let json = serde_json::to_value(&update).expect("serialize failed");
1433        assert!(
1434            !json.as_object().unwrap().contains_key("speed_limit_bps"),
1435            "None should be omitted due to skip_serializing_if"
1436        );
1437    }
1438
1439    #[test]
1440    fn config_update_some_none_serializes_as_null() {
1441        // Some(None) means "set speed limit to unlimited"
1442        let update = ConfigUpdate {
1443            speed_limit_bps: Some(None),
1444        };
1445
1446        let json = serde_json::to_value(&update).expect("serialize failed");
1447        assert!(
1448            json["speed_limit_bps"].is_null(),
1449            "Some(None) must serialize as null (= remove limit)"
1450        );
1451    }
1452
1453    #[test]
1454    fn config_update_some_some_serializes_as_number() {
1455        // Some(Some(val)) means "set speed limit to val"
1456        let update = ConfigUpdate {
1457            speed_limit_bps: Some(Some(10_000_000)),
1458        };
1459
1460        let json = serde_json::to_value(&update).expect("serialize failed");
1461        assert_eq!(
1462            json["speed_limit_bps"], 10_000_000,
1463            "Some(Some(10_000_000)) must serialize as the number 10000000"
1464        );
1465    }
1466
1467    #[test]
1468    fn config_update_deserializes_missing_field_as_none() {
1469        let json = "{}";
1470        let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
1471        assert!(
1472            update.speed_limit_bps.is_none(),
1473            "missing field must become None (= no change requested)"
1474        );
1475    }
1476
1477    #[test]
1478    fn config_update_deserializes_null_as_none() {
1479        // Note: without a custom deserializer (e.g. serde_with::double_option),
1480        // serde treats both missing and null as None for Option<Option<T>>.
1481        // The three-way distinction only works on serialization (skip_serializing_if).
1482        let json = r#"{"speed_limit_bps": null}"#;
1483        let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
1484        assert_eq!(
1485            update.speed_limit_bps, None,
1486            "null deserializes as None (same as missing) without a custom deserializer"
1487        );
1488    }
1489
1490    #[test]
1491    fn config_update_deserializes_number_as_some_some() {
1492        let json = r#"{"speed_limit_bps": 5000000}"#;
1493        let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
1494        assert_eq!(
1495            update.speed_limit_bps,
1496            Some(Some(5_000_000)),
1497            "number value must become Some(Some(val))"
1498        );
1499    }
1500
1501    // --- Invalid duration deserialization ---
1502
1503    #[test]
1504    fn duration_serde_rejects_string_instead_of_integer() {
1505        let json = r#"{"initial_delay": "not_a_number", "max_delay": 60}"#;
1506        let result = serde_json::from_str::<RetryConfig>(json);
1507
1508        match result {
1509            Err(e) => {
1510                let msg = e.to_string();
1511                assert!(
1512                    msg.contains("invalid type") || msg.contains("expected"),
1513                    "serde error should describe the type mismatch, got: {msg}"
1514                );
1515            }
1516            Ok(_) => panic!(
1517                "string value for a Duration field must produce a serde error, not silently succeed"
1518            ),
1519        }
1520    }
1521
1522    #[test]
1523    fn duration_serde_rejects_negative_integer() {
1524        let json = r#"{"initial_delay": -1, "max_delay": 60}"#;
1525        let result = serde_json::from_str::<RetryConfig>(json);
1526
1527        match result {
1528            Err(e) => {
1529                let msg = e.to_string();
1530                assert!(
1531                    msg.contains("invalid value") || msg.contains("expected"),
1532                    "serde error should describe the negative value issue, got: {msg}"
1533                );
1534            }
1535            Ok(_) => panic!(
1536                "-1 for a Duration (u64) field must produce a serde error, not silently succeed"
1537            ),
1538        }
1539    }
1540
1541    #[test]
1542    fn optional_duration_serde_rejects_string_instead_of_integer() {
1543        // RssFilter.max_age uses optional_duration_serde
1544        let json = r#"{"name": "test", "max_age": "forever"}"#;
1545        let result = serde_json::from_str::<RssFilter>(json);
1546
1547        match result {
1548            Err(e) => {
1549                let msg = e.to_string();
1550                assert!(
1551                    msg.contains("invalid type") || msg.contains("expected"),
1552                    "serde error should describe the type mismatch, got: {msg}"
1553                );
1554            }
1555            Ok(_) => {
1556                panic!("string value for an optional Duration field must produce a serde error")
1557            }
1558        }
1559    }
1560}