use std::path::PathBuf;
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub general: GeneralConfig,
pub servers: Vec<ServerConfig>,
pub categories: Vec<CategoryConfig>,
#[serde(default)]
pub otel: OtelConfig,
#[serde(default)]
pub rss_feeds: Vec<RssFeedConfig>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
servers: Vec::new(),
categories: vec![CategoryConfig::default()],
otel: OtelConfig::default(),
rss_feeds: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
pub listen_addr: String,
pub port: u16,
pub api_key: Option<String>,
pub incomplete_dir: PathBuf,
pub complete_dir: PathBuf,
pub data_dir: PathBuf,
pub speed_limit_bps: u64,
pub cache_size: u64,
pub log_level: String,
pub log_file: Option<PathBuf>,
pub history_retention: Option<usize>,
pub max_active_downloads: usize,
#[serde(default = "default_min_free_space")]
pub min_free_space_bytes: u64,
pub watch_dir: Option<PathBuf>,
#[serde(default = "default_rss_history_limit")]
pub rss_history_limit: Option<usize>,
#[serde(default = "default_true")]
pub direct_unpack: bool,
}
fn default_rss_history_limit() -> Option<usize> {
Some(500)
}
fn default_min_free_space() -> u64 {
1_073_741_824 }
impl Default for GeneralConfig {
fn default() -> Self {
Self {
listen_addr: "0.0.0.0".into(),
port: 9090,
api_key: None,
incomplete_dir: PathBuf::from("/downloads/incomplete"),
complete_dir: PathBuf::from("/downloads/complete"),
data_dir: PathBuf::from("/data"),
speed_limit_bps: 0,
cache_size: 500 * 1024 * 1024, log_level: "info".into(),
log_file: None,
history_retention: None, max_active_downloads: 1,
min_free_space_bytes: default_min_free_space(),
watch_dir: None,
rss_history_limit: default_rss_history_limit(),
direct_unpack: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OtelConfig {
pub enabled: bool,
pub endpoint: String,
pub service_name: String,
}
impl Default for OtelConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: "http://localhost:4317".into(),
service_name: "rustnzb".into(),
}
}
}
pub use nzb_nntp::ServerConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategoryConfig {
pub name: String,
pub output_dir: Option<PathBuf>,
pub post_processing: u8,
}
impl Default for CategoryConfig {
fn default() -> Self {
Self {
name: "Default".into(),
output_dir: None,
post_processing: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RssFeedConfig {
pub name: String,
pub url: String,
#[serde(default = "default_poll_interval")]
pub poll_interval_secs: u64,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub filter_regex: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub auto_download: bool,
}
fn default_poll_interval() -> u64 {
900
}
fn default_true() -> bool {
true
}
impl AppConfig {
pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
if path.exists() {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: AppConfig = toml::from_str(&contents)?;
Ok(config)
} else {
let config = AppConfig::default();
config.save(path).with_context(|| {
format!(
"Failed to create default config at {}. \
Check that the directory is writable by the current user. \
If using Docker with 'user:', ensure volume directories are owned by that user.",
path.display()
)
})?;
Ok(config)
}
}
pub fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(path, &contents)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
pub fn category(&self, name: &str) -> Option<&CategoryConfig> {
self.categories.iter().find(|c| c.name == name)
}
pub fn server(&self, id: &str) -> Option<&ServerConfig> {
self.servers.iter().find(|s| s.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_defaults() {
let cfg = ServerConfig::default();
assert_eq!(cfg.port, 563);
assert!(cfg.ssl);
assert!(cfg.ssl_verify);
assert!(cfg.username.is_none());
assert!(cfg.password.is_none());
assert_eq!(cfg.connections, 8);
assert_eq!(cfg.priority, 0);
assert!(cfg.enabled);
assert_eq!(cfg.retention, 0);
assert_eq!(cfg.pipelining, 1);
assert!(!cfg.optional);
}
#[test]
fn test_general_config_defaults() {
let cfg = GeneralConfig::default();
assert_eq!(cfg.listen_addr, "0.0.0.0");
assert_eq!(cfg.port, 9090);
assert!(cfg.api_key.is_none());
assert_eq!(cfg.speed_limit_bps, 0);
assert_eq!(cfg.cache_size, 500 * 1024 * 1024);
assert_eq!(cfg.log_level, "info");
assert!(cfg.log_file.is_none());
assert!(cfg.history_retention.is_none());
assert_eq!(cfg.max_active_downloads, 1);
assert_eq!(cfg.min_free_space_bytes, 1_073_741_824);
assert!(cfg.watch_dir.is_none());
assert_eq!(cfg.rss_history_limit, Some(500));
}
#[test]
fn test_app_config_defaults() {
let cfg = AppConfig::default();
assert!(cfg.servers.is_empty());
assert_eq!(cfg.categories.len(), 1);
assert_eq!(cfg.categories[0].name, "Default");
assert_eq!(cfg.categories[0].post_processing, 3);
assert!(!cfg.otel.enabled);
assert!(cfg.rss_feeds.is_empty());
}
#[test]
fn test_category_config_defaults() {
let cat = CategoryConfig::default();
assert_eq!(cat.name, "Default");
assert!(cat.output_dir.is_none());
assert_eq!(cat.post_processing, 3);
}
#[test]
fn test_server_config_toml_roundtrip() {
let original = ServerConfig {
id: "srv-1".into(),
name: "Usenet Provider".into(),
host: "news.example.com".into(),
port: 563,
ssl: true,
ssl_verify: true,
username: Some("user".into()),
password: Some("pass".into()),
connections: 20,
priority: 0,
enabled: true,
retention: 3000,
pipelining: 5,
optional: false,
compress: false,
ramp_up_delay_ms: 0,
recv_buffer_size: 0,
proxy_url: None,
};
let toml_str = toml::to_string_pretty(&original).unwrap();
let restored: ServerConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.id, original.id);
assert_eq!(restored.name, original.name);
assert_eq!(restored.host, original.host);
assert_eq!(restored.port, original.port);
assert_eq!(restored.ssl, original.ssl);
assert_eq!(restored.username, original.username);
assert_eq!(restored.password, original.password);
assert_eq!(restored.connections, original.connections);
assert_eq!(restored.priority, original.priority);
assert_eq!(restored.retention, original.retention);
assert_eq!(restored.pipelining, original.pipelining);
assert_eq!(restored.optional, original.optional);
}
#[test]
fn test_app_config_toml_roundtrip() {
let mut original = AppConfig::default();
original.servers.push(ServerConfig {
id: "test-srv".into(),
name: "Test".into(),
host: "news.test.com".into(),
port: 119,
ssl: false,
ssl_verify: false,
username: None,
password: None,
connections: 4,
priority: 1,
enabled: true,
retention: 0,
pipelining: 1,
optional: true,
compress: false,
ramp_up_delay_ms: 0,
recv_buffer_size: 0,
proxy_url: None,
});
original.general.speed_limit_bps = 1_000_000;
original.general.api_key = Some("secret-key".into());
let toml_str = toml::to_string_pretty(&original).unwrap();
let restored: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.servers.len(), 1);
assert_eq!(restored.servers[0].host, "news.test.com");
assert!(!restored.servers[0].ssl);
assert!(restored.servers[0].optional);
assert_eq!(restored.general.speed_limit_bps, 1_000_000);
assert_eq!(restored.general.api_key.as_deref(), Some("secret-key"));
assert_eq!(restored.categories.len(), 1);
}
#[test]
fn test_config_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut original = AppConfig::default();
original.servers.push(ServerConfig {
id: "file-srv".into(),
name: "File Test".into(),
host: "news.file.com".into(),
..ServerConfig::default()
});
original.general.port = 8888;
original.save(&path).unwrap();
assert!(path.exists());
let loaded = AppConfig::load(&path).unwrap();
assert_eq!(loaded.servers.len(), 1);
assert_eq!(loaded.servers[0].id, "file-srv");
assert_eq!(loaded.general.port, 8888);
}
#[test]
fn test_config_load_creates_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.toml");
let config = AppConfig::load(&path).unwrap();
assert!(config.servers.is_empty());
assert!(path.exists());
}
#[test]
fn test_config_find_category() {
let mut cfg = AppConfig::default();
cfg.categories.push(CategoryConfig {
name: "movies".into(),
output_dir: Some("/movies".into()),
post_processing: 3,
});
assert!(cfg.category("Default").is_some());
assert!(cfg.category("movies").is_some());
assert_eq!(cfg.category("movies").unwrap().post_processing, 3);
assert!(cfg.category("nonexistent").is_none());
}
#[test]
fn test_config_find_server() {
let mut cfg = AppConfig::default();
cfg.servers.push(ServerConfig {
id: "primary".into(),
name: "Primary".into(),
host: "news.primary.com".into(),
..ServerConfig::default()
});
assert!(cfg.server("primary").is_some());
assert_eq!(cfg.server("primary").unwrap().host, "news.primary.com");
assert!(cfg.server("nonexistent").is_none());
}
#[test]
fn test_rss_feed_config_defaults() {
let toml_str = r#"
name = "Test Feed"
url = "https://example.com/rss"
"#;
let feed: RssFeedConfig = toml::from_str(toml_str).unwrap();
assert_eq!(feed.name, "Test Feed");
assert_eq!(feed.poll_interval_secs, 900);
assert!(feed.enabled);
assert!(!feed.auto_download);
assert!(feed.category.is_none());
assert!(feed.filter_regex.is_none());
}
}