1use crate::types::Priority;
4use serde::{Deserialize, Serialize};
5use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration};
6use utoipa::ToSchema;
7
8#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
13pub struct DownloadConfig {
14 #[serde(default = "default_download_dir")]
16 pub download_dir: PathBuf,
17
18 #[serde(default = "default_temp_dir")]
20 pub temp_dir: PathBuf,
21
22 #[serde(default = "default_max_concurrent")]
24 pub max_concurrent_downloads: usize,
25
26 #[serde(default)]
28 pub speed_limit_bps: Option<u64>,
29
30 #[serde(default)]
32 pub default_post_process: PostProcess,
33
34 #[serde(default = "default_true")]
36 pub delete_samples: bool,
37
38 #[serde(default)]
40 pub file_collision: FileCollisionAction,
41
42 #[serde(default = "default_max_failure_ratio")]
48 pub max_failure_ratio: f64,
49
50 #[serde(default = "default_fast_fail_threshold")]
56 pub fast_fail_threshold: f64,
57
58 #[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#[derive(Clone, Serialize, Deserialize, ToSchema)]
92pub struct ToolsConfig {
93 #[serde(default)]
95 pub password_file: Option<PathBuf>,
96
97 #[serde(default = "default_true")]
99 pub try_empty_password: bool,
100
101 #[serde(default)]
103 pub unrar_path: Option<PathBuf>,
104
105 #[serde(default)]
107 pub sevenzip_path: Option<PathBuf>,
108
109 #[serde(default)]
111 pub par2_path: Option<PathBuf>,
112
113 #[serde(default = "default_true")]
115 pub search_path: bool,
116
117 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
166pub struct NotificationConfig {
167 #[serde(default)]
169 pub webhooks: Vec<WebhookConfig>,
170
171 #[serde(default)]
173 pub scripts: Vec<ScriptConfig>,
174}
175
176#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
188pub struct Config {
189 pub servers: Vec<ServerConfig>,
191
192 #[serde(flatten)]
194 pub download: DownloadConfig,
195
196 #[serde(flatten)]
198 pub tools: ToolsConfig,
199
200 #[serde(flatten)]
202 pub notifications: NotificationConfig,
203
204 #[serde(flatten)]
206 pub processing: ProcessingConfig,
207
208 pub persistence: PersistenceConfig,
210
211 #[serde(flatten)]
213 pub automation: AutomationConfig,
214
215 #[serde(flatten)]
217 pub server: ServerIntegrationConfig,
218}
219
220impl Config {
223 pub fn download_dir(&self) -> &PathBuf {
225 &self.download.download_dir
226 }
227
228 pub fn temp_dir(&self) -> &PathBuf {
230 &self.download.temp_dir
231 }
232}
233
234#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
236pub struct ServerConfig {
237 pub host: String,
239
240 pub port: u16,
242
243 pub tls: bool,
245
246 pub username: Option<String>,
248
249 pub password: Option<String>,
251
252 #[serde(default = "default_connections")]
254 pub connections: usize,
255
256 #[serde(default)]
258 pub priority: i32,
259
260 #[serde(default = "default_pipeline_depth")]
268 pub pipeline_depth: usize,
269}
270
271#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
273pub struct RetryConfig {
274 #[serde(default = "default_max_attempts")]
276 pub max_attempts: u32,
277
278 #[serde(default = "default_initial_delay", with = "duration_serde")]
280 pub initial_delay: Duration,
281
282 #[serde(default = "default_max_delay", with = "duration_serde")]
284 pub max_delay: Duration,
285
286 #[serde(default = "default_backoff_multiplier")]
288 pub backoff_multiplier: f64,
289
290 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
309#[serde(rename_all = "snake_case")]
310pub enum PostProcess {
311 None,
313 Verify,
315 Repair,
317 Unpack,
319 #[default]
321 UnpackAndCleanup,
322}
323
324impl PostProcess {
325 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 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, }
346 }
347}
348
349#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
351pub struct ExtractionConfig {
352 #[serde(default = "default_max_recursion")]
354 pub max_recursion_depth: u32,
355
356 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum FileCollisionAction {
374 #[default]
376 Rename,
377 Overwrite,
379 Skip,
381}
382
383#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
385pub struct DeobfuscationConfig {
386 #[serde(default = "default_true")]
388 pub enabled: bool,
389
390 #[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#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
406pub struct DuplicateConfig {
407 #[serde(default = "default_true")]
409 pub enabled: bool,
410
411 #[serde(default)]
413 pub action: DuplicateAction,
414
415 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
432#[serde(rename_all = "snake_case")]
433pub enum DuplicateAction {
434 Block,
436 #[default]
438 Warn,
439 Allow,
441}
442
443#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
445#[serde(rename_all = "snake_case")]
446pub enum DuplicateMethod {
447 NzbHash,
449 NzbName,
451 JobName,
453}
454
455#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
457pub struct DiskSpaceConfig {
458 #[serde(default = "default_true")]
460 pub enabled: bool,
461
462 #[serde(default = "default_min_free_space")]
464 pub min_free_space: u64,
465
466 #[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, size_multiplier: 2.5,
477 }
478 }
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
483pub struct CleanupConfig {
484 #[serde(default = "default_true")]
486 pub enabled: bool,
487
488 #[serde(default = "default_cleanup_extensions")]
490 pub target_extensions: Vec<String>,
491
492 #[serde(default = "default_archive_extensions")]
494 pub archive_extensions: Vec<String>,
495
496 #[serde(default = "default_true")]
498 pub delete_samples: bool,
499
500 #[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#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
524pub struct DirectUnpackConfig {
525 #[serde(default)]
527 pub enabled: bool,
528
529 #[serde(default)]
534 pub direct_rename: bool,
535
536 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
557pub struct ProcessingConfig {
558 #[serde(default)]
560 pub extraction: ExtractionConfig,
561
562 #[serde(default)]
564 pub duplicate: DuplicateConfig,
565
566 #[serde(default)]
568 pub disk_space: DiskSpaceConfig,
569
570 #[serde(default)]
572 pub retry: RetryConfig,
573
574 #[serde(default)]
576 pub cleanup: CleanupConfig,
577
578 #[serde(default)]
580 pub direct_unpack: DirectUnpackConfig,
581}
582
583#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
588pub struct AutomationConfig {
589 #[serde(default)]
591 pub rss_feeds: Vec<RssFeedConfig>,
592
593 #[serde(default)]
595 pub watch_folders: Vec<WatchFolderConfig>,
596
597 #[serde(default)]
599 pub deobfuscation: DeobfuscationConfig,
600}
601
602#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
607pub struct PersistenceConfig {
608 #[serde(default = "default_database_path")]
610 pub database_path: PathBuf,
611
612 #[serde(default)]
614 pub schedule_rules: Vec<ScheduleRule>,
615
616 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
635pub struct ServerIntegrationConfig {
636 #[serde(default)]
638 pub api: ApiConfig,
639}
640
641#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
643pub struct ApiConfig {
644 #[serde(default = "default_bind_address")]
646 pub bind_address: SocketAddr,
647
648 #[serde(default)]
650 pub api_key: Option<String>,
651
652 #[serde(default = "default_true")]
654 pub cors_enabled: bool,
655
656 #[serde(default = "default_cors_origins")]
658 pub cors_origins: Vec<String>,
659
660 #[serde(default = "default_true")]
662 pub swagger_ui: bool,
663
664 #[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#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
684pub struct RateLimitConfig {
685 #[serde(default)]
687 pub enabled: bool,
688
689 #[serde(default = "default_requests_per_second")]
691 pub requests_per_second: u32,
692
693 #[serde(default = "default_burst_size")]
695 pub burst_size: u32,
696
697 #[serde(default = "default_exempt_paths")]
699 pub exempt_paths: Vec<String>,
700
701 #[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#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
720pub struct ScheduleRule {
721 pub name: String,
723
724 #[serde(default)]
726 pub days: Vec<Weekday>,
727
728 pub start_time: String,
730
731 pub end_time: String,
733
734 pub action: ScheduleAction,
736
737 #[serde(default = "default_true")]
739 pub enabled: bool,
740}
741
742#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
744pub enum Weekday {
745 Monday,
747 Tuesday,
749 Wednesday,
751 Thursday,
753 Friday,
755 Saturday,
757 Sunday,
759}
760
761#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
763#[serde(tag = "type", rename_all = "snake_case")]
764pub enum ScheduleAction {
765 SpeedLimit {
767 limit_bps: u64,
769 },
770 Unlimited,
772 Pause,
774}
775
776#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
778pub struct WatchFolderConfig {
779 pub path: PathBuf,
781
782 #[serde(default)]
784 pub after_import: WatchFolderAction,
785
786 #[serde(default)]
788 pub category: Option<String>,
789
790 #[serde(default = "default_scan_interval", with = "duration_serde")]
792 pub scan_interval: Duration,
793}
794
795#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
797#[serde(rename_all = "snake_case")]
798pub enum WatchFolderAction {
799 Delete,
801 #[default]
803 MoveToProcessed,
804 Keep,
806}
807
808#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
810pub struct WebhookConfig {
811 pub url: String,
813
814 pub events: Vec<WebhookEvent>,
816
817 #[serde(default)]
819 pub auth_header: Option<String>,
820
821 #[serde(default = "default_webhook_timeout", with = "duration_serde")]
823 pub timeout: Duration,
824}
825
826#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
828pub enum WebhookEvent {
829 OnComplete,
831 OnFailed,
833 OnQueued,
835}
836
837#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
839pub struct ScriptConfig {
840 pub path: PathBuf,
842
843 pub events: Vec<ScriptEvent>,
845
846 #[serde(default = "default_script_timeout", with = "duration_serde")]
848 pub timeout: Duration,
849}
850
851#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
853pub enum ScriptEvent {
854 OnComplete,
856 OnFailed,
858 OnPostProcessComplete,
860}
861
862#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
864pub struct RssFeedConfig {
865 pub url: String,
867
868 #[serde(default = "default_rss_check_interval", with = "duration_serde")]
870 pub check_interval: Duration,
871
872 #[serde(default)]
874 pub category: Option<String>,
875
876 #[serde(default)]
878 pub filters: Vec<RssFilter>,
879
880 #[serde(default = "default_true")]
882 pub auto_download: bool,
883
884 #[serde(default)]
886 pub priority: Priority,
887
888 #[serde(default = "default_true")]
890 pub enabled: bool,
891}
892
893#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
895pub struct RssFilter {
896 pub name: String,
898
899 #[serde(default)]
901 pub include: Vec<String>,
902
903 #[serde(default)]
905 pub exclude: Vec<String>,
906
907 #[serde(default)]
909 pub min_size: Option<u64>,
910
911 #[serde(default)]
913 pub max_size: Option<u64>,
914
915 #[serde(default, with = "optional_duration_serde")]
917 pub max_age: Option<Duration>,
918}
919
920#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
922pub struct CategoryConfig {
923 pub destination: PathBuf,
925
926 #[serde(default)]
928 pub post_process: Option<PostProcess>,
929
930 #[serde(default)]
932 pub scripts: Vec<ScriptConfig>,
933}
934
935fn 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 }
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(), "/api/v1/health".to_string(), ]
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) }
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) }
1095
1096fn default_direct_unpack_poll_interval() -> u64 {
1097 200
1098}
1099
1100mod 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
1121mod 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
1145impl 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#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
1164pub struct ConfigUpdate {
1165 #[serde(skip_serializing_if = "Option::is_none")]
1167 pub speed_limit_bps: Option<Option<u64>>,
1168}
1169
1170#[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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 #[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 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}