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}
72
73fn default_rss_history_limit() -> Option<usize> {
74 Some(500)
75}
76
77fn default_min_free_space() -> u64 {
78 1_073_741_824 }
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, log_level: "info".into(),
93 log_file: None,
94 history_retention: None, 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#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(default)]
107pub struct OtelConfig {
108 pub enabled: bool,
110 pub endpoint: String,
112 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
126pub use nzb_nntp::ServerConfig;
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct CategoryConfig {
132 pub name: String,
134 pub output_dir: Option<PathBuf>,
136 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#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct RssFeedConfig {
153 pub name: String,
155 pub url: String,
157 #[serde(default = "default_poll_interval")]
159 pub poll_interval_secs: u64,
160 #[serde(default)]
162 pub category: Option<String>,
163 #[serde(default)]
165 pub filter_regex: Option<String>,
166 #[serde(default = "default_true")]
168 pub enabled: bool,
169 #[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 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 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 pub fn category(&self, name: &str) -> Option<&CategoryConfig> {
220 self.categories.iter().find(|c| c.name == name)
221 }
222
223 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 proxy_url: None,
305 };
306
307 let toml_str = toml::to_string_pretty(&original).unwrap();
308 let restored: ServerConfig = toml::from_str(&toml_str).unwrap();
309
310 assert_eq!(restored.id, original.id);
311 assert_eq!(restored.name, original.name);
312 assert_eq!(restored.host, original.host);
313 assert_eq!(restored.port, original.port);
314 assert_eq!(restored.ssl, original.ssl);
315 assert_eq!(restored.username, original.username);
316 assert_eq!(restored.password, original.password);
317 assert_eq!(restored.connections, original.connections);
318 assert_eq!(restored.priority, original.priority);
319 assert_eq!(restored.retention, original.retention);
320 assert_eq!(restored.pipelining, original.pipelining);
321 assert_eq!(restored.optional, original.optional);
322 }
323
324 #[test]
325 fn test_app_config_toml_roundtrip() {
326 let mut original = AppConfig::default();
327 original.servers.push(ServerConfig {
328 id: "test-srv".into(),
329 name: "Test".into(),
330 host: "news.test.com".into(),
331 port: 119,
332 ssl: false,
333 ssl_verify: false,
334 username: None,
335 password: None,
336 connections: 4,
337 priority: 1,
338 enabled: true,
339 retention: 0,
340 pipelining: 1,
341 optional: true,
342 compress: false,
343 ramp_up_delay_ms: 0,
344 proxy_url: None,
345 });
346 original.general.speed_limit_bps = 1_000_000;
347 original.general.api_key = Some("secret-key".into());
348
349 let toml_str = toml::to_string_pretty(&original).unwrap();
350 let restored: AppConfig = toml::from_str(&toml_str).unwrap();
351
352 assert_eq!(restored.servers.len(), 1);
353 assert_eq!(restored.servers[0].host, "news.test.com");
354 assert!(!restored.servers[0].ssl);
355 assert!(restored.servers[0].optional);
356 assert_eq!(restored.general.speed_limit_bps, 1_000_000);
357 assert_eq!(restored.general.api_key.as_deref(), Some("secret-key"));
358 assert_eq!(restored.categories.len(), 1);
359 }
360
361 #[test]
362 fn test_config_save_and_load() {
363 let dir = tempfile::tempdir().unwrap();
364 let path = dir.path().join("config.toml");
365
366 let mut original = AppConfig::default();
367 original.servers.push(ServerConfig {
368 id: "file-srv".into(),
369 name: "File Test".into(),
370 host: "news.file.com".into(),
371 ..ServerConfig::default()
372 });
373 original.general.port = 8888;
374
375 original.save(&path).unwrap();
376 assert!(path.exists());
377
378 let loaded = AppConfig::load(&path).unwrap();
379 assert_eq!(loaded.servers.len(), 1);
380 assert_eq!(loaded.servers[0].id, "file-srv");
381 assert_eq!(loaded.general.port, 8888);
382 }
383
384 #[test]
385 fn test_config_load_creates_default_when_missing() {
386 let dir = tempfile::tempdir().unwrap();
387 let path = dir.path().join("nonexistent.toml");
388
389 let config = AppConfig::load(&path).unwrap();
390 assert!(config.servers.is_empty());
391 assert!(path.exists());
393 }
394
395 #[test]
396 fn test_config_find_category() {
397 let mut cfg = AppConfig::default();
398 cfg.categories.push(CategoryConfig {
399 name: "movies".into(),
400 output_dir: Some("/movies".into()),
401 post_processing: 3,
402 });
403
404 assert!(cfg.category("Default").is_some());
405 assert!(cfg.category("movies").is_some());
406 assert_eq!(cfg.category("movies").unwrap().post_processing, 3);
407 assert!(cfg.category("nonexistent").is_none());
408 }
409
410 #[test]
411 fn test_config_find_server() {
412 let mut cfg = AppConfig::default();
413 cfg.servers.push(ServerConfig {
414 id: "primary".into(),
415 name: "Primary".into(),
416 host: "news.primary.com".into(),
417 ..ServerConfig::default()
418 });
419
420 assert!(cfg.server("primary").is_some());
421 assert_eq!(cfg.server("primary").unwrap().host, "news.primary.com");
422 assert!(cfg.server("nonexistent").is_none());
423 }
424
425 #[test]
426 fn test_rss_feed_config_defaults() {
427 let toml_str = r#"
428 name = "Test Feed"
429 url = "https://example.com/rss"
430 "#;
431 let feed: RssFeedConfig = toml::from_str(toml_str).unwrap();
432 assert_eq!(feed.name, "Test Feed");
433 assert_eq!(feed.poll_interval_secs, 900);
434 assert!(feed.enabled);
435 assert!(!feed.auto_download);
436 assert!(feed.category.is_none());
437 assert!(feed.filter_regex.is_none());
438 }
439}