1use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9fn default_max_connections() -> u32 {
11 10
12}
13
14fn default_health_check_interval() -> u64 {
16 30
17}
18
19fn default_health_check_timeout() -> u64 {
21 5
22}
23
24fn default_unhealthy_threshold() -> u32 {
26 3
27}
28
29fn default_cache_max_capacity() -> u64 {
31 10000
32}
33
34fn default_cache_ttl_secs() -> u64 {
36 3600
37}
38
39#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
41pub struct Config {
42 #[serde(default)]
44 pub servers: Vec<ServerConfig>,
45 #[serde(default)]
47 pub health_check: HealthCheckConfig,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub cache: Option<CacheConfig>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct CacheConfig {
56 #[serde(default = "default_cache_max_capacity")]
58 pub max_capacity: u64,
59 #[serde(default = "default_cache_ttl_secs")]
61 pub ttl_secs: u64,
62}
63
64impl Default for CacheConfig {
65 fn default() -> Self {
66 Self {
67 max_capacity: default_cache_max_capacity(),
68 ttl_secs: default_cache_ttl_secs(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct HealthCheckConfig {
76 #[serde(default = "default_health_check_interval")]
78 pub interval_secs: u64,
79 #[serde(default = "default_health_check_timeout")]
81 pub timeout_secs: u64,
82 #[serde(default = "default_unhealthy_threshold")]
84 pub unhealthy_threshold: u32,
85}
86
87impl Default for HealthCheckConfig {
88 fn default() -> Self {
89 Self {
90 interval_secs: default_health_check_interval(),
91 timeout_secs: default_health_check_timeout(),
92 unhealthy_threshold: default_unhealthy_threshold(),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct ServerConfig {
100 pub host: String,
101 pub port: u16,
102 pub name: String,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub username: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub password: Option<String>,
107 #[serde(default = "default_max_connections")]
109 pub max_connections: u32,
110
111 #[serde(default)]
113 pub use_tls: bool,
114 #[serde(default = "default_tls_verify_cert")]
116 pub tls_verify_cert: bool,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub tls_cert_path: Option<String>,
120}
121
122fn default_tls_verify_cert() -> bool {
124 true
125}
126
127impl Config {
128 pub fn validate(&self) -> Result<()> {
136 if self.servers.is_empty() {
137 return Err(anyhow::anyhow!(
138 "Configuration must have at least one server"
139 ));
140 }
141
142 for server in &self.servers {
143 if server.name.trim().is_empty() {
144 return Err(anyhow::anyhow!("Server name cannot be empty"));
145 }
146 if server.host.trim().is_empty() {
147 return Err(anyhow::anyhow!("Server '{}' has empty host", server.name));
148 }
149 if server.port == 0 {
150 return Err(anyhow::anyhow!(
151 "Invalid port 0 for server '{}'",
152 server.name
153 ));
154 }
155 if server.max_connections == 0 {
156 return Err(anyhow::anyhow!(
157 "max_connections must be > 0 for server '{}'",
158 server.name
159 ));
160 }
161 }
162
163 if self.health_check.interval_secs == 0 {
165 return Err(anyhow::anyhow!("health_check.interval_secs must be > 0"));
166 }
167 if self.health_check.timeout_secs == 0 {
168 return Err(anyhow::anyhow!("health_check.timeout_secs must be > 0"));
169 }
170 if self.health_check.unhealthy_threshold == 0 {
171 return Err(anyhow::anyhow!(
172 "health_check.unhealthy_threshold must be > 0"
173 ));
174 }
175
176 if let Some(cache) = &self.cache {
178 if cache.max_capacity == 0 {
179 return Err(anyhow::anyhow!("cache.max_capacity must be > 0"));
180 }
181 if cache.ttl_secs == 0 {
182 return Err(anyhow::anyhow!("cache.ttl_secs must be > 0"));
183 }
184 }
185
186 Ok(())
187 }
188}
189
190fn load_servers_from_env() -> Option<Vec<ServerConfig>> {
204 let mut servers = Vec::new();
205 let mut index = 0;
206
207 loop {
208 let host_key = format!("NNTP_SERVER_{}_HOST", index);
210 let host = match std::env::var(&host_key) {
211 Ok(h) => h,
212 Err(_) => {
213 break;
215 }
216 };
217
218 let port_key = format!("NNTP_SERVER_{}_PORT", index);
220 let port = std::env::var(&port_key)
221 .ok()
222 .and_then(|p| p.parse::<u16>().ok())
223 .unwrap_or(119); let name_key = format!("NNTP_SERVER_{}_NAME", index);
227 let name = std::env::var(&name_key).unwrap_or_else(|_| format!("Server {}", index));
228
229 let username_key = format!("NNTP_SERVER_{}_USERNAME", index);
231 let username = std::env::var(&username_key).ok();
232
233 let password_key = format!("NNTP_SERVER_{}_PASSWORD", index);
234 let password = std::env::var(&password_key).ok();
235
236 let max_conn_key = format!("NNTP_SERVER_{}_MAX_CONNECTIONS", index);
237 let max_connections = std::env::var(&max_conn_key)
238 .ok()
239 .and_then(|m| m.parse::<u32>().ok())
240 .unwrap_or_else(default_max_connections);
241
242 servers.push(ServerConfig {
243 host,
244 port,
245 name,
246 username,
247 password,
248 max_connections,
249 use_tls: false,
250 tls_verify_cert: default_tls_verify_cert(),
251 tls_cert_path: None,
252 });
253
254 index += 1;
255 }
256
257 if servers.is_empty() {
258 None
259 } else {
260 Some(servers)
261 }
262}
263
264pub fn load_config(config_path: &str) -> Result<Config> {
274 let config_content = std::fs::read_to_string(config_path)
275 .map_err(|e| anyhow::anyhow!("Failed to read config file '{}': {}", config_path, e))?;
276
277 let mut config: Config = toml::from_str(&config_content)
278 .map_err(|e| anyhow::anyhow!("Failed to parse config file '{}': {}", config_path, e))?;
279
280 if let Some(env_servers) = load_servers_from_env() {
282 tracing::info!(
283 "Using {} backend server(s) from environment variables (overriding config file)",
284 env_servers.len()
285 );
286 config.servers = env_servers;
287 }
288
289 config.validate()?;
291
292 Ok(config)
293}
294
295#[must_use]
297pub fn create_default_config() -> Config {
298 Config {
299 servers: vec![ServerConfig {
300 host: "news.example.com".to_string(),
301 port: 119,
302 name: "Example News Server".to_string(),
303 username: None,
304 password: None,
305 max_connections: default_max_connections(),
306 use_tls: false,
307 tls_verify_cert: default_tls_verify_cert(),
308 tls_cert_path: None,
309 }],
310 ..Default::default()
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::io::Write;
318 use tempfile::NamedTempFile;
319
320 fn create_test_config() -> Config {
321 Config {
322 servers: vec![
323 ServerConfig {
324 host: "server1.example.com".to_string(),
325 port: 119,
326 name: "Test Server 1".to_string(),
327 username: None,
328 password: None,
329 max_connections: 5,
330 use_tls: false,
331 tls_verify_cert: default_tls_verify_cert(),
332 tls_cert_path: None,
333 },
334 ServerConfig {
335 host: "server2.example.com".to_string(),
336 port: 119,
337 name: "Test Server 2".to_string(),
338 username: None,
339 password: None,
340 max_connections: 8,
341 use_tls: false,
342 tls_verify_cert: default_tls_verify_cert(),
343 tls_cert_path: None,
344 },
345 ],
346 ..Default::default()
347 }
348 }
349
350 #[test]
351 fn test_server_config_creation() {
352 let config = ServerConfig {
353 host: "news.example.com".to_string(),
354 port: 119,
355 name: "Example Server".to_string(),
356 username: None,
357 password: None,
358 max_connections: 15,
359 use_tls: false,
360 tls_verify_cert: default_tls_verify_cert(),
361 tls_cert_path: None,
362 };
363
364 assert_eq!(config.host, "news.example.com");
365 assert_eq!(config.port, 119);
366 assert_eq!(config.name, "Example Server");
367 assert_eq!(config.max_connections, 15);
368 }
369
370 #[test]
371 fn test_load_config_from_file() -> Result<()> {
372 let config = create_test_config();
373 let config_toml = toml::to_string_pretty(&config)?;
374
375 let mut temp_file = NamedTempFile::new()?;
377 write!(temp_file, "{}", config_toml)?;
378
379 let loaded_config = load_config(temp_file.path().to_str().unwrap())?;
381
382 assert_eq!(loaded_config.servers.len(), 2);
383 assert_eq!(loaded_config.servers[0].name, "Test Server 1");
384 assert_eq!(loaded_config.servers[0].host, "server1.example.com");
385 assert_eq!(loaded_config.servers[0].port, 119);
386
387 Ok(())
388 }
389
390 #[test]
391 fn test_load_config_nonexistent_file() {
392 let result = load_config("/nonexistent/path/config.toml");
393 assert!(result.is_err());
394 assert!(
395 result
396 .unwrap_err()
397 .to_string()
398 .contains("Failed to read config file")
399 );
400 }
401
402 #[test]
403 fn test_load_config_invalid_toml() -> Result<()> {
404 let invalid_toml = "invalid toml content [[[";
405
406 let mut temp_file = NamedTempFile::new()?;
408 write!(temp_file, "{}", invalid_toml)?;
409
410 let result = load_config(temp_file.path().to_str().unwrap());
411 assert!(result.is_err());
412 assert!(
413 result
414 .unwrap_err()
415 .to_string()
416 .contains("Failed to parse config file")
417 );
418
419 Ok(())
420 }
421
422 #[test]
423 fn test_create_default_config() {
424 let config = create_default_config();
425
426 assert_eq!(config.servers.len(), 1);
427 assert_eq!(config.servers[0].host, "news.example.com");
428 assert_eq!(config.servers[0].port, 119);
429 assert_eq!(config.servers[0].name, "Example News Server");
430 }
431
432 #[test]
433 fn test_config_serialization() -> Result<()> {
434 let config = create_test_config();
435
436 let toml_string = toml::to_string_pretty(&config)?;
438 assert!(toml_string.contains("server1.example.com"));
439 assert!(toml_string.contains("Test Server 1"));
440
441 let deserialized: Config = toml::from_str(&toml_string)?;
443 assert_eq!(deserialized, config);
444
445 Ok(())
446 }
447
448 #[test]
449 fn test_config_with_authentication() -> Result<()> {
450 let config = Config {
451 servers: vec![ServerConfig {
452 host: "secure.news.com".to_string(),
453 port: 563,
454 name: "Secure Server".to_string(),
455 username: Some("user123".to_string()),
456 password: Some("password123".to_string()),
457 max_connections: 10,
458 use_tls: false,
459 tls_verify_cert: default_tls_verify_cert(),
460 tls_cert_path: None,
461 }],
462 ..Default::default()
463 };
464
465 let toml_string = toml::to_string_pretty(&config)?;
467 let deserialized: Config = toml::from_str(&toml_string)?;
468
469 assert_eq!(
470 deserialized.servers[0].username,
471 Some("user123".to_string())
472 );
473 assert_eq!(
474 deserialized.servers[0].password,
475 Some("password123".to_string())
476 );
477
478 Ok(())
479 }
480
481 #[test]
482 fn test_config_missing_username_with_password() -> Result<()> {
483 let toml_str = r#"
484[[servers]]
485host = "news.example.com"
486port = 119
487name = "Test"
488password = "secret"
489max_connections = 5
490"#;
491
492 let config: Result<Config, _> = toml::from_str(toml_str);
493 assert!(config.is_ok());
495
496 Ok(())
497 }
498
499 #[test]
500 fn test_config_edge_case_ports() -> Result<()> {
501 let config1 = Config {
503 servers: vec![ServerConfig {
504 host: "news.com".to_string(),
505 port: 1,
506 name: "Min Port".to_string(),
507 username: None,
508 password: None,
509 max_connections: 5,
510 use_tls: false,
511 tls_verify_cert: default_tls_verify_cert(),
512 tls_cert_path: None,
513 }],
514 ..Default::default()
515 };
516 let toml1 = toml::to_string(&config1)?;
517 let parsed1: Config = toml::from_str(&toml1)?;
518 assert_eq!(parsed1.servers[0].port, 1);
519
520 let config2 = Config {
522 servers: vec![ServerConfig {
523 host: "news.com".to_string(),
524 port: 65535,
525 name: "Max Port".to_string(),
526 username: None,
527 password: None,
528 max_connections: 5,
529 use_tls: false,
530 tls_verify_cert: default_tls_verify_cert(),
531 tls_cert_path: None,
532 }],
533 ..Default::default()
534 };
535 let toml2 = toml::to_string(&config2)?;
536 let parsed2: Config = toml::from_str(&toml2)?;
537 assert_eq!(parsed2.servers[0].port, 65535);
538
539 Ok(())
540 }
541
542 #[test]
543 fn test_config_multiple_servers() -> Result<()> {
544 let config = Config {
545 servers: vec![
546 ServerConfig {
547 host: "server1.com".to_string(),
548 port: 119,
549 name: "Server 1".to_string(),
550 username: None,
551 password: None,
552 max_connections: 5,
553 use_tls: false,
554 tls_verify_cert: default_tls_verify_cert(),
555 tls_cert_path: None,
556 },
557 ServerConfig {
558 host: "server2.com".to_string(),
559 port: 563,
560 name: "Server 2".to_string(),
561 username: Some("user".to_string()),
562 password: Some("pass".to_string()),
563 max_connections: 10,
564 use_tls: false,
565 tls_verify_cert: default_tls_verify_cert(),
566 tls_cert_path: None,
567 },
568 ServerConfig {
569 host: "server3.com".to_string(),
570 port: 8119,
571 name: "Server 3".to_string(),
572 username: None,
573 password: None,
574 max_connections: 15,
575 use_tls: false,
576 tls_verify_cert: default_tls_verify_cert(),
577 tls_cert_path: None,
578 },
579 ],
580 ..Default::default()
581 };
582
583 let toml_string = toml::to_string_pretty(&config)?;
584 let deserialized: Config = toml::from_str(&toml_string)?;
585
586 assert_eq!(deserialized.servers.len(), 3);
587 assert_eq!(deserialized.servers[1].username, Some("user".to_string()));
588 assert_eq!(deserialized.servers[2].port, 8119);
589
590 Ok(())
591 }
592
593 #[test]
594 fn test_config_empty_strings() -> Result<()> {
595 let toml_str = r#"
596[[servers]]
597host = ""
598port = 119
599name = ""
600max_connections = 5
601"#;
602
603 let config: Config = toml::from_str(toml_str)?;
604 assert_eq!(config.servers[0].host, "");
605 assert_eq!(config.servers[0].name, "");
606
607 Ok(())
608 }
609
610 #[test]
611 fn test_config_special_characters_in_strings() -> Result<()> {
612 let config = Config {
613 servers: vec![ServerConfig {
614 host: "news-server.example.com".to_string(),
615 port: 119,
616 name: "Test Server (Production) #1".to_string(),
617 username: Some("user@domain.com".to_string()),
618 password: Some("p@ssw0rd!#$%".to_string()),
619 max_connections: 5,
620 use_tls: false,
621 tls_verify_cert: default_tls_verify_cert(),
622 tls_cert_path: None,
623 }],
624 ..Default::default()
625 };
626
627 let toml_string = toml::to_string_pretty(&config)?;
628 let deserialized: Config = toml::from_str(&toml_string)?;
629
630 assert_eq!(deserialized.servers[0].host, "news-server.example.com");
631 assert_eq!(deserialized.servers[0].name, "Test Server (Production) #1");
632 assert_eq!(
633 deserialized.servers[0].username,
634 Some("user@domain.com".to_string())
635 );
636
637 Ok(())
638 }
639
640 #[test]
641 fn test_config_max_connections_bounds() -> Result<()> {
642 let config1 = Config {
644 servers: vec![ServerConfig {
645 host: "news.com".to_string(),
646 port: 119,
647 name: "Min Connections".to_string(),
648 username: None,
649 password: None,
650 max_connections: 1,
651 use_tls: false,
652 tls_verify_cert: default_tls_verify_cert(),
653 tls_cert_path: None,
654 }],
655 ..Default::default()
656 };
657 let toml1 = toml::to_string(&config1)?;
658 let parsed1: Config = toml::from_str(&toml1)?;
659 assert_eq!(parsed1.servers[0].max_connections, 1);
660
661 let config2 = Config {
663 servers: vec![ServerConfig {
664 host: "news.com".to_string(),
665 port: 119,
666 name: "Many Connections".to_string(),
667 username: None,
668 password: None,
669 max_connections: 1000,
670 use_tls: false,
671 tls_verify_cert: default_tls_verify_cert(),
672 tls_cert_path: None,
673 }],
674 ..Default::default()
675 };
676 let toml2 = toml::to_string(&config2)?;
677 let parsed2: Config = toml::from_str(&toml2)?;
678 assert_eq!(parsed2.servers[0].max_connections, 1000);
679
680 Ok(())
681 }
682
683 #[test]
684 fn test_config_ipv4_and_ipv6_hosts() -> Result<()> {
685 let config = Config {
686 servers: vec![
687 ServerConfig {
688 host: "192.168.1.1".to_string(),
689 port: 119,
690 name: "IPv4 Server".to_string(),
691 username: None,
692 password: None,
693 max_connections: 5,
694 use_tls: false,
695 tls_verify_cert: default_tls_verify_cert(),
696 tls_cert_path: None,
697 },
698 ServerConfig {
699 host: "::1".to_string(),
700 port: 119,
701 name: "IPv6 Server".to_string(),
702 username: None,
703 password: None,
704 max_connections: 5,
705 use_tls: false,
706 tls_verify_cert: default_tls_verify_cert(),
707 tls_cert_path: None,
708 },
709 ServerConfig {
710 host: "2001:db8::1".to_string(),
711 port: 119,
712 name: "IPv6 Global".to_string(),
713 username: None,
714 password: None,
715 max_connections: 5,
716 use_tls: false,
717 tls_verify_cert: default_tls_verify_cert(),
718 tls_cert_path: None,
719 },
720 ],
721 ..Default::default()
722 };
723
724 let toml_string = toml::to_string_pretty(&config)?;
725 let deserialized: Config = toml::from_str(&toml_string)?;
726
727 assert_eq!(deserialized.servers[0].host, "192.168.1.1");
728 assert_eq!(deserialized.servers[1].host, "::1");
729 assert_eq!(deserialized.servers[2].host, "2001:db8::1");
730
731 Ok(())
732 }
733
734 #[test]
735 fn test_config_unicode_in_names() -> Result<()> {
736 let config = Config {
737 servers: vec![ServerConfig {
738 host: "news.example.com".to_string(),
739 port: 119,
740 name: "测试服务器 🚀".to_string(),
741 username: None,
742 password: None,
743 max_connections: 5,
744 use_tls: false,
745 tls_verify_cert: default_tls_verify_cert(),
746 tls_cert_path: None,
747 }],
748 ..Default::default()
749 };
750
751 let toml_string = toml::to_string_pretty(&config)?;
752 let deserialized: Config = toml::from_str(&toml_string)?;
753
754 assert_eq!(deserialized.servers[0].name, "测试服务器 🚀");
755
756 Ok(())
757 }
758
759 #[cfg(test)]
763 mod test_env_helpers {
764 pub fn set_env(key: &str, value: &str) {
767 unsafe {
768 std::env::set_var(key, value);
769 }
770 }
771
772 pub fn remove_env(key: &str) {
775 unsafe {
776 std::env::remove_var(key);
777 }
778 }
779
780 pub fn remove_env_range(prefix: &str, start: usize, end: usize) {
783 unsafe {
784 for i in start..end {
785 std::env::remove_var(format!("{}_{}", prefix, i));
786 }
787 }
788 }
789 }
790
791 #[test]
792 #[serial_test::serial]
793 fn test_load_servers_from_env() {
794 use test_env_helpers::*;
795
796 set_env("NNTP_SERVER_0_HOST", "env-server1.com");
798 set_env("NNTP_SERVER_0_PORT", "8119");
799 set_env("NNTP_SERVER_0_NAME", "Env Server 1");
800 set_env("NNTP_SERVER_0_USERNAME", "envuser");
801 set_env("NNTP_SERVER_0_PASSWORD", "envpass");
802 set_env("NNTP_SERVER_0_MAX_CONNECTIONS", "15");
803
804 set_env("NNTP_SERVER_1_HOST", "env-server2.com");
805 set_env("NNTP_SERVER_1_PORT", "119");
806 let servers = load_servers_from_env().expect("Should load servers from env");
810
811 assert_eq!(servers.len(), 2);
812
813 assert_eq!(servers[0].host, "env-server1.com");
815 assert_eq!(servers[0].port, 8119);
816 assert_eq!(servers[0].name, "Env Server 1");
817 assert_eq!(servers[0].username, Some("envuser".to_string()));
818 assert_eq!(servers[0].password, Some("envpass".to_string()));
819 assert_eq!(servers[0].max_connections, 15);
820
821 assert_eq!(servers[1].host, "env-server2.com");
823 assert_eq!(servers[1].port, 119);
824 assert_eq!(servers[1].name, "Server 1"); assert_eq!(servers[1].username, None);
826 assert_eq!(servers[1].password, None);
827 assert_eq!(servers[1].max_connections, 10); remove_env("NNTP_SERVER_0_HOST");
831 remove_env("NNTP_SERVER_0_PORT");
832 remove_env("NNTP_SERVER_0_NAME");
833 remove_env("NNTP_SERVER_0_USERNAME");
834 remove_env("NNTP_SERVER_0_PASSWORD");
835 remove_env("NNTP_SERVER_0_MAX_CONNECTIONS");
836 remove_env("NNTP_SERVER_1_HOST");
837 remove_env("NNTP_SERVER_1_PORT");
838 }
839
840 #[test]
841 #[serial_test::serial]
842 fn test_load_servers_from_env_empty() {
843 use test_env_helpers::*;
844
845 remove_env_range("NNTP_SERVER", 0, 5);
847
848 let servers = load_servers_from_env();
849 assert!(servers.is_none(), "Should return None when no env vars set");
850 }
851}