Skip to main content

nzb_core/
config.rs

1use std::path::PathBuf;
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5
6/// Top-level application configuration.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(default)]
9pub struct AppConfig {
10    pub general: GeneralConfig,
11    pub servers: Vec<ServerConfig>,
12    pub categories: Vec<CategoryConfig>,
13    #[serde(default)]
14    pub otel: OtelConfig,
15    #[serde(default)]
16    pub rss_feeds: Vec<RssFeedConfig>,
17}
18
19impl Default for AppConfig {
20    fn default() -> Self {
21        Self {
22            general: GeneralConfig::default(),
23            servers: Vec::new(),
24            categories: vec![CategoryConfig::default()],
25            otel: OtelConfig::default(),
26            rss_feeds: Vec::new(),
27        }
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(default)]
33pub struct GeneralConfig {
34    /// HTTP API listen address
35    pub listen_addr: String,
36    /// HTTP API port
37    pub port: u16,
38    /// API key for authentication
39    pub api_key: Option<String>,
40    /// Directory for incomplete downloads
41    pub incomplete_dir: PathBuf,
42    /// Directory for completed downloads
43    pub complete_dir: PathBuf,
44    /// Directory for application data (DB, logs)
45    pub data_dir: PathBuf,
46    /// Download speed limit in bytes/sec (0 = unlimited)
47    pub speed_limit_bps: u64,
48    /// Article cache size in bytes
49    pub cache_size: u64,
50    /// Log level
51    pub log_level: String,
52    /// Log file path (None = stdout only)
53    pub log_file: Option<PathBuf>,
54    /// History retention: how many NZBs to keep in history (None = keep all)
55    pub history_retention: Option<usize>,
56    /// Max number of NZBs downloading simultaneously (default 1)
57    pub max_active_downloads: usize,
58    /// Minimum free disk space in bytes before pausing downloads (default 1 GB)
59    #[serde(default = "default_min_free_space")]
60    pub min_free_space_bytes: u64,
61    /// Directory to watch for new .nzb files to auto-enqueue
62    pub watch_dir: Option<PathBuf>,
63    /// RSS feed history limit: how many feed items to keep (None = keep all, default 500)
64    #[serde(default = "default_rss_history_limit")]
65    pub rss_history_limit: Option<usize>,
66    /// Begin extracting RAR volumes during download instead of waiting for the
67    /// job to complete. Requires `unrar` on PATH. Falls back to normal
68    /// post-processing if articles fail or unrar is unavailable.
69    #[serde(default = "default_true")]
70    pub direct_unpack: bool,
71}
72
73fn default_rss_history_limit() -> Option<usize> {
74    Some(500)
75}
76
77fn default_min_free_space() -> u64 {
78    1_073_741_824 // 1 GB
79}
80
81impl Default for GeneralConfig {
82    fn default() -> Self {
83        Self {
84            listen_addr: "0.0.0.0".into(),
85            port: 9090,
86            api_key: None,
87            incomplete_dir: PathBuf::from("/downloads/incomplete"),
88            complete_dir: PathBuf::from("/downloads/complete"),
89            data_dir: PathBuf::from("/data"),
90            speed_limit_bps: 0,
91            cache_size: 500 * 1024 * 1024, // 500 MB
92            log_level: "info".into(),
93            log_file: None,
94            history_retention: None, // keep all
95            max_active_downloads: 1,
96            min_free_space_bytes: default_min_free_space(),
97            watch_dir: None,
98            rss_history_limit: default_rss_history_limit(),
99            direct_unpack: true,
100        }
101    }
102}
103
104/// OpenTelemetry configuration. All values can be overridden via env vars.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(default)]
107pub struct OtelConfig {
108    /// Enable OpenTelemetry export
109    pub enabled: bool,
110    /// OTLP endpoint for logs and metrics
111    pub endpoint: String,
112    /// Service name reported to the collector
113    pub service_name: String,
114}
115
116impl Default for OtelConfig {
117    fn default() -> Self {
118        Self {
119            enabled: false,
120            endpoint: "http://localhost:4317".into(),
121            service_name: "rustnzb".into(),
122        }
123    }
124}
125
126/// NNTP server configuration — re-exported from the `nzb-nntp` crate.
127pub use nzb_nntp::ServerConfig;
128
129/// Category configuration for organizing downloads.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct CategoryConfig {
132    /// Category name
133    pub name: String,
134    /// Output directory override (relative to complete_dir)
135    pub output_dir: Option<PathBuf>,
136    /// Post-processing level: 0=none, 1=repair, 2=unpack, 3=repair+unpack
137    pub post_processing: u8,
138}
139
140impl Default for CategoryConfig {
141    fn default() -> Self {
142        Self {
143            name: "Default".into(),
144            output_dir: None,
145            post_processing: 3,
146        }
147    }
148}
149
150/// RSS feed configuration for automatic NZB downloading.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct RssFeedConfig {
153    /// Display name for the feed
154    pub name: String,
155    /// Feed URL (RSS 2.0 or Atom)
156    pub url: String,
157    /// How often to poll, in seconds (default 900 = 15 minutes)
158    #[serde(default = "default_poll_interval")]
159    pub poll_interval_secs: u64,
160    /// Category to assign to downloaded NZBs
161    #[serde(default)]
162    pub category: Option<String>,
163    /// Regex pattern to filter feed entries by title
164    #[serde(default)]
165    pub filter_regex: Option<String>,
166    /// Whether this feed is active
167    #[serde(default = "default_true")]
168    pub enabled: bool,
169    /// Auto-download all items from this feed (no rules needed).
170    /// Ignored when filter_regex is set (use download rules instead).
171    #[serde(default)]
172    pub auto_download: bool,
173}
174
175fn default_poll_interval() -> u64 {
176    900
177}
178
179fn default_true() -> bool {
180    true
181}
182
183impl AppConfig {
184    /// Load config from a TOML file, creating default if it doesn't exist.
185    pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
186        if path.exists() {
187            let contents = std::fs::read_to_string(path)
188                .with_context(|| format!("Failed to read config file: {}", path.display()))?;
189            let config: AppConfig = toml::from_str(&contents)?;
190            Ok(config)
191        } else {
192            let config = AppConfig::default();
193            config.save(path).with_context(|| {
194                format!(
195                    "Failed to create default config at {}. \
196                     Check that the directory is writable by the current user. \
197                     If using Docker with 'user:', ensure volume directories are owned by that user.",
198                    path.display()
199                )
200            })?;
201            Ok(config)
202        }
203    }
204
205    /// Save config to a TOML file.
206    pub fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
207        if let Some(parent) = path.parent() {
208            std::fs::create_dir_all(parent).with_context(|| {
209                format!("Failed to create config directory: {}", parent.display())
210            })?;
211        }
212        let contents = toml::to_string_pretty(self)?;
213        std::fs::write(path, &contents)
214            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
215        Ok(())
216    }
217
218    /// Find a category by name.
219    pub fn category(&self, name: &str) -> Option<&CategoryConfig> {
220        self.categories.iter().find(|c| c.name == name)
221    }
222
223    /// Find a server by ID.
224    pub fn server(&self, id: &str) -> Option<&ServerConfig> {
225        self.servers.iter().find(|s| s.id == id)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_server_config_defaults() {
235        let cfg = ServerConfig::default();
236        assert_eq!(cfg.port, 563);
237        assert!(cfg.ssl);
238        assert!(cfg.ssl_verify);
239        assert!(cfg.username.is_none());
240        assert!(cfg.password.is_none());
241        assert_eq!(cfg.connections, 8);
242        assert_eq!(cfg.priority, 0);
243        assert!(cfg.enabled);
244        assert_eq!(cfg.retention, 0);
245        assert_eq!(cfg.pipelining, 1);
246        assert!(!cfg.optional);
247    }
248
249    #[test]
250    fn test_general_config_defaults() {
251        let cfg = GeneralConfig::default();
252        assert_eq!(cfg.listen_addr, "0.0.0.0");
253        assert_eq!(cfg.port, 9090);
254        assert!(cfg.api_key.is_none());
255        assert_eq!(cfg.speed_limit_bps, 0);
256        assert_eq!(cfg.cache_size, 500 * 1024 * 1024);
257        assert_eq!(cfg.log_level, "info");
258        assert!(cfg.log_file.is_none());
259        assert!(cfg.history_retention.is_none());
260        assert_eq!(cfg.max_active_downloads, 1);
261        assert_eq!(cfg.min_free_space_bytes, 1_073_741_824);
262        assert!(cfg.watch_dir.is_none());
263        assert_eq!(cfg.rss_history_limit, Some(500));
264    }
265
266    #[test]
267    fn test_app_config_defaults() {
268        let cfg = AppConfig::default();
269        assert!(cfg.servers.is_empty());
270        assert_eq!(cfg.categories.len(), 1);
271        assert_eq!(cfg.categories[0].name, "Default");
272        assert_eq!(cfg.categories[0].post_processing, 3);
273        assert!(!cfg.otel.enabled);
274        assert!(cfg.rss_feeds.is_empty());
275    }
276
277    #[test]
278    fn test_category_config_defaults() {
279        let cat = CategoryConfig::default();
280        assert_eq!(cat.name, "Default");
281        assert!(cat.output_dir.is_none());
282        assert_eq!(cat.post_processing, 3);
283    }
284
285    #[test]
286    fn test_server_config_toml_roundtrip() {
287        let original = ServerConfig {
288            id: "srv-1".into(),
289            name: "Usenet Provider".into(),
290            host: "news.example.com".into(),
291            port: 563,
292            ssl: true,
293            ssl_verify: true,
294            username: Some("user".into()),
295            password: Some("pass".into()),
296            connections: 20,
297            priority: 0,
298            enabled: true,
299            retention: 3000,
300            pipelining: 5,
301            optional: false,
302            compress: false,
303            ramp_up_delay_ms: 0,
304            recv_buffer_size: 0,
305            proxy_url: None,
306        };
307
308        let toml_str = toml::to_string_pretty(&original).unwrap();
309        let restored: ServerConfig = toml::from_str(&toml_str).unwrap();
310
311        assert_eq!(restored.id, original.id);
312        assert_eq!(restored.name, original.name);
313        assert_eq!(restored.host, original.host);
314        assert_eq!(restored.port, original.port);
315        assert_eq!(restored.ssl, original.ssl);
316        assert_eq!(restored.username, original.username);
317        assert_eq!(restored.password, original.password);
318        assert_eq!(restored.connections, original.connections);
319        assert_eq!(restored.priority, original.priority);
320        assert_eq!(restored.retention, original.retention);
321        assert_eq!(restored.pipelining, original.pipelining);
322        assert_eq!(restored.optional, original.optional);
323    }
324
325    #[test]
326    fn test_app_config_toml_roundtrip() {
327        let mut original = AppConfig::default();
328        original.servers.push(ServerConfig {
329            id: "test-srv".into(),
330            name: "Test".into(),
331            host: "news.test.com".into(),
332            port: 119,
333            ssl: false,
334            ssl_verify: false,
335            username: None,
336            password: None,
337            connections: 4,
338            priority: 1,
339            enabled: true,
340            retention: 0,
341            pipelining: 1,
342            optional: true,
343            compress: false,
344            ramp_up_delay_ms: 0,
345            recv_buffer_size: 0,
346            proxy_url: None,
347        });
348        original.general.speed_limit_bps = 1_000_000;
349        original.general.api_key = Some("secret-key".into());
350
351        let toml_str = toml::to_string_pretty(&original).unwrap();
352        let restored: AppConfig = toml::from_str(&toml_str).unwrap();
353
354        assert_eq!(restored.servers.len(), 1);
355        assert_eq!(restored.servers[0].host, "news.test.com");
356        assert!(!restored.servers[0].ssl);
357        assert!(restored.servers[0].optional);
358        assert_eq!(restored.general.speed_limit_bps, 1_000_000);
359        assert_eq!(restored.general.api_key.as_deref(), Some("secret-key"));
360        assert_eq!(restored.categories.len(), 1);
361    }
362
363    #[test]
364    fn test_config_save_and_load() {
365        let dir = tempfile::tempdir().unwrap();
366        let path = dir.path().join("config.toml");
367
368        let mut original = AppConfig::default();
369        original.servers.push(ServerConfig {
370            id: "file-srv".into(),
371            name: "File Test".into(),
372            host: "news.file.com".into(),
373            ..ServerConfig::default()
374        });
375        original.general.port = 8888;
376
377        original.save(&path).unwrap();
378        assert!(path.exists());
379
380        let loaded = AppConfig::load(&path).unwrap();
381        assert_eq!(loaded.servers.len(), 1);
382        assert_eq!(loaded.servers[0].id, "file-srv");
383        assert_eq!(loaded.general.port, 8888);
384    }
385
386    #[test]
387    fn test_config_load_creates_default_when_missing() {
388        let dir = tempfile::tempdir().unwrap();
389        let path = dir.path().join("nonexistent.toml");
390
391        let config = AppConfig::load(&path).unwrap();
392        assert!(config.servers.is_empty());
393        // File should now exist with default config
394        assert!(path.exists());
395    }
396
397    #[test]
398    fn test_config_find_category() {
399        let mut cfg = AppConfig::default();
400        cfg.categories.push(CategoryConfig {
401            name: "movies".into(),
402            output_dir: Some("/movies".into()),
403            post_processing: 3,
404        });
405
406        assert!(cfg.category("Default").is_some());
407        assert!(cfg.category("movies").is_some());
408        assert_eq!(cfg.category("movies").unwrap().post_processing, 3);
409        assert!(cfg.category("nonexistent").is_none());
410    }
411
412    #[test]
413    fn test_config_find_server() {
414        let mut cfg = AppConfig::default();
415        cfg.servers.push(ServerConfig {
416            id: "primary".into(),
417            name: "Primary".into(),
418            host: "news.primary.com".into(),
419            ..ServerConfig::default()
420        });
421
422        assert!(cfg.server("primary").is_some());
423        assert_eq!(cfg.server("primary").unwrap().host, "news.primary.com");
424        assert!(cfg.server("nonexistent").is_none());
425    }
426
427    #[test]
428    fn test_rss_feed_config_defaults() {
429        let toml_str = r#"
430            name = "Test Feed"
431            url = "https://example.com/rss"
432        "#;
433        let feed: RssFeedConfig = toml::from_str(toml_str).unwrap();
434        assert_eq!(feed.name, "Test Feed");
435        assert_eq!(feed.poll_interval_secs, 900);
436        assert!(feed.enabled);
437        assert!(!feed.auto_download);
438        assert!(feed.category.is_none());
439        assert!(feed.filter_regex.is_none());
440    }
441}