1use std::path::PathBuf;
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5
6#[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 pub listen_addr: String,
36 pub port: u16,
38 pub api_key: Option<String>,
40 pub incomplete_dir: PathBuf,
42 pub complete_dir: PathBuf,
44 pub data_dir: PathBuf,
46 pub speed_limit_bps: u64,
48 pub cache_size: u64,
50 pub log_level: String,
52 pub log_file: Option<PathBuf>,
54 pub history_retention: Option<usize>,
56 pub max_active_downloads: usize,
58 #[serde(default = "default_min_free_space")]
60 pub min_free_space_bytes: u64,
61 pub watch_dir: Option<PathBuf>,
63 #[serde(default = "default_rss_history_limit")]
65 pub rss_history_limit: Option<usize>,
66 #[serde(default = "default_true")]
70 pub direct_unpack: bool,
71 #[serde(default = "default_true")]
75 pub abort_hopeless: bool,
76 #[serde(default = "default_true")]
81 pub early_failure_check: bool,
82 #[serde(default = "default_required_completion_pct")]
88 pub required_completion_pct: f64,
89 #[serde(default = "default_article_timeout_secs")]
93 pub article_timeout_secs: u64,
94}
95
96fn default_rss_history_limit() -> Option<usize> {
97 Some(500)
98}
99
100fn default_min_free_space() -> u64 {
101 1_073_741_824 }
103
104fn default_required_completion_pct() -> f64 {
105 100.2
106}
107
108fn default_article_timeout_secs() -> u64 {
109 30
110}
111
112impl Default for GeneralConfig {
113 fn default() -> Self {
114 Self {
115 listen_addr: "0.0.0.0".into(),
116 port: 9090,
117 api_key: None,
118 incomplete_dir: PathBuf::from("/downloads/incomplete"),
119 complete_dir: PathBuf::from("/downloads/complete"),
120 data_dir: PathBuf::from("/data"),
121 speed_limit_bps: 0,
122 cache_size: 500 * 1024 * 1024, log_level: "info".into(),
124 log_file: None,
125 history_retention: None, max_active_downloads: 1,
127 min_free_space_bytes: default_min_free_space(),
128 watch_dir: None,
129 rss_history_limit: default_rss_history_limit(),
130 direct_unpack: true,
131 abort_hopeless: true,
132 early_failure_check: true,
133 required_completion_pct: default_required_completion_pct(),
134 article_timeout_secs: default_article_timeout_secs(),
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct OtelConfig {
143 pub enabled: bool,
145 pub endpoint: String,
147 pub service_name: String,
149}
150
151impl Default for OtelConfig {
152 fn default() -> Self {
153 Self {
154 enabled: false,
155 endpoint: "http://localhost:4317".into(),
156 service_name: "rustnzb".into(),
157 }
158 }
159}
160
161pub use nzb_nntp::ServerConfig;
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct CategoryConfig {
167 pub name: String,
169 pub output_dir: Option<PathBuf>,
171 pub post_processing: u8,
173}
174
175impl Default for CategoryConfig {
176 fn default() -> Self {
177 Self {
178 name: "Default".into(),
179 output_dir: None,
180 post_processing: 3,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct RssFeedConfig {
188 pub name: String,
190 pub url: String,
192 #[serde(default = "default_poll_interval")]
194 pub poll_interval_secs: u64,
195 #[serde(default)]
197 pub category: Option<String>,
198 #[serde(default)]
200 pub filter_regex: Option<String>,
201 #[serde(default = "default_true")]
203 pub enabled: bool,
204 #[serde(default)]
207 pub auto_download: bool,
208}
209
210fn default_poll_interval() -> u64 {
211 900
212}
213
214fn default_true() -> bool {
215 true
216}
217
218impl AppConfig {
219 pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
221 if path.exists() {
222 let contents = std::fs::read_to_string(path)
223 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
224 let config: AppConfig = toml::from_str(&contents)?;
225 Ok(config)
226 } else {
227 let config = AppConfig::default();
228 config.save(path).with_context(|| {
229 format!(
230 "Failed to create default config at {}. \
231 Check that the directory is writable by the current user. \
232 If using Docker with 'user:', ensure volume directories are owned by that user.",
233 path.display()
234 )
235 })?;
236 Ok(config)
237 }
238 }
239
240 pub fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
242 if let Some(parent) = path.parent() {
243 std::fs::create_dir_all(parent).with_context(|| {
244 format!("Failed to create config directory: {}", parent.display())
245 })?;
246 }
247 let contents = toml::to_string_pretty(self)?;
248 std::fs::write(path, &contents)
249 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
250 Ok(())
251 }
252
253 pub fn category(&self, name: &str) -> Option<&CategoryConfig> {
255 self.categories.iter().find(|c| c.name == name)
256 }
257
258 pub fn server(&self, id: &str) -> Option<&ServerConfig> {
260 self.servers.iter().find(|s| s.id == id)
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn test_server_config_defaults() {
270 let cfg = ServerConfig::default();
271 assert_eq!(cfg.port, 563);
272 assert!(cfg.ssl);
273 assert!(cfg.ssl_verify);
274 assert!(cfg.username.is_none());
275 assert!(cfg.password.is_none());
276 assert_eq!(cfg.connections, 8);
277 assert_eq!(cfg.priority, 0);
278 assert!(cfg.enabled);
279 assert_eq!(cfg.retention, 0);
280 assert_eq!(cfg.pipelining, 1);
281 assert!(!cfg.optional);
282 }
283
284 #[test]
285 fn test_general_config_defaults() {
286 let cfg = GeneralConfig::default();
287 assert_eq!(cfg.listen_addr, "0.0.0.0");
288 assert_eq!(cfg.port, 9090);
289 assert!(cfg.api_key.is_none());
290 assert_eq!(cfg.speed_limit_bps, 0);
291 assert_eq!(cfg.cache_size, 500 * 1024 * 1024);
292 assert_eq!(cfg.log_level, "info");
293 assert!(cfg.log_file.is_none());
294 assert!(cfg.history_retention.is_none());
295 assert_eq!(cfg.max_active_downloads, 1);
296 assert_eq!(cfg.min_free_space_bytes, 1_073_741_824);
297 assert!(cfg.watch_dir.is_none());
298 assert_eq!(cfg.rss_history_limit, Some(500));
299 }
300
301 #[test]
302 fn test_app_config_defaults() {
303 let cfg = AppConfig::default();
304 assert!(cfg.servers.is_empty());
305 assert_eq!(cfg.categories.len(), 1);
306 assert_eq!(cfg.categories[0].name, "Default");
307 assert_eq!(cfg.categories[0].post_processing, 3);
308 assert!(!cfg.otel.enabled);
309 assert!(cfg.rss_feeds.is_empty());
310 }
311
312 #[test]
313 fn test_category_config_defaults() {
314 let cat = CategoryConfig::default();
315 assert_eq!(cat.name, "Default");
316 assert!(cat.output_dir.is_none());
317 assert_eq!(cat.post_processing, 3);
318 }
319
320 #[test]
321 fn test_server_config_toml_roundtrip() {
322 let mut original = ServerConfig::new("srv-1", "news.example.com");
323 original.name = "Usenet Provider".into();
324 original.username = Some("user".into());
325 original.password = Some("pass".into());
326 original.connections = 20;
327 original.retention = 3000;
328 original.pipelining = 5;
329 original.ramp_up_delay_ms = 0;
330 original.recv_buffer_size = 0;
331
332 let toml_str = toml::to_string_pretty(&original).unwrap();
333 let restored: ServerConfig = toml::from_str(&toml_str).unwrap();
334
335 assert_eq!(restored.id, original.id);
336 assert_eq!(restored.name, original.name);
337 assert_eq!(restored.host, original.host);
338 assert_eq!(restored.port, original.port);
339 assert_eq!(restored.ssl, original.ssl);
340 assert_eq!(restored.username, original.username);
341 assert_eq!(restored.password, original.password);
342 assert_eq!(restored.connections, original.connections);
343 assert_eq!(restored.priority, original.priority);
344 assert_eq!(restored.retention, original.retention);
345 assert_eq!(restored.pipelining, original.pipelining);
346 assert_eq!(restored.optional, original.optional);
347 }
348
349 #[test]
350 fn test_app_config_toml_roundtrip() {
351 let mut original = AppConfig::default();
352 let mut srv = ServerConfig::new("test-srv", "news.test.com");
353 srv.name = "Test".into();
354 srv.port = 119;
355 srv.ssl = false;
356 srv.ssl_verify = false;
357 srv.connections = 4;
358 srv.priority = 1;
359 srv.optional = true;
360 srv.ramp_up_delay_ms = 0;
361 srv.recv_buffer_size = 0;
362 original.servers.push(srv);
363 original.general.speed_limit_bps = 1_000_000;
364 original.general.api_key = Some("secret-key".into());
365
366 let toml_str = toml::to_string_pretty(&original).unwrap();
367 let restored: AppConfig = toml::from_str(&toml_str).unwrap();
368
369 assert_eq!(restored.servers.len(), 1);
370 assert_eq!(restored.servers[0].host, "news.test.com");
371 assert!(!restored.servers[0].ssl);
372 assert!(restored.servers[0].optional);
373 assert_eq!(restored.general.speed_limit_bps, 1_000_000);
374 assert_eq!(restored.general.api_key.as_deref(), Some("secret-key"));
375 assert_eq!(restored.categories.len(), 1);
376 }
377
378 #[test]
379 fn test_config_save_and_load() {
380 let dir = tempfile::tempdir().unwrap();
381 let path = dir.path().join("config.toml");
382
383 let mut original = AppConfig::default();
384 let mut srv = ServerConfig::new("file-srv", "news.file.com");
385 srv.name = "File Test".into();
386 original.servers.push(srv);
387 original.general.port = 8888;
388
389 original.save(&path).unwrap();
390 assert!(path.exists());
391
392 let loaded = AppConfig::load(&path).unwrap();
393 assert_eq!(loaded.servers.len(), 1);
394 assert_eq!(loaded.servers[0].id, "file-srv");
395 assert_eq!(loaded.general.port, 8888);
396 }
397
398 #[test]
399 fn test_config_load_creates_default_when_missing() {
400 let dir = tempfile::tempdir().unwrap();
401 let path = dir.path().join("nonexistent.toml");
402
403 let config = AppConfig::load(&path).unwrap();
404 assert!(config.servers.is_empty());
405 assert!(path.exists());
407 }
408
409 #[test]
410 fn test_config_find_category() {
411 let mut cfg = AppConfig::default();
412 cfg.categories.push(CategoryConfig {
413 name: "movies".into(),
414 output_dir: Some("/movies".into()),
415 post_processing: 3,
416 });
417
418 assert!(cfg.category("Default").is_some());
419 assert!(cfg.category("movies").is_some());
420 assert_eq!(cfg.category("movies").unwrap().post_processing, 3);
421 assert!(cfg.category("nonexistent").is_none());
422 }
423
424 #[test]
425 fn test_config_find_server() {
426 let mut cfg = AppConfig::default();
427 let mut srv = ServerConfig::new("primary", "news.primary.com");
428 srv.name = "Primary".into();
429 cfg.servers.push(srv);
430
431 assert!(cfg.server("primary").is_some());
432 assert_eq!(cfg.server("primary").unwrap().host, "news.primary.com");
433 assert!(cfg.server("nonexistent").is_none());
434 }
435
436 #[test]
437 fn test_rss_feed_config_defaults() {
438 let toml_str = r#"
439 name = "Test Feed"
440 url = "https://example.com/rss"
441 "#;
442 let feed: RssFeedConfig = toml::from_str(toml_str).unwrap();
443 assert_eq!(feed.name, "Test Feed");
444 assert_eq!(feed.poll_interval_secs, 900);
445 assert!(feed.enabled);
446 assert!(!feed.auto_download);
447 assert!(feed.category.is_none());
448 assert!(feed.filter_regex.is_none());
449 }
450}