use crate::types::Priority;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration};
use utoipa::ToSchema;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DownloadConfig {
#[serde(default = "default_download_dir")]
pub download_dir: PathBuf,
#[serde(default = "default_temp_dir")]
pub temp_dir: PathBuf,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_downloads: usize,
#[serde(default)]
pub speed_limit_bps: Option<u64>,
#[serde(default)]
pub default_post_process: PostProcess,
#[serde(default = "default_true")]
pub delete_samples: bool,
#[serde(default)]
pub file_collision: FileCollisionAction,
#[serde(default = "default_max_failure_ratio")]
pub max_failure_ratio: f64,
#[serde(default = "default_fast_fail_threshold")]
pub fast_fail_threshold: f64,
#[serde(default = "default_fast_fail_sample_size")]
pub fast_fail_sample_size: usize,
}
impl Default for DownloadConfig {
fn default() -> Self {
Self {
download_dir: default_download_dir(),
temp_dir: default_temp_dir(),
max_concurrent_downloads: default_max_concurrent(),
speed_limit_bps: None,
default_post_process: PostProcess::default(),
delete_samples: true,
file_collision: FileCollisionAction::default(),
max_failure_ratio: default_max_failure_ratio(),
fast_fail_threshold: default_fast_fail_threshold(),
fast_fail_sample_size: default_fast_fail_sample_size(),
}
}
}
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct ToolsConfig {
#[serde(default)]
pub password_file: Option<PathBuf>,
#[serde(default = "default_true")]
pub try_empty_password: bool,
#[serde(default)]
pub unrar_path: Option<PathBuf>,
#[serde(default)]
pub sevenzip_path: Option<PathBuf>,
#[serde(default)]
pub par2_path: Option<PathBuf>,
#[serde(default = "default_true")]
pub search_path: bool,
#[serde(skip)]
#[schema(ignore)]
pub parity_handler: Option<std::sync::Arc<dyn crate::parity::ParityHandler>>,
}
impl std::fmt::Debug for ToolsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolsConfig")
.field("password_file", &self.password_file)
.field("try_empty_password", &self.try_empty_password)
.field("unrar_path", &self.unrar_path)
.field("sevenzip_path", &self.sevenzip_path)
.field("par2_path", &self.par2_path)
.field("search_path", &self.search_path)
.field(
"parity_handler",
&self
.parity_handler
.as_ref()
.map(|h| h.name()),
)
.finish()
}
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
password_file: None,
try_empty_password: true,
unrar_path: None,
sevenzip_path: None,
par2_path: None,
search_path: true,
parity_handler: None,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct NotificationConfig {
#[serde(default)]
pub webhooks: Vec<WebhookConfig>,
#[serde(default)]
pub scripts: Vec<ScriptConfig>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct Config {
pub servers: Vec<ServerConfig>,
#[serde(flatten)]
pub download: DownloadConfig,
#[serde(flatten)]
pub tools: ToolsConfig,
#[serde(flatten)]
pub notifications: NotificationConfig,
#[serde(flatten)]
pub processing: ProcessingConfig,
pub persistence: PersistenceConfig,
#[serde(flatten)]
pub automation: AutomationConfig,
#[serde(flatten)]
pub server: ServerIntegrationConfig,
}
impl Config {
pub fn download_dir(&self) -> &PathBuf {
&self.download.download_dir
}
pub fn temp_dir(&self) -> &PathBuf {
&self.download.temp_dir
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub tls: bool,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default = "default_connections")]
pub connections: usize,
#[serde(default)]
pub priority: i32,
#[serde(default = "default_pipeline_depth")]
pub pipeline_depth: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RetryConfig {
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_initial_delay", with = "duration_serde")]
pub initial_delay: Duration,
#[serde(default = "default_max_delay", with = "duration_serde")]
pub max_delay: Duration,
#[serde(default = "default_backoff_multiplier")]
pub backoff_multiplier: f64,
#[serde(default = "default_true")]
pub jitter: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 5,
initial_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
backoff_multiplier: 2.0,
jitter: true,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PostProcess {
None,
Verify,
Repair,
Unpack,
#[default]
UnpackAndCleanup,
}
impl PostProcess {
pub fn to_i32(&self) -> i32 {
match self {
PostProcess::None => 0,
PostProcess::Verify => 1,
PostProcess::Repair => 2,
PostProcess::Unpack => 3,
PostProcess::UnpackAndCleanup => 4,
}
}
pub fn from_i32(value: i32) -> Self {
match value {
0 => PostProcess::None,
1 => PostProcess::Verify,
2 => PostProcess::Repair,
3 => PostProcess::Unpack,
4 => PostProcess::UnpackAndCleanup,
_ => PostProcess::UnpackAndCleanup, }
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ExtractionConfig {
#[serde(default = "default_max_recursion")]
pub max_recursion_depth: u32,
#[serde(default = "default_archive_extensions")]
pub archive_extensions: Vec<String>,
}
impl Default for ExtractionConfig {
fn default() -> Self {
Self {
max_recursion_depth: 2,
archive_extensions: default_archive_extensions(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FileCollisionAction {
#[default]
Rename,
Overwrite,
Skip,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DeobfuscationConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_min_length")]
pub min_length: usize,
}
impl Default for DeobfuscationConfig {
fn default() -> Self {
Self {
enabled: true,
min_length: 12,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DuplicateConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub action: DuplicateAction,
#[serde(default = "default_duplicate_methods")]
pub methods: Vec<DuplicateMethod>,
}
impl Default for DuplicateConfig {
fn default() -> Self {
Self {
enabled: true,
action: DuplicateAction::default(),
methods: default_duplicate_methods(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DuplicateAction {
Block,
#[default]
Warn,
Allow,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DuplicateMethod {
NzbHash,
NzbName,
JobName,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DiskSpaceConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_min_free_space")]
pub min_free_space: u64,
#[serde(default = "default_size_multiplier")]
pub size_multiplier: f64,
}
impl Default for DiskSpaceConfig {
fn default() -> Self {
Self {
enabled: true,
min_free_space: 1024 * 1024 * 1024, size_multiplier: 2.5,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CleanupConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_cleanup_extensions")]
pub target_extensions: Vec<String>,
#[serde(default = "default_archive_extensions")]
pub archive_extensions: Vec<String>,
#[serde(default = "default_true")]
pub delete_samples: bool,
#[serde(default = "default_sample_folder_names")]
pub sample_folder_names: Vec<String>,
}
impl Default for CleanupConfig {
fn default() -> Self {
Self {
enabled: true,
target_extensions: default_cleanup_extensions(),
archive_extensions: default_archive_extensions(),
delete_samples: true,
sample_folder_names: default_sample_folder_names(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DirectUnpackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub direct_rename: bool,
#[serde(default = "default_direct_unpack_poll_interval")]
pub poll_interval_ms: u64,
}
impl Default for DirectUnpackConfig {
fn default() -> Self {
Self {
enabled: false,
direct_rename: false,
poll_interval_ms: default_direct_unpack_poll_interval(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ProcessingConfig {
#[serde(default)]
pub extraction: ExtractionConfig,
#[serde(default)]
pub duplicate: DuplicateConfig,
#[serde(default)]
pub disk_space: DiskSpaceConfig,
#[serde(default)]
pub retry: RetryConfig,
#[serde(default)]
pub cleanup: CleanupConfig,
#[serde(default)]
pub direct_unpack: DirectUnpackConfig,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct AutomationConfig {
#[serde(default)]
pub rss_feeds: Vec<RssFeedConfig>,
#[serde(default)]
pub watch_folders: Vec<WatchFolderConfig>,
#[serde(default)]
pub deobfuscation: DeobfuscationConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PersistenceConfig {
#[serde(default = "default_database_path")]
pub database_path: PathBuf,
#[serde(default)]
pub schedule_rules: Vec<ScheduleRule>,
#[serde(default)]
pub categories: HashMap<String, CategoryConfig>,
}
impl Default for PersistenceConfig {
fn default() -> Self {
Self {
database_path: default_database_path(),
schedule_rules: vec![],
categories: HashMap::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ServerIntegrationConfig {
#[serde(default)]
pub api: ApiConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiConfig {
#[serde(default = "default_bind_address")]
pub bind_address: SocketAddr,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_true")]
pub cors_enabled: bool,
#[serde(default = "default_cors_origins")]
pub cors_origins: Vec<String>,
#[serde(default = "default_true")]
pub swagger_ui: bool,
#[serde(default)]
pub rate_limit: RateLimitConfig,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
bind_address: default_bind_address(),
api_key: None,
cors_enabled: true,
cors_origins: default_cors_origins(),
swagger_ui: true,
rate_limit: RateLimitConfig::default(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RateLimitConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_requests_per_second")]
pub requests_per_second: u32,
#[serde(default = "default_burst_size")]
pub burst_size: u32,
#[serde(default = "default_exempt_paths")]
pub exempt_paths: Vec<String>,
#[serde(default = "default_exempt_ips")]
pub exempt_ips: Vec<std::net::IpAddr>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: 100,
burst_size: 200,
exempt_paths: default_exempt_paths(),
exempt_ips: default_exempt_ips(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ScheduleRule {
pub name: String,
#[serde(default)]
pub days: Vec<Weekday>,
pub start_time: String,
pub end_time: String,
pub action: ScheduleAction,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ScheduleAction {
SpeedLimit {
limit_bps: u64,
},
Unlimited,
Pause,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct WatchFolderConfig {
pub path: PathBuf,
#[serde(default)]
pub after_import: WatchFolderAction,
#[serde(default)]
pub category: Option<String>,
#[serde(default = "default_scan_interval", with = "duration_serde")]
pub scan_interval: Duration,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum WatchFolderAction {
Delete,
#[default]
MoveToProcessed,
Keep,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct WebhookConfig {
pub url: String,
pub events: Vec<WebhookEvent>,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default = "default_webhook_timeout", with = "duration_serde")]
pub timeout: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum WebhookEvent {
OnComplete,
OnFailed,
OnQueued,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ScriptConfig {
pub path: PathBuf,
pub events: Vec<ScriptEvent>,
#[serde(default = "default_script_timeout", with = "duration_serde")]
pub timeout: Duration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum ScriptEvent {
OnComplete,
OnFailed,
OnPostProcessComplete,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RssFeedConfig {
pub url: String,
#[serde(default = "default_rss_check_interval", with = "duration_serde")]
pub check_interval: Duration,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub filters: Vec<RssFilter>,
#[serde(default = "default_true")]
pub auto_download: bool,
#[serde(default)]
pub priority: Priority,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct RssFilter {
pub name: String,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub min_size: Option<u64>,
#[serde(default)]
pub max_size: Option<u64>,
#[serde(default, with = "optional_duration_serde")]
pub max_age: Option<Duration>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CategoryConfig {
pub destination: PathBuf,
#[serde(default)]
pub post_process: Option<PostProcess>,
#[serde(default)]
pub scripts: Vec<ScriptConfig>,
}
fn default_download_dir() -> PathBuf {
PathBuf::from("downloads")
}
fn default_temp_dir() -> PathBuf {
PathBuf::from("temp")
}
fn default_max_concurrent() -> usize {
3
}
fn default_database_path() -> PathBuf {
PathBuf::from("usenet-dl.db")
}
fn default_connections() -> usize {
10
}
fn default_pipeline_depth() -> usize {
10
}
fn default_true() -> bool {
true
}
fn default_max_failure_ratio() -> f64 {
0.5
}
fn default_fast_fail_threshold() -> f64 {
0.8
}
fn default_fast_fail_sample_size() -> usize {
10
}
fn default_max_attempts() -> u32 {
5
}
fn default_initial_delay() -> Duration {
Duration::from_secs(1)
}
fn default_max_delay() -> Duration {
Duration::from_secs(60)
}
fn default_backoff_multiplier() -> f64 {
2.0
}
fn default_max_recursion() -> u32 {
2
}
fn default_archive_extensions() -> Vec<String> {
vec![
"rar".into(),
"zip".into(),
"7z".into(),
"tar".into(),
"gz".into(),
"bz2".into(),
]
}
fn default_min_length() -> usize {
12
}
fn default_duplicate_methods() -> Vec<DuplicateMethod> {
vec![DuplicateMethod::NzbHash, DuplicateMethod::JobName]
}
fn default_min_free_space() -> u64 {
1024 * 1024 * 1024 }
fn default_size_multiplier() -> f64 {
2.5
}
fn default_bind_address() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 6789))
}
fn default_cors_origins() -> Vec<String> {
vec!["*".into()]
}
fn default_requests_per_second() -> u32 {
100
}
fn default_burst_size() -> u32 {
200
}
fn default_exempt_paths() -> Vec<String> {
vec![
"/api/v1/events".to_string(), "/api/v1/health".to_string(), ]
}
fn default_exempt_ips() -> Vec<std::net::IpAddr> {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
vec![
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::LOCALHOST),
]
}
fn default_scan_interval() -> Duration {
Duration::from_secs(5)
}
fn default_webhook_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_script_timeout() -> Duration {
Duration::from_secs(300) }
fn default_cleanup_extensions() -> Vec<String> {
vec![
"par2".into(),
"PAR2".into(),
"nzb".into(),
"NZB".into(),
"sfv".into(),
"SFV".into(),
"srr".into(),
"SRR".into(),
"nfo".into(),
"NFO".into(),
]
}
fn default_sample_folder_names() -> Vec<String> {
vec![
"sample".into(),
"Sample".into(),
"SAMPLE".into(),
"samples".into(),
"Samples".into(),
"SAMPLES".into(),
]
}
fn default_rss_check_interval() -> Duration {
Duration::from_secs(15 * 60) }
fn default_direct_unpack_poll_interval() -> u64 {
200
}
mod duration_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
mod optional_duration_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match duration {
Some(d) => serializer.serialize_some(&d.as_secs()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
let secs = Option::<u64>::deserialize(deserializer)?;
Ok(secs.map(Duration::from_secs))
}
}
impl From<ServerConfig> for nntp_rs::ServerConfig {
fn from(config: ServerConfig) -> Self {
nntp_rs::ServerConfig {
host: config.host,
port: config.port,
tls: config.tls,
allow_insecure_tls: false,
username: config.username.unwrap_or_default(),
password: config.password.unwrap_or_default(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct ConfigUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_limit_bps: Option<Option<u64>>,
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rss_feed_serialization() {
let feed = RssFeedConfig {
url: "https://test.com/rss".to_string(),
check_interval: Duration::from_secs(900),
category: Some("movies".to_string()),
filters: vec![],
auto_download: true,
priority: Priority::Normal,
enabled: true,
};
let json = serde_json::to_string(&feed).expect("serialize failed");
let deserialized: RssFeedConfig = serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(deserialized.url, feed.url);
assert_eq!(deserialized.check_interval, feed.check_interval);
assert_eq!(deserialized.category, feed.category);
assert!(deserialized.auto_download);
assert_eq!(deserialized.priority, feed.priority);
assert!(deserialized.enabled);
}
#[test]
fn post_process_round_trips_through_i32_for_all_variants() {
let cases = [
(PostProcess::None, 0),
(PostProcess::Verify, 1),
(PostProcess::Repair, 2),
(PostProcess::Unpack, 3),
(PostProcess::UnpackAndCleanup, 4),
];
for (variant, expected_int) in cases {
assert_eq!(
variant.to_i32(),
expected_int,
"{variant:?} should encode to {expected_int}"
);
assert_eq!(
PostProcess::from_i32(expected_int),
variant,
"{expected_int} should decode to {variant:?}"
);
}
}
#[test]
fn post_process_from_unknown_integer_defaults_to_unpack_and_cleanup() {
assert_eq!(
PostProcess::from_i32(99),
PostProcess::UnpackAndCleanup,
"unknown value must default to the safest full-pipeline mode"
);
assert_eq!(
PostProcess::from_i32(-1),
PostProcess::UnpackAndCleanup,
"negative value must also default to UnpackAndCleanup"
);
}
#[test]
fn server_config_converts_with_credentials() {
let our = ServerConfig {
host: "news.example.com".to_string(),
port: 563,
tls: true,
username: Some("user1".to_string()),
password: Some("secret".to_string()),
connections: 10,
priority: 0,
pipeline_depth: 10,
};
let nntp: nntp_rs::ServerConfig = our.into();
assert_eq!(nntp.host, "news.example.com");
assert_eq!(nntp.port, 563);
assert!(nntp.tls, "TLS flag must be forwarded");
assert!(
!nntp.allow_insecure_tls,
"insecure TLS must always be false"
);
assert_eq!(nntp.username, "user1");
assert_eq!(nntp.password, "secret");
}
#[test]
fn server_config_converts_without_credentials_to_empty_strings() {
let our = ServerConfig {
host: "news.free.example".to_string(),
port: 119,
tls: false,
username: None,
password: None,
connections: 5,
priority: 1,
pipeline_depth: 10,
};
let nntp: nntp_rs::ServerConfig = our.into();
assert_eq!(nntp.host, "news.free.example");
assert_eq!(nntp.port, 119);
assert!(!nntp.tls);
assert_eq!(
nntp.username, "",
"None username must become empty string for nntp-rs"
);
assert_eq!(
nntp.password, "",
"None password must become empty string for nntp-rs"
);
}
#[test]
fn config_default_survives_json_round_trip() {
let original = Config::default();
let json = serde_json::to_string(&original).expect("Config must serialize to JSON");
let restored: Config =
serde_json::from_str(&json).expect("Config must deserialize from its own JSON");
assert_eq!(
restored.download.download_dir, original.download.download_dir,
"download_dir must survive round-trip"
);
assert_eq!(
restored.download.temp_dir, original.download.temp_dir,
"temp_dir must survive round-trip"
);
assert_eq!(
restored.download.max_concurrent_downloads, original.download.max_concurrent_downloads,
"max_concurrent_downloads must survive round-trip"
);
assert_eq!(
restored.download.speed_limit_bps, original.download.speed_limit_bps,
"speed_limit_bps must survive round-trip"
);
assert_eq!(
restored.download.default_post_process, original.download.default_post_process,
"default_post_process must survive round-trip"
);
assert_eq!(
restored.persistence.database_path, original.persistence.database_path,
"database_path must survive round-trip"
);
assert_eq!(
restored.server.api.bind_address, original.server.api.bind_address,
"api bind_address must survive round-trip"
);
assert_eq!(
restored.processing.retry.max_attempts, original.processing.retry.max_attempts,
"retry max_attempts must survive round-trip"
);
assert_eq!(
restored.processing.retry.initial_delay, original.processing.retry.initial_delay,
"retry initial_delay must survive round-trip"
);
}
#[test]
fn duration_serde_serializes_as_seconds() {
let config = RetryConfig {
initial_delay: Duration::from_secs(5),
max_delay: Duration::from_secs(120),
..RetryConfig::default()
};
let json = serde_json::to_value(&config).expect("serialize failed");
assert_eq!(
json["initial_delay"], 5,
"duration_serde must serialize Duration as integer seconds"
);
assert_eq!(json["max_delay"], 120);
}
#[test]
fn duration_serde_deserializes_from_seconds() {
let json = r#"{"max_attempts":3,"initial_delay":10,"max_delay":300,"backoff_multiplier":2.0,"jitter":false}"#;
let config: RetryConfig = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(
config.initial_delay,
Duration::from_secs(10),
"integer 10 must deserialize to Duration::from_secs(10)"
);
assert_eq!(
config.max_delay,
Duration::from_secs(300),
"integer 300 must deserialize to Duration::from_secs(300)"
);
}
#[test]
fn optional_duration_serde_round_trips_some_value() {
let filter = RssFilter {
name: "test".to_string(),
include: vec![],
exclude: vec![],
min_size: None,
max_size: None,
max_age: Some(Duration::from_secs(3600)),
};
let json = serde_json::to_value(&filter).expect("serialize failed");
assert_eq!(
json["max_age"], 3600,
"Some(Duration) must serialize as integer seconds"
);
let restored: RssFilter = serde_json::from_value(json).expect("deserialize failed");
assert_eq!(restored.max_age, Some(Duration::from_secs(3600)));
}
#[test]
fn optional_duration_serde_round_trips_none() {
let filter = RssFilter {
name: "test".to_string(),
include: vec![],
exclude: vec![],
min_size: None,
max_size: None,
max_age: None,
};
let json = serde_json::to_value(&filter).expect("serialize failed");
assert!(
json["max_age"].is_null(),
"None duration must serialize as null"
);
let restored: RssFilter = serde_json::from_value(json).expect("deserialize failed");
assert_eq!(restored.max_age, None, "null must deserialize back to None");
}
#[test]
fn config_update_none_omits_field_entirely() {
let update = ConfigUpdate {
speed_limit_bps: None,
};
let json = serde_json::to_value(&update).expect("serialize failed");
assert!(
!json.as_object().unwrap().contains_key("speed_limit_bps"),
"None should be omitted due to skip_serializing_if"
);
}
#[test]
fn config_update_some_none_serializes_as_null() {
let update = ConfigUpdate {
speed_limit_bps: Some(None),
};
let json = serde_json::to_value(&update).expect("serialize failed");
assert!(
json["speed_limit_bps"].is_null(),
"Some(None) must serialize as null (= remove limit)"
);
}
#[test]
fn config_update_some_some_serializes_as_number() {
let update = ConfigUpdate {
speed_limit_bps: Some(Some(10_000_000)),
};
let json = serde_json::to_value(&update).expect("serialize failed");
assert_eq!(
json["speed_limit_bps"], 10_000_000,
"Some(Some(10_000_000)) must serialize as the number 10000000"
);
}
#[test]
fn config_update_deserializes_missing_field_as_none() {
let json = "{}";
let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
assert!(
update.speed_limit_bps.is_none(),
"missing field must become None (= no change requested)"
);
}
#[test]
fn config_update_deserializes_null_as_none() {
let json = r#"{"speed_limit_bps": null}"#;
let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(
update.speed_limit_bps, None,
"null deserializes as None (same as missing) without a custom deserializer"
);
}
#[test]
fn config_update_deserializes_number_as_some_some() {
let json = r#"{"speed_limit_bps": 5000000}"#;
let update: ConfigUpdate = serde_json::from_str(json).expect("deserialize failed");
assert_eq!(
update.speed_limit_bps,
Some(Some(5_000_000)),
"number value must become Some(Some(val))"
);
}
#[test]
fn duration_serde_rejects_string_instead_of_integer() {
let json = r#"{"initial_delay": "not_a_number", "max_delay": 60}"#;
let result = serde_json::from_str::<RetryConfig>(json);
match result {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("invalid type") || msg.contains("expected"),
"serde error should describe the type mismatch, got: {msg}"
);
}
Ok(_) => panic!(
"string value for a Duration field must produce a serde error, not silently succeed"
),
}
}
#[test]
fn duration_serde_rejects_negative_integer() {
let json = r#"{"initial_delay": -1, "max_delay": 60}"#;
let result = serde_json::from_str::<RetryConfig>(json);
match result {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("invalid value") || msg.contains("expected"),
"serde error should describe the negative value issue, got: {msg}"
);
}
Ok(_) => panic!(
"-1 for a Duration (u64) field must produce a serde error, not silently succeed"
),
}
}
#[test]
fn optional_duration_serde_rejects_string_instead_of_integer() {
let json = r#"{"name": "test", "max_age": "forever"}"#;
let result = serde_json::from_str::<RssFilter>(json);
match result {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("invalid type") || msg.contains("expected"),
"serde error should describe the type mismatch, got: {msg}"
);
}
Ok(_) => {
panic!("string value for an optional Duration field must produce a serde error")
}
}
}
}