Skip to main content

nntp_proxy/config/
loading.rs

1//! Configuration loading from files and environment variables
2//!
3//! This module handles loading configuration from TOML files and environment variables,
4//! with environment variables taking precedence for Docker/container deployments.
5
6use anyhow::{Context, Result};
7use serde::de::DeserializeOwned;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use super::defaults;
12use super::types::{Config, Server, UserCredentials};
13
14type TomlTable = toml::map::Map<String, toml::Value>;
15const CREDENTIALS_FILE_ENV: &str = "NNTP_PROXY_CREDENTIALS_FILE";
16
17#[derive(Debug, Default, serde::Deserialize)]
18struct CredentialsOverlay {
19    #[serde(default)]
20    servers: Vec<ServerCredentialsOverlay>,
21    #[serde(default)]
22    client_auth: ClientAuthCredentialsOverlay,
23}
24
25#[derive(Debug, serde::Deserialize)]
26struct ServerCredentialsOverlay {
27    name: String,
28    #[serde(default)]
29    username: Option<String>,
30    #[serde(default)]
31    password: Option<String>,
32}
33
34#[derive(Debug, Default, serde::Deserialize)]
35struct ClientAuthCredentialsOverlay {
36    #[serde(default)]
37    users: Vec<UserCredentials>,
38}
39
40fn table<'a>(value: &'a toml::Value, key: &str) -> Option<&'a TomlTable> {
41    value.get(key)?.as_table()
42}
43
44fn table_has_key(value: &toml::Value, section: &str, key: &str) -> bool {
45    table(value, section).is_some_and(|t| t.contains_key(key))
46}
47
48fn table_has_any_key(value: &toml::Value, section: &str, keys: &[&str]) -> bool {
49    keys.iter().any(|key| table_has_key(value, section, key))
50}
51
52fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
53    let mut path = path.as_os_str().to_os_string();
54    path.push(suffix);
55    PathBuf::from(path)
56}
57
58fn apply_credentials_overlay(config: &mut Config, overlay_path: &str) -> Result<()> {
59    let overlay_content = fs::read_to_string(overlay_path)
60        .with_context(|| format!("Failed to read credentials overlay file '{overlay_path}'"))?;
61    let overlay: CredentialsOverlay = toml::from_str(&overlay_content)
62        .with_context(|| format!("Failed to parse credentials overlay file '{overlay_path}'"))?;
63
64    for server_overlay in overlay.servers {
65        let server = config
66            .servers
67            .iter_mut()
68            .find(|server| server.name.as_str() == server_overlay.name)
69            .with_context(|| {
70                format!(
71                    "Credentials overlay '{}' references unknown server '{}'",
72                    overlay_path, server_overlay.name
73                )
74            })?;
75
76        if let Some(username) = server_overlay.username {
77            server.username = Some(username);
78        }
79        if let Some(password) = server_overlay.password {
80            server.password = Some(password);
81        }
82    }
83
84    for user in overlay.client_auth.users {
85        if let Some(existing) = config
86            .client_auth
87            .users
88            .iter_mut()
89            .find(|existing| existing.username == user.username)
90        {
91            existing.password = user.password;
92        } else {
93            config.client_auth.users.push(user);
94        }
95    }
96
97    Ok(())
98}
99
100fn apply_credentials_overlay_from_env<E: EnvProvider>(config: &mut Config, env: &E) -> Result<()> {
101    let Some(overlay_path) = env.get(CREDENTIALS_FILE_ENV) else {
102        return Ok(());
103    };
104
105    apply_credentials_overlay(config, &overlay_path)?;
106    tracing::info!(
107        "Loaded credentials overlay from '{}' via {}",
108        overlay_path,
109        CREDENTIALS_FILE_ENV
110    );
111    Ok(())
112}
113
114fn write_canonical_config(config_path: &str, config: &Config) -> Result<()> {
115    let canonical =
116        toml::to_string_pretty(config).context("Failed to serialize migrated config")?;
117    let path = Path::new(config_path);
118    let backup_path = path_with_suffix(path, ".bak");
119
120    fs::copy(path, &backup_path)
121        .with_context(|| format!("Failed to create backup config '{}'", backup_path.display()))?;
122
123    let tmp_path = path_with_suffix(path, ".tmp");
124    fs::write(&tmp_path, canonical).with_context(|| {
125        format!(
126            "Failed to write migrated config to temporary file '{}'",
127            tmp_path.display()
128        )
129    })?;
130
131    let original_permissions = fs::metadata(path)
132        .with_context(|| format!("Failed to read config metadata '{}'", path.display()))?
133        .permissions();
134    fs::set_permissions(&tmp_path, original_permissions).with_context(|| {
135        format!(
136            "Failed to preserve config permissions on temporary file '{}'",
137            tmp_path.display()
138        )
139    })?;
140
141    fs::rename(&tmp_path, path).with_context(|| {
142        format!(
143            "Failed to atomically replace config '{}' with migrated schema",
144            path.display()
145        )
146    })?;
147
148    Ok(())
149}
150
151fn assign_from_table<T>(target: &mut T, source: &TomlTable, key: &str) -> bool
152where
153    T: DeserializeOwned,
154{
155    let Some(value) = source
156        .get(key)
157        .and_then(|value| value.clone().try_into::<T>().ok())
158    else {
159        return false;
160    };
161
162    *target = value;
163    true
164}
165
166fn migrate_proxy_routing(config: &mut Config, raw: &toml::Value) -> bool {
167    let Some(proxy) = table(raw, "proxy") else {
168        return false;
169    };
170
171    let mut migrated = false;
172    if !table_has_any_key(raw, "routing", &["mode", "routing_mode"]) {
173        migrated |= assign_from_table(&mut config.routing.routing_mode, proxy, "routing_mode");
174    }
175    if !table_has_any_key(raw, "routing", &["backend_selection", "strategy"]) {
176        migrated |= assign_from_table(
177            &mut config.routing.backend_selection,
178            proxy,
179            "backend_selection",
180        );
181    }
182    migrated
183}
184
185fn migrate_proxy_memory(config: &mut Config, raw: &toml::Value) -> bool {
186    let Some(proxy) = table(raw, "proxy") else {
187        return false;
188    };
189
190    let mut migrated = false;
191    if !table_has_key(raw, "memory", "buffer_pool_count") {
192        migrated |= assign_from_table(
193            &mut config.memory.buffer_pool_count,
194            proxy,
195            "buffer_pool_count",
196        );
197    }
198    if !table_has_key(raw, "memory", "capture_pool_count") {
199        migrated |= assign_from_table(
200            &mut config.memory.capture_pool_count,
201            proxy,
202            "capture_pool_count",
203        );
204    }
205    migrated
206}
207
208fn migrate_cache_precheck(config: &mut Config, raw: &toml::Value) -> bool {
209    if !table_has_key(raw, "routing", "adaptive_precheck")
210        && let Some(cache) = table(raw, "cache")
211        && assign_from_table(
212            &mut config.routing.adaptive_precheck,
213            cache,
214            "adaptive_precheck",
215        )
216    {
217        return true;
218    }
219
220    false
221}
222
223fn migrate_client_auth_users(config: &mut Config, raw: &toml::Value) -> bool {
224    if !config.client_auth.users.is_empty() {
225        return false;
226    }
227
228    let Some(client_auth) = table(raw, "client_auth") else {
229        return false;
230    };
231
232    let Some(username) = client_auth
233        .get("username")
234        .and_then(|value| value.as_str())
235        .map(str::to_owned)
236    else {
237        return false;
238    };
239    let Some(password) = client_auth
240        .get("password")
241        .and_then(|value| value.as_str())
242        .map(str::to_owned)
243    else {
244        return false;
245    };
246
247    config
248        .client_auth
249        .users
250        .push(UserCredentials { username, password });
251    true
252}
253
254fn migrate_legacy_config(config: &mut Config, raw: &toml::Value) -> bool {
255    migrate_proxy_routing(config, raw)
256        | migrate_proxy_memory(config, raw)
257        | migrate_cache_precheck(config, raw)
258        | migrate_client_auth_users(config, raw)
259}
260
261/// Environment variable getter trait for dependency injection
262pub trait EnvProvider {
263    fn get(&self, key: &str) -> Option<String>;
264}
265
266/// Standard environment provider using `std::env::var`
267#[derive(Default)]
268pub struct StdEnvProvider;
269
270impl EnvProvider for StdEnvProvider {
271    fn get(&self, key: &str) -> Option<String> {
272        std::env::var(key).ok()
273    }
274}
275
276/// Parse server configuration from environment variables (pure function, easily testable)
277///
278/// # Arguments
279/// * `index` - Server index (0, 1, 2, ...)
280/// * `env` - Environment variable provider
281///
282/// # Returns
283/// Some(Server) if HOST variable exists, None otherwise
284pub fn parse_server_from_env<E: EnvProvider>(index: usize, env: &E) -> Option<Server> {
285    // Check if this server index exists by looking for HOST
286    let host_key = format!("NNTP_SERVER_{index}_HOST");
287    let host = env.get(&host_key)?;
288
289    // Parse port (required)
290    let port_key = format!("NNTP_SERVER_{index}_PORT");
291    let port = env
292        .get(&port_key)
293        .and_then(|p| p.parse::<u16>().ok())
294        .unwrap_or(119); // Default NNTP port
295
296    // Get name (required, use host as fallback)
297    let name_key = format!("NNTP_SERVER_{index}_NAME");
298    let name = env
299        .get(&name_key)
300        .unwrap_or_else(|| format!("Server {index}"));
301
302    // Optional fields
303    let username_key = format!("NNTP_SERVER_{index}_USERNAME");
304    let username = env.get(&username_key);
305
306    let password_key = format!("NNTP_SERVER_{index}_PASSWORD");
307    let password = env.get(&password_key);
308
309    let max_conn_key = format!("NNTP_SERVER_{index}_MAX_CONNECTIONS");
310    let max_connections = env
311        .get(&max_conn_key)
312        .and_then(|m| m.parse::<usize>().ok())
313        .and_then(|m| crate::types::MaxConnections::try_new(m).ok())
314        .unwrap_or_else(defaults::max_connections);
315
316    // TLS configuration
317    let use_tls_key = format!("NNTP_SERVER_{index}_USE_TLS");
318    let use_tls = env
319        .get(&use_tls_key)
320        .and_then(|v| v.parse::<bool>().ok())
321        .unwrap_or(false);
322
323    let tls_verify_key = format!("NNTP_SERVER_{index}_TLS_VERIFY_CERT");
324    let tls_verify_cert = env
325        .get(&tls_verify_key)
326        .and_then(|v| v.parse::<bool>().ok())
327        .unwrap_or_else(defaults::tls_verify_cert);
328
329    let tls_cert_path_key = format!("NNTP_SERVER_{index}_TLS_CERT_PATH");
330    let tls_cert_path = env.get(&tls_cert_path_key);
331
332    // Connection keepalive (in seconds)
333    let keepalive_key = format!("NNTP_SERVER_{index}_CONNECTION_KEEPALIVE");
334    let connection_keepalive = env
335        .get(&keepalive_key)
336        .and_then(|k| k.parse::<u64>().ok())
337        .map(std::time::Duration::from_secs);
338
339    // Health check configuration
340    let health_max_key = format!("NNTP_SERVER_{index}_HEALTH_CHECK_MAX_PER_CYCLE");
341    let health_check_max_per_cycle = env
342        .get(&health_max_key)
343        .and_then(|h| h.parse::<usize>().ok())
344        .unwrap_or_else(defaults::health_check_max_per_cycle);
345
346    let health_timeout_key = format!("NNTP_SERVER_{index}_HEALTH_CHECK_POOL_TIMEOUT");
347    let health_check_pool_timeout = env
348        .get(&health_timeout_key)
349        .and_then(|h| h.parse::<u64>().ok())
350        .map_or_else(
351            defaults::health_check_pool_timeout,
352            std::time::Duration::from_secs,
353        );
354
355    let tier_key = format!("NNTP_SERVER_{index}_TIER");
356    let tier = env.get(&tier_key).map_or(0, |tier_str| {
357        tier_str
358            .parse::<u8>()
359            .unwrap_or_else(|_| panic!("Invalid tier in {tier_key}: '{tier_str}' (must be 0-255)"))
360    });
361
362    Some(Server {
363        host: crate::types::HostName::try_new(host.clone())
364            .unwrap_or_else(|_| panic!("Invalid hostname in {host_key}: '{host}'")),
365        port: crate::types::Port::try_new(port)
366            .unwrap_or_else(|_| panic!("Invalid port in {port_key}: {port}")),
367        name: crate::types::ServerName::try_new(name.clone())
368            .unwrap_or_else(|_| panic!("Invalid server name in {name_key}: '{name}'")),
369        username,
370        password,
371        max_connections,
372        use_tls,
373        tls_verify_cert,
374        tls_cert_path,
375        connection_keepalive,
376        replacement_cooldown: crate::config::defaults::replacement_cooldown_option(),
377        health_check_max_per_cycle,
378        health_check_pool_timeout,
379        tier,
380        compress: None,
381        compress_level: None,
382        backend_idle_timeout: crate::config::defaults::backend_idle_timeout(),
383    })
384}
385
386/// Load servers using a custom environment provider (testable version)
387pub fn load_servers_from_env_provider<E: EnvProvider>(env: &E) -> Option<Vec<Server>> {
388    let servers: Vec<Server> = (0..)
389        .map_while(|index| parse_server_from_env(index, env))
390        .collect();
391
392    if servers.is_empty() {
393        None
394    } else {
395        Some(servers)
396    }
397}
398
399/// Check if any backend server environment variables are set
400///
401/// Returns true if at least `NNTP_SERVER_0_HOST` is set
402#[must_use]
403pub fn has_server_env_vars() -> bool {
404    std::env::var("NNTP_SERVER_0_HOST").is_ok()
405}
406
407/// Load configuration from environment variables only
408///
409/// Used when no config file is present. Requires at least `NNTP_SERVER_0_HOST` to be set.
410///
411/// # Errors
412///
413/// Returns an error if no backend servers are configured via environment variables.
414pub fn load_config_from_env() -> Result<Config> {
415    load_config_from_env_provider(&StdEnvProvider)
416}
417
418fn load_config_from_env_provider<E: EnvProvider>(env: &E) -> Result<Config> {
419    use anyhow::Context;
420
421    let servers = load_servers_from_env_provider(env)
422        .context("No backend servers configured via environment variables. Set NNTP_SERVER_0_HOST, NNTP_SERVER_0_PORT, etc.")?;
423
424    let mut config = Config {
425        servers,
426        ..Default::default()
427    };
428
429    apply_credentials_overlay_from_env(&mut config, env)?;
430
431    // Validate the loaded configuration
432    config.validate()?;
433
434    Ok(config)
435}
436
437/// Load configuration from a TOML file, with environment variable overrides
438///
439/// Environment variables for backend servers take precedence over config file:
440/// - `NNTP_SERVER_0_HOST`, `NNTP_SERVER_0_PORT`, `NNTP_SERVER_0_NAME`
441/// - `NNTP_SERVER_1_HOST`, `NNTP_SERVER_1_PORT`, `NNTP_SERVER_1_NAME`
442/// - etc.
443///
444/// This allows Docker/container deployments to override servers without
445/// modifying the config file.
446///
447/// # Errors
448/// Returns read/parsing or validation errors encountered while loading the
449/// config. Legacy schema write-back failures are logged but do not prevent
450/// startup because the migrated in-memory config can still be used.
451pub fn load_config(config_path: &str) -> Result<Config> {
452    load_config_with_env_provider(config_path, &StdEnvProvider)
453}
454
455fn load_config_with_env_provider<E: EnvProvider>(config_path: &str, env: &E) -> Result<Config> {
456    use anyhow::Context;
457
458    let config_content = std::fs::read_to_string(config_path)
459        .with_context(|| format!("Failed to read config file '{config_path}'"))?;
460
461    let raw_config: toml::Value = toml::from_str(&config_content)
462        .with_context(|| format!("Failed to parse config file '{config_path}'"))?;
463
464    let mut config: Config = toml::from_str(&config_content)
465        .with_context(|| format!("Failed to parse config file '{config_path}'"))?;
466
467    let mut file_config = config.clone();
468    let migrated = migrate_legacy_config(&mut file_config, &raw_config);
469
470    if migrated {
471        tracing::info!(
472            "Migrating legacy configuration schema in '{}' to the new routing/cache/memory/client-auth layout",
473            config_path
474        );
475        config = file_config;
476        if let Err(error) = write_canonical_config(config_path, &config) {
477            tracing::warn!(
478                "Failed to write migrated config schema for '{}': {:#}. Continuing with migrated in-memory config.",
479                config_path,
480                error
481            );
482        }
483    }
484
485    // Check for environment variable server overrides after migration so
486    // canonical file rewrites never discard container-provided backends.
487    if let Some(env_servers) = load_servers_from_env_provider(env) {
488        tracing::info!(
489            "Using {} backend server(s) from environment variables (overriding config file)",
490            env_servers.len()
491        );
492        config.servers = env_servers;
493    }
494
495    apply_credentials_overlay_from_env(&mut config, env)?;
496
497    // Validate the loaded configuration
498    config.validate()?;
499
500    Ok(config)
501}
502
503/// Configuration source
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum ConfigSource {
506    /// Loaded from TOML file
507    File,
508    /// Loaded from environment variables
509    Environment,
510    /// Default config created (file doesn't exist)
511    DefaultCreated,
512}
513
514impl ConfigSource {
515    /// Get a human-readable description
516    #[must_use]
517    pub const fn description(&self) -> &'static str {
518        match self {
519            Self::File => "configuration file",
520            Self::Environment => "environment variables",
521            Self::DefaultCreated => "default configuration (created)",
522        }
523    }
524}
525
526/// Load configuration with automatic fallback logic
527///
528/// Attempts to load configuration in this order:
529/// 1. If config file exists, load from file (with env var overrides)
530/// 2. Else if environment variables exist (`NNTP_SERVER_*`), load from env
531/// 3. Else create default config file and return default config
532///
533/// # Arguments
534/// * `config_path` - Path to configuration file
535///
536/// # Returns
537/// Tuple of (Config, `ConfigSource`) indicating where config came from
538///
539/// # Errors
540/// Returns error if:
541/// - Config file exists but can't be read or parsed
542/// - Environment variables exist but are invalid
543/// - Default config can't be created
544pub fn load_config_with_fallback(config_path: &str) -> Result<(Config, ConfigSource)> {
545    use anyhow::Context;
546
547    // Check if config file exists
548    if std::path::Path::new(config_path).exists() {
549        match load_config(config_path) {
550            Ok(config) => {
551                tracing::info!("Loaded configuration from file: {}", config_path);
552                return Ok((config, ConfigSource::File));
553            }
554            Err(e) => {
555                tracing::error!(
556                    "Failed to load existing config file '{}': {}",
557                    config_path,
558                    e
559                );
560                tracing::error!("Please check your config file syntax and try again");
561                return Err(e);
562            }
563        }
564    }
565
566    // Config file doesn't exist - check for environment variables
567    if has_server_env_vars() {
568        match load_config_from_env() {
569            Ok(config) => {
570                tracing::info!(
571                    "Using configuration from environment variables (no config file found)"
572                );
573                return Ok((config, ConfigSource::Environment));
574            }
575            Err(e) => {
576                tracing::error!(
577                    "Failed to load configuration from environment variables: {}",
578                    e
579                );
580                return Err(e);
581            }
582        }
583    }
584
585    // No config file and no env vars - create default
586    tracing::warn!(
587        "Config file '{}' not found and no NNTP_SERVER_* environment variables set",
588        config_path
589    );
590    tracing::warn!("Creating default config file - please edit it to add your backend servers");
591
592    let default_config = create_default_config();
593    let config_toml =
594        toml::to_string_pretty(&default_config).context("Failed to serialize default config")?;
595
596    std::fs::write(config_path, &config_toml)
597        .with_context(|| format!("Failed to write default config to '{config_path}'"))?;
598
599    tracing::info!("Created default config file: {}", config_path);
600    Ok((default_config, ConfigSource::DefaultCreated))
601}
602
603/// Create a default configuration for examples/testing
604#[must_use]
605///
606/// # Panics
607/// Panics only if the hard-coded example hostname, port, or server name stop
608/// satisfying their validated newtype constructors.
609pub fn create_default_config() -> Config {
610    Config {
611        servers: vec![Server {
612            host: crate::types::HostName::try_new("news.example.com".to_string())
613                .expect("Valid hostname"),
614            port: crate::types::Port::try_new(119).expect("Valid port"),
615            name: crate::types::ServerName::try_new("Example News Server".to_string())
616                .expect("Valid server name"),
617            username: None,
618            password: None,
619            max_connections: defaults::max_connections(),
620            use_tls: false,
621            tls_verify_cert: defaults::tls_verify_cert(),
622            tls_cert_path: None,
623            connection_keepalive: None,
624            replacement_cooldown: defaults::replacement_cooldown_option(),
625            health_check_max_per_cycle: defaults::health_check_max_per_cycle(),
626            health_check_pool_timeout: defaults::health_check_pool_timeout(),
627            tier: 0,
628            compress: None,
629            compress_level: None,
630            backend_idle_timeout: defaults::backend_idle_timeout(),
631        }],
632        ..Default::default()
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::config::{BackendSelectionStrategy, RoutingMode};
640    use std::collections::HashMap;
641    use std::io::Write;
642    use tempfile::NamedTempFile;
643
644    // Mock environment provider for testing
645    struct MockEnv {
646        vars: HashMap<String, String>,
647    }
648
649    impl MockEnv {
650        fn new() -> Self {
651            Self {
652                vars: HashMap::new(),
653            }
654        }
655
656        fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
657            self.vars.insert(key.into(), value.into());
658            self
659        }
660    }
661
662    impl EnvProvider for MockEnv {
663        fn get(&self, key: &str) -> Option<String> {
664            self.vars.get(key).cloned()
665        }
666    }
667
668    #[test]
669    fn test_parse_server_from_env_minimal() {
670        let mut env = MockEnv::new();
671        env.set("NNTP_SERVER_0_HOST", "news.example.com");
672
673        let server = parse_server_from_env(0, &env);
674        assert!(server.is_some());
675
676        let server = server.unwrap();
677        assert_eq!(server.host.as_str(), "news.example.com");
678        assert_eq!(server.port.get(), 119); // Default port
679        assert_eq!(server.name.as_str(), "Server 0"); // Default name
680        assert!(server.username.is_none());
681        assert!(server.password.is_none());
682    }
683
684    #[test]
685    fn test_parse_server_from_env_full() {
686        let mut env = MockEnv::new();
687        env.set("NNTP_SERVER_0_HOST", "secure.example.com")
688            .set("NNTP_SERVER_0_PORT", "563")
689            .set("NNTP_SERVER_0_NAME", "Secure News")
690            .set("NNTP_SERVER_0_USERNAME", "testuser")
691            .set("NNTP_SERVER_0_PASSWORD", "testpass")
692            .set("NNTP_SERVER_0_MAX_CONNECTIONS", "20")
693            .set("NNTP_SERVER_0_USE_TLS", "true")
694            .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "false");
695
696        let server = parse_server_from_env(0, &env).unwrap();
697        assert_eq!(server.host.as_str(), "secure.example.com");
698        assert_eq!(server.port.get(), 563);
699        assert_eq!(server.name.as_str(), "Secure News");
700        assert_eq!(server.username, Some("testuser".to_string()));
701        assert_eq!(server.password, Some("testpass".to_string()));
702        assert_eq!(server.max_connections.get(), 20);
703        assert!(server.use_tls);
704        assert!(!server.tls_verify_cert);
705    }
706
707    #[test]
708    fn test_parse_server_from_env_no_host() {
709        let env = MockEnv::new();
710        let server = parse_server_from_env(0, &env);
711        assert!(server.is_none());
712    }
713
714    #[test]
715    fn test_parse_server_from_env_invalid_port() {
716        let mut env = MockEnv::new();
717        env.set("NNTP_SERVER_0_HOST", "news.example.com")
718            .set("NNTP_SERVER_0_PORT", "invalid");
719
720        let server = parse_server_from_env(0, &env).unwrap();
721        assert_eq!(server.port.get(), 119); // Falls back to default
722    }
723
724    #[test]
725    fn test_parse_server_from_env_invalid_max_connections() {
726        let mut env = MockEnv::new();
727        env.set("NNTP_SERVER_0_HOST", "news.example.com")
728            .set("NNTP_SERVER_0_MAX_CONNECTIONS", "not_a_number");
729
730        let server = parse_server_from_env(0, &env).unwrap();
731        assert_eq!(server.max_connections.get(), 10); // Default
732    }
733
734    #[test]
735    fn test_parse_server_from_env_zero_max_connections() {
736        let mut env = MockEnv::new();
737        env.set("NNTP_SERVER_0_HOST", "news.example.com")
738            .set("NNTP_SERVER_0_MAX_CONNECTIONS", "0");
739
740        let server = parse_server_from_env(0, &env).unwrap();
741        assert_eq!(server.max_connections.get(), 10); // Falls back to default (NonZero rejects 0)
742    }
743
744    #[test]
745    fn test_parse_server_from_env_keepalive() {
746        let mut env = MockEnv::new();
747        env.set("NNTP_SERVER_0_HOST", "news.example.com")
748            .set("NNTP_SERVER_0_CONNECTION_KEEPALIVE", "300");
749
750        let server = parse_server_from_env(0, &env).unwrap();
751        assert_eq!(
752            server.connection_keepalive,
753            Some(crate::constants::duration_polyfill::from_minutes(5))
754        );
755    }
756
757    #[test]
758    fn test_parse_server_from_env_health_check_config() {
759        let mut env = MockEnv::new();
760        env.set("NNTP_SERVER_0_HOST", "news.example.com")
761            .set("NNTP_SERVER_0_HEALTH_CHECK_MAX_PER_CYCLE", "5")
762            .set("NNTP_SERVER_0_HEALTH_CHECK_POOL_TIMEOUT", "15");
763
764        let server = parse_server_from_env(0, &env).unwrap();
765        assert_eq!(server.health_check_max_per_cycle, 5);
766        assert_eq!(
767            server.health_check_pool_timeout,
768            std::time::Duration::from_secs(15)
769        );
770    }
771
772    #[test]
773    fn test_parse_server_from_env_tls_cert_path() {
774        let mut env = MockEnv::new();
775        env.set("NNTP_SERVER_0_HOST", "news.example.com")
776            .set("NNTP_SERVER_0_USE_TLS", "true")
777            .set("NNTP_SERVER_0_TLS_CERT_PATH", "/path/to/ca.pem");
778
779        let server = parse_server_from_env(0, &env).unwrap();
780        assert!(server.use_tls);
781        assert_eq!(server.tls_cert_path, Some("/path/to/ca.pem".to_string()));
782    }
783
784    #[test]
785    fn test_load_servers_from_env_provider_empty() {
786        let env = MockEnv::new();
787        let servers = load_servers_from_env_provider(&env);
788        assert!(servers.is_none());
789    }
790
791    #[test]
792    fn test_load_servers_from_env_provider_single() {
793        let mut env = MockEnv::new();
794        env.set("NNTP_SERVER_0_HOST", "news1.example.com");
795
796        let servers = load_servers_from_env_provider(&env);
797        assert!(servers.is_some());
798
799        let servers = servers.unwrap();
800        assert_eq!(servers.len(), 1);
801        assert_eq!(servers[0].host.as_str(), "news1.example.com");
802    }
803
804    #[test]
805    fn test_load_servers_from_env_provider_multiple() {
806        let mut env = MockEnv::new();
807        env.set("NNTP_SERVER_0_HOST", "news1.example.com")
808            .set("NNTP_SERVER_0_PORT", "119")
809            .set("NNTP_SERVER_1_HOST", "news2.example.com")
810            .set("NNTP_SERVER_1_PORT", "563")
811            .set("NNTP_SERVER_1_USE_TLS", "true")
812            .set("NNTP_SERVER_2_HOST", "news3.example.com");
813
814        let servers = load_servers_from_env_provider(&env);
815        assert!(servers.is_some());
816
817        let servers = servers.unwrap();
818        assert_eq!(servers.len(), 3);
819        assert_eq!(servers[0].host.as_str(), "news1.example.com");
820        assert_eq!(servers[1].host.as_str(), "news2.example.com");
821        assert_eq!(servers[2].host.as_str(), "news3.example.com");
822        assert!(servers[1].use_tls);
823        assert!(!servers[0].use_tls);
824    }
825
826    #[test]
827    fn test_load_servers_from_env_provider_gaps() {
828        let mut env = MockEnv::new();
829        // Server 0 and 2 defined, but not 1 - should stop at 1
830        env.set("NNTP_SERVER_0_HOST", "news1.example.com")
831            .set("NNTP_SERVER_2_HOST", "news3.example.com");
832
833        let servers = load_servers_from_env_provider(&env);
834        assert!(servers.is_some());
835
836        let servers = servers.unwrap();
837        // Should only get server 0, stops at first gap
838        assert_eq!(servers.len(), 1);
839        assert_eq!(servers[0].host.as_str(), "news1.example.com");
840    }
841
842    #[test]
843    fn test_parse_server_from_env_bool_variations() {
844        let mut env = MockEnv::new();
845        env.set("NNTP_SERVER_0_HOST", "news.example.com")
846            .set("NNTP_SERVER_0_USE_TLS", "True")
847            .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "FALSE");
848
849        let server = parse_server_from_env(0, &env).unwrap();
850        // Rust's parse::<bool>() requires exact "true"/"false" lowercase
851        // So these should fail to parse and use defaults
852        assert!(!server.use_tls); // Defaults to false
853        assert!(server.tls_verify_cert); // Defaults to true
854    }
855
856    #[test]
857    fn test_parse_server_from_env_correct_bool() {
858        let mut env = MockEnv::new();
859        env.set("NNTP_SERVER_0_HOST", "news.example.com")
860            .set("NNTP_SERVER_0_USE_TLS", "true")
861            .set("NNTP_SERVER_0_TLS_VERIFY_CERT", "false");
862
863        let server = parse_server_from_env(0, &env).unwrap();
864        assert!(server.use_tls);
865        assert!(!server.tls_verify_cert);
866    }
867
868    #[test]
869    fn test_config_source_description() {
870        assert_eq!(ConfigSource::File.description(), "configuration file");
871        assert_eq!(
872            ConfigSource::Environment.description(),
873            "environment variables"
874        );
875        assert_eq!(
876            ConfigSource::DefaultCreated.description(),
877            "default configuration (created)"
878        );
879    }
880
881    #[test]
882    fn test_config_source_equality() {
883        assert_eq!(ConfigSource::File, ConfigSource::File);
884        assert_ne!(ConfigSource::File, ConfigSource::Environment);
885        assert_ne!(ConfigSource::Environment, ConfigSource::DefaultCreated);
886    }
887
888    #[test]
889    fn test_load_config_with_fallback_creates_default() {
890        use tempfile::NamedTempFile;
891
892        let temp_file = NamedTempFile::new().unwrap();
893        let path = temp_file.path().to_str().unwrap().to_string();
894
895        // Remove the temp file so it doesn't exist
896        drop(temp_file);
897
898        // Should create default config
899        let result = load_config_with_fallback(&path);
900        assert!(result.is_ok());
901
902        let (config, source) = result.unwrap();
903        assert_eq!(source, ConfigSource::DefaultCreated);
904        assert_eq!(config.servers.len(), 1);
905        assert_eq!(config.servers[0].host.as_str(), "news.example.com");
906
907        // Cleanup
908        let _ = std::fs::remove_file(&path);
909    }
910
911    #[test]
912    fn test_load_config_with_fallback_reads_existing() {
913        use std::io::Write;
914        use tempfile::NamedTempFile;
915
916        let mut temp_file = NamedTempFile::new().unwrap();
917
918        // Write a valid config
919        let config_content = r#"
920[[servers]]
921host = "test.example.com"
922port = 119
923name = "Test Server"
924"#;
925        temp_file.write_all(config_content.as_bytes()).unwrap();
926        temp_file.flush().unwrap();
927
928        // Get path as owned string before borrowing for read
929        let path = temp_file.path().to_str().unwrap().to_string();
930
931        let result = load_config_with_fallback(&path);
932        assert!(result.is_ok());
933
934        let (config, source) = result.unwrap();
935        assert_eq!(source, ConfigSource::File);
936        assert_eq!(config.servers.len(), 1);
937        assert_eq!(config.servers[0].host.as_str(), "test.example.com");
938    }
939
940    #[test]
941    fn test_load_config_applies_credentials_overlay() {
942        let mut config_file = NamedTempFile::new().unwrap();
943        let config_content = r#"
944[[servers]]
945host = "news.example.com"
946port = 563
947name = "Primary"
948use_tls = true
949
950[proxy]
951port = 8121
952"#;
953        config_file.write_all(config_content.as_bytes()).unwrap();
954        config_file.flush().unwrap();
955
956        let mut credentials_file = NamedTempFile::new().unwrap();
957        let credentials_content = r#"
958[[servers]]
959name = "Primary"
960username = "backend-user"
961password = "backend-pass"
962
963[[client_auth.users]]
964username = "sabnzbd"
965password = "client-pass"
966"#;
967        credentials_file
968            .write_all(credentials_content.as_bytes())
969            .unwrap();
970        credentials_file.flush().unwrap();
971
972        let mut env = MockEnv::new();
973        env.set(
974            CREDENTIALS_FILE_ENV,
975            credentials_file.path().to_str().unwrap(),
976        );
977
978        let config =
979            load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap();
980
981        assert_eq!(config.servers[0].username.as_deref(), Some("backend-user"));
982        assert_eq!(config.servers[0].password.as_deref(), Some("backend-pass"));
983        assert_eq!(config.client_auth.users.len(), 1);
984        assert_eq!(config.client_auth.users[0].username, "sabnzbd");
985        assert_eq!(config.client_auth.users[0].password, "client-pass");
986    }
987
988    #[test]
989    fn test_load_config_overlay_updates_existing_client_auth_user() {
990        let mut config_file = NamedTempFile::new().unwrap();
991        let config_content = r#"
992[[servers]]
993host = "news.example.com"
994port = 563
995name = "Primary"
996
997[[client_auth.users]]
998username = "sabnzbd"
999password = "old-pass"
1000"#;
1001        config_file.write_all(config_content.as_bytes()).unwrap();
1002        config_file.flush().unwrap();
1003
1004        let mut credentials_file = NamedTempFile::new().unwrap();
1005        let credentials_content = r#"
1006[[client_auth.users]]
1007username = "sabnzbd"
1008password = "new-pass"
1009"#;
1010        credentials_file
1011            .write_all(credentials_content.as_bytes())
1012            .unwrap();
1013        credentials_file.flush().unwrap();
1014
1015        let mut env = MockEnv::new();
1016        env.set(
1017            CREDENTIALS_FILE_ENV,
1018            credentials_file.path().to_str().unwrap(),
1019        );
1020
1021        let config =
1022            load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap();
1023
1024        assert_eq!(config.client_auth.users.len(), 1);
1025        assert_eq!(config.client_auth.users[0].password, "new-pass");
1026    }
1027
1028    #[test]
1029    fn test_load_config_overlay_errors_on_unknown_server() {
1030        let mut config_file = NamedTempFile::new().unwrap();
1031        let config_content = r#"
1032[[servers]]
1033host = "news.example.com"
1034port = 563
1035name = "Primary"
1036"#;
1037        config_file.write_all(config_content.as_bytes()).unwrap();
1038        config_file.flush().unwrap();
1039
1040        let mut credentials_file = NamedTempFile::new().unwrap();
1041        let credentials_content = r#"
1042[[servers]]
1043name = "Missing"
1044username = "backend-user"
1045"#;
1046        credentials_file
1047            .write_all(credentials_content.as_bytes())
1048            .unwrap();
1049        credentials_file.flush().unwrap();
1050
1051        let mut env = MockEnv::new();
1052        env.set(
1053            CREDENTIALS_FILE_ENV,
1054            credentials_file.path().to_str().unwrap(),
1055        );
1056
1057        let error =
1058            load_config_with_env_provider(config_file.path().to_str().unwrap(), &env).unwrap_err();
1059
1060        assert!(
1061            error
1062                .to_string()
1063                .contains("references unknown server 'Missing'")
1064        );
1065    }
1066
1067    #[test]
1068    fn test_create_default_config() {
1069        let config = create_default_config();
1070        assert_eq!(config.servers.len(), 1);
1071        assert_eq!(config.servers[0].host.as_str(), "news.example.com");
1072        assert_eq!(config.servers[0].port.get(), 119);
1073        assert!(!config.servers[0].use_tls);
1074    }
1075
1076    #[test]
1077    fn test_load_config_migrates_legacy_schema() {
1078        let mut temp_file = NamedTempFile::new().unwrap();
1079
1080        let config_content = r#"
1081[[servers]]
1082host = "legacy.example.com"
1083port = 119
1084name = "Legacy Server"
1085
1086[proxy]
1087routing_mode = "per-command"
1088backend_selection = "least-loaded"
1089buffer_pool_count = 64
1090capture_pool_count = 32
1091
1092[cache]
1093max_capacity = "256mib"
1094ttl = 1800
1095cache_articles = true
1096adaptive_precheck = true
1097"#;
1098        temp_file.write_all(config_content.as_bytes()).unwrap();
1099        temp_file.flush().unwrap();
1100
1101        let path = temp_file.path().to_str().unwrap().to_string();
1102        let config = load_config(&path).unwrap();
1103
1104        assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1105        assert_eq!(
1106            config.routing.backend_selection,
1107            BackendSelectionStrategy::LeastLoaded
1108        );
1109        assert_eq!(config.memory.buffer_pool_count, 64);
1110        assert_eq!(config.memory.capture_pool_count, 32);
1111        assert!(config.routing.adaptive_precheck);
1112        let cache = config.cache.as_ref().unwrap();
1113        assert_eq!(cache.article_cache_capacity.get(), 256 * 1024 * 1024);
1114        assert_eq!(cache.article_cache_ttl_secs.as_secs(), 1800);
1115        assert!(cache.store_article_bodies);
1116
1117        let migrated = std::fs::read_to_string(&path).unwrap();
1118        assert!(migrated.contains("[routing]"));
1119        assert!(migrated.contains("[memory]"));
1120        assert!(migrated.contains("mode = \"per-command\""));
1121        assert!(migrated.contains("buffer_pool_count = 64"));
1122        assert!(migrated.contains("capture_pool_count = 32"));
1123        assert!(migrated.contains("article_cache_capacity = 268435456"));
1124        assert!(migrated.contains("article_cache_ttl_secs = 1800"));
1125        assert!(migrated.contains("store_article_bodies = true"));
1126
1127        let proxy_offset = migrated.find("[proxy]").unwrap();
1128        let routing_offset = migrated.find("[routing]").unwrap();
1129        let memory_offset = migrated.find("[memory]").unwrap();
1130        let cache_offset = migrated.find("[cache]").unwrap();
1131        let health_check_offset = migrated.find("[health_check]").unwrap();
1132        let client_auth_offset = migrated.find("[client_auth]").unwrap();
1133        let servers_offset = migrated.find("[[servers]]").unwrap();
1134
1135        assert!(proxy_offset < routing_offset);
1136        assert!(routing_offset < memory_offset);
1137        assert!(memory_offset < cache_offset);
1138        assert!(cache_offset < health_check_offset);
1139        assert!(health_check_offset < client_auth_offset);
1140        assert!(client_auth_offset < servers_offset);
1141
1142        let backup_path = format!("{path}.bak");
1143        assert!(std::path::Path::new(&backup_path).exists());
1144    }
1145
1146    #[cfg(unix)]
1147    #[test]
1148    fn test_load_config_preserves_file_mode_when_migrating() {
1149        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1150
1151        let mut temp_file = NamedTempFile::new().unwrap();
1152
1153        let config_content = r#"
1154[[servers]]
1155host = "legacy.example.com"
1156port = 119
1157name = "Legacy Server"
1158
1159[proxy]
1160routing_mode = "per-command"
1161backend_selection = "least-loaded"
1162"#;
1163        temp_file.write_all(config_content.as_bytes()).unwrap();
1164        temp_file.flush().unwrap();
1165
1166        let path = temp_file.path().to_path_buf();
1167        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1168
1169        load_config_with_env_provider(path.to_str().unwrap(), &MockEnv::new()).unwrap();
1170
1171        let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
1172        assert_eq!(mode, 0o600);
1173    }
1174
1175    #[test]
1176    fn test_load_config_preserves_env_server_overrides_after_migration() {
1177        let mut temp_file = NamedTempFile::new().unwrap();
1178
1179        let config_content = r#"
1180[proxy]
1181routing_mode = "per-command"
1182backend_selection = "least-loaded"
1183buffer_pool_count = 64
1184capture_pool_count = 32
1185"#;
1186        temp_file.write_all(config_content.as_bytes()).unwrap();
1187        temp_file.flush().unwrap();
1188
1189        let mut env = MockEnv::new();
1190        env.set("NNTP_SERVER_0_HOST", "env.example.com")
1191            .set("NNTP_SERVER_0_PORT", "563")
1192            .set("NNTP_SERVER_0_NAME", "Env Server")
1193            .set("NNTP_SERVER_0_USE_TLS", "true");
1194
1195        let path = temp_file.path().to_str().unwrap().to_string();
1196        let config = load_config_with_env_provider(&path, &env).unwrap();
1197
1198        assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1199        assert_eq!(config.servers.len(), 1);
1200        assert_eq!(config.servers[0].host.as_str(), "env.example.com");
1201        assert_eq!(config.servers[0].port.get(), 563);
1202        assert_eq!(config.servers[0].name.as_str(), "Env Server");
1203        assert!(config.servers[0].use_tls);
1204    }
1205
1206    #[test]
1207    fn test_load_config_merges_legacy_proxy_routing_into_partial_routing_section() {
1208        let mut temp_file = NamedTempFile::new().unwrap();
1209
1210        let config_content = r#"
1211[[servers]]
1212host = "legacy.example.com"
1213port = 119
1214name = "Legacy Server"
1215
1216[proxy]
1217routing_mode = "per-command"
1218backend_selection = "least-loaded"
1219
1220[routing]
1221adaptive_precheck = true
1222"#;
1223        temp_file.write_all(config_content.as_bytes()).unwrap();
1224        temp_file.flush().unwrap();
1225
1226        let path = temp_file.path().to_str().unwrap().to_string();
1227        let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1228
1229        assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1230        assert_eq!(
1231            config.routing.backend_selection,
1232            BackendSelectionStrategy::LeastLoaded
1233        );
1234        assert!(config.routing.adaptive_precheck);
1235
1236        let migrated = std::fs::read_to_string(&path).unwrap();
1237        assert!(migrated.contains("[routing]"));
1238        assert!(migrated.contains("mode = \"per-command\""));
1239        assert!(migrated.contains("backend_selection = \"least-loaded\""));
1240        assert!(migrated.contains("adaptive_precheck = true"));
1241    }
1242
1243    #[test]
1244    fn test_load_config_merges_legacy_proxy_memory_into_partial_memory_section() {
1245        let mut temp_file = NamedTempFile::new().unwrap();
1246
1247        let config_content = r#"
1248[[servers]]
1249host = "legacy.example.com"
1250port = 119
1251name = "Legacy Server"
1252
1253[proxy]
1254buffer_pool_count = 64
1255capture_pool_count = 32
1256
1257[memory]
1258socket_recv_buffer_size = 8388608
1259"#;
1260        temp_file.write_all(config_content.as_bytes()).unwrap();
1261        temp_file.flush().unwrap();
1262
1263        let path = temp_file.path().to_str().unwrap().to_string();
1264        let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1265
1266        assert_eq!(config.memory.socket_recv_buffer_size, 8 * 1024 * 1024);
1267        assert_eq!(config.memory.buffer_pool_count, 64);
1268        assert_eq!(config.memory.capture_pool_count, 32);
1269
1270        let migrated = std::fs::read_to_string(&path).unwrap();
1271        assert!(migrated.contains("[memory]"));
1272        assert!(migrated.contains("socket_recv_buffer_size = 8388608"));
1273        assert!(migrated.contains("buffer_pool_count = 64"));
1274        assert!(migrated.contains("capture_pool_count = 32"));
1275    }
1276
1277    #[test]
1278    fn test_load_config_migrates_legacy_client_auth_single_user() {
1279        let mut temp_file = NamedTempFile::new().unwrap();
1280
1281        let config_content = r#"
1282[[servers]]
1283host = "legacy.example.com"
1284port = 119
1285name = "Legacy Server"
1286
1287[client_auth]
1288greeting = "201 auth required"
1289username = "legacy-user"
1290password = "legacy-pass"
1291"#;
1292        temp_file.write_all(config_content.as_bytes()).unwrap();
1293        temp_file.flush().unwrap();
1294
1295        let path = temp_file.path().to_str().unwrap().to_string();
1296        let config = load_config_with_env_provider(&path, &MockEnv::new()).unwrap();
1297
1298        assert_eq!(
1299            config.client_auth.greeting.as_deref(),
1300            Some("201 auth required")
1301        );
1302        assert_eq!(config.client_auth.users.len(), 1);
1303        assert_eq!(config.client_auth.users[0].username, "legacy-user");
1304        assert_eq!(config.client_auth.users[0].password, "legacy-pass");
1305
1306        let migrated = std::fs::read_to_string(&path).unwrap();
1307        assert!(migrated.contains("[client_auth]"));
1308        assert!(migrated.contains("greeting = \"201 auth required\""));
1309        assert!(migrated.contains("[[client_auth.users]]"));
1310        assert!(migrated.contains("username = \"legacy-user\""));
1311        assert!(migrated.contains("password = \"legacy-pass\""));
1312    }
1313
1314    #[test]
1315    fn test_load_config_uses_migrated_config_when_writeback_fails() {
1316        let temp_dir = tempfile::tempdir().unwrap();
1317        let path = temp_dir.path().join("config.toml");
1318
1319        let config_content = r#"
1320[[servers]]
1321host = "legacy.example.com"
1322port = 119
1323name = "Legacy Server"
1324
1325[proxy]
1326routing_mode = "per-command"
1327backend_selection = "least-loaded"
1328"#;
1329        std::fs::write(&path, config_content).unwrap();
1330
1331        let backup_path = path_with_suffix(&path, ".bak");
1332        std::fs::create_dir(&backup_path).unwrap();
1333
1334        let result = load_config_with_env_provider(path.to_str().unwrap(), &MockEnv::new());
1335
1336        let config = result.unwrap();
1337        assert_eq!(config.routing.routing_mode, RoutingMode::PerCommand);
1338        assert_eq!(
1339            config.routing.backend_selection,
1340            BackendSelectionStrategy::LeastLoaded
1341        );
1342        assert_eq!(config.servers.len(), 1);
1343        assert_eq!(config.servers[0].host.as_str(), "legacy.example.com");
1344        assert!(backup_path.is_dir());
1345    }
1346}