nntp_proxy/
config.rs

1//! Configuration module
2//!
3//! This module handles all configuration types and loading
4//! for the NNTP proxy server.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8
9/// Default maximum connections per server
10fn default_max_connections() -> u32 {
11    10
12}
13
14/// Default health check interval in seconds
15fn default_health_check_interval() -> u64 {
16    30
17}
18
19/// Default health check timeout in seconds
20fn default_health_check_timeout() -> u64 {
21    5
22}
23
24/// Default unhealthy threshold
25fn default_unhealthy_threshold() -> u32 {
26    3
27}
28
29/// Default cache max capacity (number of articles)
30fn default_cache_max_capacity() -> u64 {
31    10000
32}
33
34/// Default cache TTL in seconds (1 hour)
35fn default_cache_ttl_secs() -> u64 {
36    3600
37}
38
39/// Main proxy configuration
40#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
41pub struct Config {
42    /// List of backend NNTP servers
43    #[serde(default)]
44    pub servers: Vec<ServerConfig>,
45    /// Health check configuration
46    #[serde(default)]
47    pub health_check: HealthCheckConfig,
48    /// Cache configuration (optional, for caching proxy)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub cache: Option<CacheConfig>,
51}
52
53/// Cache configuration for article caching
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct CacheConfig {
56    /// Maximum number of articles to cache
57    #[serde(default = "default_cache_max_capacity")]
58    pub max_capacity: u64,
59    /// Time-to-live for cached articles in seconds
60    #[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/// Health check configuration
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct HealthCheckConfig {
76    /// Interval between health checks in seconds
77    #[serde(default = "default_health_check_interval")]
78    pub interval_secs: u64,
79    /// Timeout for each health check in seconds
80    #[serde(default = "default_health_check_timeout")]
81    pub timeout_secs: u64,
82    /// Number of consecutive failures before marking unhealthy
83    #[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/// Configuration for a single backend server
98#[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    /// Maximum number of concurrent connections to this server
108    #[serde(default = "default_max_connections")]
109    pub max_connections: u32,
110
111    /// Enable TLS/SSL for this backend connection
112    #[serde(default)]
113    pub use_tls: bool,
114    /// Verify TLS certificates (recommended for production)
115    #[serde(default = "default_tls_verify_cert")]
116    pub tls_verify_cert: bool,
117    /// Optional path to custom CA certificate
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub tls_cert_path: Option<String>,
120}
121
122/// Default for TLS certificate verification (true for security)
123fn default_tls_verify_cert() -> bool {
124    true
125}
126
127impl Config {
128    /// Validate configuration for correctness
129    ///
130    /// Checks for:
131    /// - Empty server names
132    /// - Invalid ports (0)
133    /// - Invalid max_connections (0)
134    /// - At least one server configured
135    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        // Validate health check configuration
164        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        // Validate cache configuration if present
177        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
190/// Load backend server configuration from environment variables
191///
192/// Supports indexed environment variables for Docker/container deployments:
193/// - `NNTP_SERVER_0_HOST`, `NNTP_SERVER_0_PORT`, `NNTP_SERVER_0_NAME`, etc.
194/// - `NNTP_SERVER_1_HOST`, `NNTP_SERVER_1_PORT`, `NNTP_SERVER_1_NAME`, etc.
195///
196/// Optional per-server variables:
197/// - `NNTP_SERVER_N_USERNAME` - Backend authentication username
198/// - `NNTP_SERVER_N_PASSWORD` - Backend authentication password
199/// - `NNTP_SERVER_N_MAX_CONNECTIONS` - Max connections (default: 10)
200///
201/// If any `NNTP_SERVER_N_HOST` is found, environment variables take precedence
202/// over config file servers.
203fn load_servers_from_env() -> Option<Vec<ServerConfig>> {
204    let mut servers = Vec::new();
205    let mut index = 0;
206
207    loop {
208        // Check if this server index exists by looking for HOST
209        let host_key = format!("NNTP_SERVER_{}_HOST", index);
210        let host = match std::env::var(&host_key) {
211            Ok(h) => h,
212            Err(_) => {
213                // No more servers found
214                break;
215            }
216        };
217
218        // Parse port (required)
219        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); // Default NNTP port
224
225        // Get name (required, use host as fallback)
226        let name_key = format!("NNTP_SERVER_{}_NAME", index);
227        let name = std::env::var(&name_key).unwrap_or_else(|_| format!("Server {}", index));
228
229        // Optional fields
230        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
264/// Load configuration from a TOML file, with environment variable overrides
265///
266/// Environment variables for backend servers take precedence over config file:
267/// - `NNTP_SERVER_0_HOST`, `NNTP_SERVER_0_PORT`, `NNTP_SERVER_0_NAME`
268/// - `NNTP_SERVER_1_HOST`, `NNTP_SERVER_1_PORT`, `NNTP_SERVER_1_NAME`
269/// - etc.
270///
271/// This allows Docker/container deployments to override servers without
272/// modifying the config file.
273pub 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    // Check for environment variable server overrides
281    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    // Validate the loaded configuration
290    config.validate()?;
291
292    Ok(config)
293}
294
295/// Create a default configuration for examples/testing
296#[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        // Create a temporary file
376        let mut temp_file = NamedTempFile::new()?;
377        write!(temp_file, "{}", config_toml)?;
378
379        // Load config from file
380        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        // Create a temporary file with invalid TOML
407        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        // Serialize to TOML
437        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        // Deserialize back
442        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        // Serialize and deserialize
466        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        // Should still parse - validation happens at runtime if needed
494        assert!(config.is_ok());
495
496        Ok(())
497    }
498
499    #[test]
500    fn test_config_edge_case_ports() -> Result<()> {
501        // Test minimum valid port
502        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        // Test maximum valid port
521        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        // Test minimum connections
643        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        // Test large connections
662        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    // Test helper functions to encapsulate unsafe env var operations
760    // SAFETY: These are only safe when tests are run serially (not in parallel)
761    // Use #[serial] attribute to ensure thread-safety
762    #[cfg(test)]
763    mod test_env_helpers {
764        /// Set an environment variable (test helper)
765        /// SAFETY: Only safe when called from #[serial] tests to avoid race conditions
766        pub fn set_env(key: &str, value: &str) {
767            unsafe {
768                std::env::set_var(key, value);
769            }
770        }
771
772        /// Remove an environment variable (test helper)
773        /// SAFETY: Only safe when called from #[serial] tests to avoid race conditions
774        pub fn remove_env(key: &str) {
775            unsafe {
776                std::env::remove_var(key);
777            }
778        }
779
780        /// Remove multiple environment variables (test helper)
781        /// SAFETY: Only safe when called from #[serial] tests to avoid race conditions
782        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 up environment variables for testing
797        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        // No name set - should use default "Server 1"
807        // No max_connections set - should use default 10
808
809        let servers = load_servers_from_env().expect("Should load servers from env");
810
811        assert_eq!(servers.len(), 2);
812
813        // Check first server
814        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        // Check second server
822        assert_eq!(servers[1].host, "env-server2.com");
823        assert_eq!(servers[1].port, 119);
824        assert_eq!(servers[1].name, "Server 1"); // Default name
825        assert_eq!(servers[1].username, None);
826        assert_eq!(servers[1].password, None);
827        assert_eq!(servers[1].max_connections, 10); // Default
828
829        // Clean up environment variables
830        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        // Ensure no env vars are set
846        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}