Skip to main content

nntp_proxy/config/
types.rs

1//! Configuration type definitions
2//!
3//! This module contains all the core configuration structures used by the proxy.
4
5use super::defaults;
6use crate::types::{
7    CacheCapacity, HostName, MaxConnections, MaxErrors, Port, ServerName, ThreadCount,
8    duration_serde, option_duration_serde,
9};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12
13/// Routing mode for the proxy
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "kebab-case")]
16#[value(rename_all = "kebab-case")]
17pub enum RoutingMode {
18    /// Stateful 1:1 mode - each client gets a dedicated backend connection
19    Stateful,
20    /// Per-command routing - each command can use a different backend (stateless only)
21    #[serde(alias = "percommand")]
22    PerCommand,
23    /// Hybrid mode - starts in per-command routing, auto-switches to stateful on first stateful command
24    Hybrid,
25}
26
27impl Default for RoutingMode {
28    /// Default routing mode is Hybrid, which provides optimal performance and full protocol support.
29    /// This mode automatically starts in per-command routing for efficiency and seamlessly switches
30    /// to stateful mode when commands requiring group context are detected.
31    fn default() -> Self {
32        Self::Hybrid
33    }
34}
35
36impl RoutingMode {
37    /// Check if this mode supports per-command routing
38    #[must_use]
39    pub const fn supports_per_command_routing(&self) -> bool {
40        matches!(self, Self::PerCommand | Self::Hybrid)
41    }
42
43    /// Check if this mode can handle stateful commands
44    #[must_use]
45    pub const fn supports_stateful_commands(&self) -> bool {
46        matches!(self, Self::Stateful | Self::Hybrid)
47    }
48
49    /// Get short lowercase name for metrics/logging (no allocation)
50    #[must_use]
51    pub const fn short_name(&self) -> &'static str {
52        match self {
53            Self::Stateful => "stateful",
54            Self::PerCommand => "per-command",
55            Self::Hybrid => "hybrid",
56        }
57    }
58
59    /// Get a human-readable description of this routing mode
60    #[must_use]
61    pub const fn as_str(&self) -> &'static str {
62        match self {
63            Self::Stateful => "stateful 1:1 mode",
64            Self::PerCommand => "per-command routing mode (stateless)",
65            Self::Hybrid => "hybrid routing mode",
66        }
67    }
68}
69
70impl std::fmt::Display for RoutingMode {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(self.as_str())
73    }
74}
75
76/// Backend selection strategy for load balancing
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
78#[serde(rename_all = "kebab-case")]
79pub enum BackendSelectionStrategy {
80    /// Weighted round-robin - distributes requests proportionally to `max_connections`
81    #[serde(alias = "round-robin")]
82    WeightedRoundRobin,
83    /// Least-loaded - routes to backend with fewest pending requests
84    #[serde(alias = "adaptive-weighted")]
85    LeastLoaded,
86}
87
88impl Default for BackendSelectionStrategy {
89    /// Default is least-loaded for optimal dynamic load distribution
90    fn default() -> Self {
91        Self::LeastLoaded
92    }
93}
94
95impl BackendSelectionStrategy {
96    /// Get a human-readable description
97    #[must_use]
98    pub const fn as_str(&self) -> &'static str {
99        match self {
100            Self::WeightedRoundRobin => "weighted round-robin",
101            Self::LeastLoaded => "least-loaded",
102        }
103    }
104}
105
106impl std::fmt::Display for BackendSelectionStrategy {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.write_str(self.as_str())
109    }
110}
111
112/// Main proxy configuration
113#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
114pub struct Config {
115    /// Proxy server settings
116    #[serde(default)]
117    pub proxy: Proxy,
118    /// Routing configuration
119    #[serde(default)]
120    pub routing: Routing,
121    /// Memory configuration
122    #[serde(default)]
123    pub memory: Memory,
124    /// Cache configuration.
125    ///
126    /// The proxy uses the cache for backend availability-driven routing/retry
127    /// decisions when the configured capacity can hold the fixed availability
128    /// index. In availability-only mode, `store_article_bodies` only controls
129    /// whether the cache also retains full article bodies.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub cache: Option<Cache>,
132    /// Health check configuration
133    #[serde(default)]
134    pub health_check: HealthCheck,
135    /// Client authentication configuration
136    #[serde(default)]
137    pub client_auth: ClientAuth,
138    /// List of backend NNTP servers
139    #[serde(default)]
140    pub servers: Vec<Server>,
141}
142
143/// Proxy server settings
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(default)]
146pub struct Proxy {
147    /// Host/IP to bind to (default: 0.0.0.0)
148    pub host: String,
149    /// Port to listen on (default: 8119)
150    pub port: Port,
151    /// Number of worker threads (default: 1, use 0 for CPU cores)
152    pub threads: ThreadCount,
153    /// Routing mode for the proxy
154    #[serde(skip_serializing)]
155    pub routing_mode: RoutingMode,
156    /// Backend selection strategy for load balancing
157    #[serde(skip_serializing)]
158    pub backend_selection: BackendSelectionStrategy,
159    /// Validate yEnc structure and checksums (default: true)
160    pub validate_yenc: bool,
161    /// Filter directives for the optional local-TUI `debug.log` appender (default: "warn")
162    /// Accepts tracing filter directives: "error", "warn", "info", "debug", "trace"
163    #[serde(default = "super::defaults::log_file_level")]
164    pub log_file_level: String,
165    /// Path to stats file for metric persistence (optional)
166    /// When set, metrics are persisted to this file every 30 seconds and on shutdown
167    /// Defaults to "stats.json" alongside the config file if not specified
168    #[serde(default)]
169    pub stats_file: Option<std::path::PathBuf>,
170    /// Legacy buffer pool count retained for config migration compatibility.
171    #[serde(default, skip_serializing)]
172    pub buffer_pool_count: usize,
173    /// Legacy capture pool count retained for config migration compatibility.
174    #[serde(default, skip_serializing)]
175    pub capture_pool_count: usize,
176}
177
178impl Proxy {
179    /// Default listen host (all interfaces)
180    pub const DEFAULT_HOST: &'static str = "0.0.0.0";
181}
182
183impl Default for Proxy {
184    fn default() -> Self {
185        Self {
186            host: Self::DEFAULT_HOST.to_string(),
187            port: Port::default(),
188            threads: ThreadCount::default(),
189            validate_yenc: true,
190            log_file_level: defaults::log_file_level(),
191            stats_file: None,
192            routing_mode: RoutingMode::default(),
193            backend_selection: BackendSelectionStrategy::default(),
194            buffer_pool_count: defaults::buffer_pool_count(),
195            capture_pool_count: defaults::capture_pool_count(),
196        }
197    }
198}
199
200/// Routing configuration
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(default)]
203pub struct Routing {
204    /// Routing mode for the proxy
205    #[serde(rename = "mode", alias = "routing_mode")]
206    pub routing_mode: RoutingMode,
207    /// Backend selection strategy for load balancing
208    #[serde(alias = "strategy")]
209    pub backend_selection: BackendSelectionStrategy,
210    /// Enable adaptive availability prechecking for STAT/HEAD commands (default: false)
211    #[serde(default = "super::defaults::adaptive_precheck")]
212    pub adaptive_precheck: bool,
213}
214
215impl Default for Routing {
216    fn default() -> Self {
217        Self {
218            routing_mode: RoutingMode::default(),
219            backend_selection: BackendSelectionStrategy::default(),
220            adaptive_precheck: defaults::adaptive_precheck(),
221        }
222    }
223}
224
225/// Memory configuration for transport and buffer pools
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227#[serde(default)]
228pub struct Memory {
229    /// TCP socket receive buffer size for backend and client connections
230    #[serde(default = "super::defaults::socket_recv_buffer_size")]
231    pub socket_recv_buffer_size: usize,
232    /// TCP socket send buffer size for backend and client connections
233    #[serde(default = "super::defaults::socket_send_buffer_size")]
234    pub socket_send_buffer_size: usize,
235    /// Size of each pooled I/O buffer used for streaming
236    #[serde(default = "super::defaults::buffer_pool_size")]
237    pub buffer_pool_size: usize,
238    /// Number of buffers in the main buffer pool
239    #[serde(default = "super::defaults::buffer_pool_count")]
240    pub buffer_pool_count: usize,
241    /// Size of each capture buffer for caching and response assembly
242    #[serde(default = "super::defaults::capture_pool_size")]
243    pub capture_pool_size: usize,
244    /// Number of buffers in the capture pool
245    #[serde(default = "super::defaults::capture_pool_count")]
246    pub capture_pool_count: usize,
247}
248
249impl Default for Memory {
250    fn default() -> Self {
251        Self {
252            socket_recv_buffer_size: defaults::socket_recv_buffer_size(),
253            socket_send_buffer_size: defaults::socket_send_buffer_size(),
254            buffer_pool_size: defaults::buffer_pool_size(),
255            buffer_pool_count: defaults::buffer_pool_count(),
256            capture_pool_size: defaults::capture_pool_size(),
257            capture_pool_count: defaults::capture_pool_count(),
258        }
259    }
260}
261
262/// Article cache configuration
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264#[serde(default)]
265pub struct Cache {
266    /// Maximum article-cache size in bytes (memory tier for hybrid cache)
267    ///
268    /// Supports human-readable formats:
269    /// - \"1gb\" = 1 GB
270    /// - \"500mb\" = 500 MB
271    /// - \"64mb\" = 64 MB (default)
272    /// - 10000 = 10,000 bytes
273    #[serde(
274        default = "super::defaults::cache_max_capacity",
275        rename = "article_cache_capacity",
276        alias = "cache_capacity",
277        alias = "max_capacity"
278    )]
279    pub article_cache_capacity: CacheCapacity,
280    /// Time-to-live for the article cache
281    #[serde(
282        with = "duration_serde",
283        default = "super::defaults::cache_ttl",
284        rename = "article_cache_ttl_secs",
285        alias = "cache_ttl",
286        alias = "ttl_secs",
287        alias = "ttl"
288    )]
289    pub article_cache_ttl_secs: Duration,
290    /// Whether to store full article bodies in the article cache (default: false)
291    ///
292    /// When false:
293    /// - Cache still tracks backend availability (smart routing, 430 retry)
294    /// - Article bodies are NOT stored (saves ~750KB per article)
295    /// - Uses the dedicated availability-only index with bounded LRU eviction
296    /// - Useful for availability-only mode with limited memory
297    ///
298    /// When true:
299    /// - Full caching mode (bodies + availability tracking)
300    /// - Can serve articles from cache without backend query
301    #[serde(
302        default = "super::defaults::cache_articles",
303        rename = "store_article_bodies",
304        alias = "store_articles",
305        alias = "cache_articles"
306    )]
307    pub store_article_bodies: bool,
308
309    /// Disk cache configuration (requires `hybrid-cache` feature)
310    ///
311    /// When enabled, articles evicted from memory are written to disk,
312    /// creating a two-tier cache (memory → disk → backend).
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub disk: Option<DiskCache>,
315
316    /// Path to the availability index persistence file (optional).
317    ///
318    /// This is only used in availability-only mode (`store_article_bodies = false`).
319    /// When set, the proxy uses this path to persist backend availability state;
320    /// otherwise it defaults to "availability.idx" alongside the config file.
321    #[serde(
322        default,
323        skip_serializing_if = "Option::is_none",
324        rename = "availability_index_path",
325        alias = "availability_path",
326        alias = "availability_file"
327    )]
328    pub availability_index_path: Option<std::path::PathBuf>,
329    /// Legacy adaptive precheck retained for config migration compatibility.
330    #[serde(default, skip_serializing)]
331    pub adaptive_precheck: bool,
332}
333
334/// Compression codec for disk cache storage
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, clap::ValueEnum)]
336#[serde(rename_all = "lowercase")]
337pub enum CompressionCodec {
338    /// No compression (fastest, largest disk usage)
339    None,
340    /// LZ4 compression (fast, ~60% reduction for typical NNTP articles, default)
341    ///
342    /// Uses SIMD (SSE2/AVX2) auto-detection for maximum throughput.
343    /// Compression level: fast mode (default).
344    #[default]
345    Lz4,
346    /// Zstandard compression (better ratio, moderate CPU overhead)
347    ///
348    /// Uses SIMD (SSE2/AVX2/AVX512) auto-detection.
349    /// Compression level: 3 (library default, balanced speed/ratio).
350    Zstd,
351}
352
353impl std::fmt::Display for CompressionCodec {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        match self {
356            Self::None => write!(f, "none"),
357            Self::Lz4 => write!(f, "lz4"),
358            Self::Zstd => write!(f, "zstd"),
359        }
360    }
361}
362
363/// Disk cache configuration for hybrid caching
364///
365/// When enabled, creates a two-tier cache:
366/// - Hot articles in memory (fast, limited capacity)
367/// - Cold articles on disk (slower, larger capacity)
368///
369/// Requires the `hybrid-cache` feature to be enabled.
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371pub struct DiskCache {
372    /// Path to disk cache directory
373    ///
374    /// Directory will be created if it doesn't exist.
375    /// Recommended: Use a fast SSD or `NVMe` drive.
376    #[serde(default = "super::defaults::disk_cache_path")]
377    pub path: std::path::PathBuf,
378
379    /// Maximum disk cache size in bytes
380    ///
381    /// Supports human-readable formats:
382    /// - \"100gb\" = 100 GB
383    /// - \"10gb\" = 10 GB (default)
384    /// - \"1tb\" = 1 TB
385    #[serde(default = "super::defaults::disk_cache_capacity")]
386    pub capacity: CacheCapacity,
387
388    /// Compression codec for disk storage (default: lz4)
389    ///
390    /// Options:
391    /// - "lz4" (default): Fast compression (~60% reduction), minimal CPU overhead
392    /// - "zstd": Better compression ratio, moderate CPU overhead
393    /// - "none": No compression, fastest but largest disk usage
394    ///
395    /// For the "lz4" and "zstd" codecs, SIMD optimizations (SSE2/AVX2/AVX512) are
396    /// auto-detected and enabled by default. When `compression = "none"`, no
397    /// compression or SIMD acceleration is performed.
398    #[serde(default = "super::defaults::disk_cache_compression_codec")]
399    pub compression: CompressionCodec,
400
401    /// Number of shards for concurrent disk access (default: 4)
402    ///
403    /// Higher values improve concurrency but use more file handles.
404    #[serde(default = "super::defaults::disk_cache_shards")]
405    pub shards: usize,
406}
407
408impl Default for DiskCache {
409    fn default() -> Self {
410        Self {
411            path: defaults::disk_cache_path(),
412            capacity: defaults::disk_cache_capacity(),
413            compression: defaults::disk_cache_compression_codec(),
414            shards: defaults::disk_cache_shards(),
415        }
416    }
417}
418
419impl Default for Cache {
420    fn default() -> Self {
421        Self {
422            article_cache_capacity: defaults::cache_max_capacity(),
423            article_cache_ttl_secs: defaults::cache_ttl(),
424            store_article_bodies: defaults::cache_articles(),
425            disk: None,
426            availability_index_path: None,
427            adaptive_precheck: defaults::adaptive_precheck(),
428        }
429    }
430}
431
432/// Health check configuration
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
434pub struct HealthCheck {
435    /// Interval between health checks
436    #[serde(
437        with = "duration_serde",
438        default = "super::defaults::health_check_interval"
439    )]
440    pub interval: Duration,
441    /// Timeout for each health check
442    #[serde(
443        with = "duration_serde",
444        default = "super::defaults::health_check_timeout"
445    )]
446    pub timeout: Duration,
447    /// Number of consecutive failures before marking unhealthy
448    #[serde(default = "super::defaults::unhealthy_threshold")]
449    pub unhealthy_threshold: MaxErrors,
450}
451
452impl Default for HealthCheck {
453    fn default() -> Self {
454        Self {
455            interval: super::defaults::health_check_interval(),
456            timeout: super::defaults::health_check_timeout(),
457            unhealthy_threshold: super::defaults::unhealthy_threshold(),
458        }
459    }
460}
461
462/// Client authentication configuration
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
464pub struct ClientAuth {
465    /// Optional custom greeting message
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub greeting: Option<String>,
468    /// List of authorized users for client authentication
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub users: Vec<UserCredentials>,
471}
472
473/// Individual user credentials
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475pub struct UserCredentials {
476    pub username: String,
477    pub password: String,
478}
479
480impl ClientAuth {
481    /// Check if authentication is enabled
482    #[must_use]
483    pub const fn is_enabled(&self) -> bool {
484        !self.users.is_empty()
485    }
486
487    /// Get all users
488    #[must_use]
489    pub fn all_users(&self) -> Vec<(&str, &str)> {
490        self.users
491            .iter()
492            .map(|user| (user.username.as_str(), user.password.as_str()))
493            .collect()
494    }
495}
496
497/// Configuration for a single backend server
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499pub struct Server {
500    pub host: HostName,
501    pub port: Port,
502    pub name: ServerName,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub username: Option<String>,
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub password: Option<String>,
507    /// Maximum number of concurrent connections to this server
508    #[serde(default = "super::defaults::max_connections")]
509    pub max_connections: MaxConnections,
510
511    /// Enable TLS/SSL for this backend connection
512    #[serde(default)]
513    pub use_tls: bool,
514    /// Verify TLS certificates (recommended for production)
515    #[serde(default = "super::defaults::tls_verify_cert")]
516    pub tls_verify_cert: bool,
517    /// Optional path to custom CA certificate
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub tls_cert_path: Option<String>,
520    /// Interval to send keep-alive commands (DATE) on idle connections
521    /// None disables keep-alive (default)
522    #[serde(
523        with = "option_duration_serde",
524        default,
525        skip_serializing_if = "Option::is_none"
526    )]
527    pub connection_keepalive: Option<Duration>,
528    /// How long to wait before replacing an actively-removed connection.
529    /// This can damp backend connection churn after repeated failures, but it
530    /// also temporarily reduces pool capacity on backend-error removals.
531    /// Default: 30 seconds. Set to 0 to disable.
532    #[serde(
533        with = "option_duration_serde",
534        default = "super::defaults::replacement_cooldown_option",
535        skip_serializing_if = "Option::is_none"
536    )]
537    pub replacement_cooldown: Option<Duration>,
538    /// Maximum number of connections to check per health check cycle
539    /// Lower values reduce pool contention but may take longer to detect all stale connections
540    #[serde(default = "super::defaults::health_check_max_per_cycle")]
541    pub health_check_max_per_cycle: usize,
542    /// Timeout when acquiring a connection for health checking
543    /// Short timeout prevents blocking if pool is busy
544    #[serde(
545        with = "duration_serde",
546        default = "super::defaults::health_check_pool_timeout"
547    )]
548    pub health_check_pool_timeout: Duration,
549    /// Server tier for prioritization (lower = higher priority, default: 0)
550    /// Servers with lower tier numbers are tried first; higher tiers only when lower exhausted
551    #[serde(default)]
552    pub tier: u8,
553    /// Wire compression for backend connections (RFC 8054 COMPRESS DEFLATE)
554    /// None (default) = auto-detect, Some(true) = require, Some(false) = disable
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub compress: Option<bool>,
557    /// Compression level (0-9). None = fast (level 1). Higher = better ratio, more CPU.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub compress_level: Option<u32>,
560
561    /// Duration of proxy-wide inactivity after which this backend's idle connections are cleared.
562    /// Prevents stale connections from accumulating during overnight idle periods.
563    /// Default: 600 seconds (10 minutes). Set to 0 to disable.
564    #[serde(
565        with = "duration_serde",
566        default = "super::defaults::backend_idle_timeout"
567    )]
568    pub backend_idle_timeout: Duration,
569}
570
571/// Builder for constructing `Server` instances
572///
573/// Provides a fluent API for creating server configurations, especially useful in tests
574/// where creating Server with all 11+ fields is verbose.
575///
576/// # Examples
577///
578/// ```
579/// use nntp_proxy::config::Server;
580/// use nntp_proxy::types::{Port, MaxConnections};
581///
582/// // Minimal configuration
583/// let config = Server::builder("news.example.com", Port::try_new(119).unwrap())
584///     .build()
585///     .unwrap();
586///
587/// // With authentication and TLS
588/// let config = Server::builder("secure.example.com", Port::try_new(563).unwrap())
589///     .name("Secure Server")
590///     .username("user")
591///     .password("pass")
592///     .max_connections(MaxConnections::try_new(20).unwrap())
593///     .use_tls(true)
594///     .build()
595///     .unwrap();
596/// ```
597pub struct ServerBuilder {
598    host: String,
599    port: Port,
600    name: Option<String>,
601    username: Option<String>,
602    password: Option<String>,
603    max_connections: Option<MaxConnections>,
604    use_tls: bool,
605    tls_verify_cert: bool,
606    tls_cert_path: Option<String>,
607    connection_keepalive: Option<Duration>,
608    replacement_cooldown: Option<Duration>,
609    health_check_max_per_cycle: Option<usize>,
610    health_check_pool_timeout: Option<Duration>,
611    tier: u8,
612    compress: Option<bool>,
613    compress_level: Option<u32>,
614    backend_idle_timeout: Option<Duration>,
615}
616
617impl ServerBuilder {
618    /// Create a new builder with required parameters
619    ///
620    /// # Arguments
621    /// * `host` - Backend server hostname or IP address
622    /// * `port` - Backend server port
623    #[must_use]
624    pub fn new(host: impl Into<String>, port: Port) -> Self {
625        Self {
626            host: host.into(),
627            port,
628            name: None,
629            username: None,
630            password: None,
631            max_connections: None,
632            use_tls: false,
633            tls_verify_cert: true, // Secure by default
634            tls_cert_path: None,
635            connection_keepalive: None,
636            replacement_cooldown: None,
637            health_check_max_per_cycle: None,
638            health_check_pool_timeout: None,
639            tier: 0,
640            compress: None,
641            compress_level: None,
642            backend_idle_timeout: None,
643        }
644    }
645
646    /// Set a friendly name for logging (defaults to "host:port")
647    #[must_use]
648    pub fn name(mut self, name: impl Into<String>) -> Self {
649        self.name = Some(name.into());
650        self
651    }
652
653    /// Set authentication username
654    #[must_use]
655    pub fn username(mut self, username: impl Into<String>) -> Self {
656        self.username = Some(username.into());
657        self
658    }
659
660    /// Set authentication password
661    #[must_use]
662    pub fn password(mut self, password: impl Into<String>) -> Self {
663        self.password = Some(password.into());
664        self
665    }
666
667    /// Set maximum number of concurrent connections
668    #[must_use]
669    pub const fn max_connections(mut self, max: MaxConnections) -> Self {
670        self.max_connections = Some(max);
671        self
672    }
673
674    /// Enable TLS/SSL for this backend connection
675    #[must_use]
676    pub const fn use_tls(mut self, enabled: bool) -> Self {
677        self.use_tls = enabled;
678        self
679    }
680
681    /// Set whether to verify TLS certificates
682    #[must_use]
683    pub const fn tls_verify_cert(mut self, verify: bool) -> Self {
684        self.tls_verify_cert = verify;
685        self
686    }
687
688    /// Set path to custom CA certificate
689    #[must_use]
690    pub fn tls_cert_path(mut self, path: impl Into<String>) -> Self {
691        self.tls_cert_path = Some(path.into());
692        self
693    }
694
695    /// Set keep-alive interval for idle connections
696    #[must_use]
697    pub const fn connection_keepalive(mut self, interval: Duration) -> Self {
698        self.connection_keepalive = Some(interval);
699        self
700    }
701
702    /// Set connection replacement cooldown duration
703    #[must_use]
704    pub const fn replacement_cooldown(mut self, cooldown: Duration) -> Self {
705        self.replacement_cooldown = Some(cooldown);
706        self
707    }
708
709    /// Set maximum connections to check per health check cycle
710    #[must_use]
711    pub const fn health_check_max_per_cycle(mut self, max: usize) -> Self {
712        self.health_check_max_per_cycle = Some(max);
713        self
714    }
715
716    /// Set timeout for acquiring connections during health checks
717    #[must_use]
718    pub const fn health_check_pool_timeout(mut self, timeout: Duration) -> Self {
719        self.health_check_pool_timeout = Some(timeout);
720        self
721    }
722
723    /// Set server tier for prioritization (lower = higher priority)
724    #[must_use]
725    pub const fn tier(mut self, tier: u8) -> Self {
726        self.tier = tier;
727        self
728    }
729
730    /// Set wire compression mode (RFC 8054 COMPRESS DEFLATE)
731    #[must_use]
732    pub const fn compress(mut self, compress: Option<bool>) -> Self {
733        self.compress = compress;
734        self
735    }
736
737    /// Set compression level (0-9, default: 1 = fast)
738    ///
739    /// # Panics
740    ///
741    /// Panics if `level` is greater than 9.
742    #[must_use]
743    pub fn compress_level(mut self, level: u32) -> Self {
744        assert!(level <= 9, "compress_level must be 0-9, got {level}");
745        self.compress_level = Some(level);
746        self
747    }
748
749    /// Set the backend idle timeout duration
750    ///
751    /// Connections to this backend are cleared after this duration of proxy-wide inactivity.
752    /// Default: 10 minutes.
753    #[must_use]
754    pub const fn backend_idle_timeout(mut self, timeout: Duration) -> Self {
755        self.backend_idle_timeout = Some(timeout);
756        self
757    }
758
759    /// Build the Server
760    ///
761    /// # Errors
762    ///
763    /// Returns an error if:
764    /// - Host is empty or invalid
765    /// - Port is 0
766    /// - Name is empty (when explicitly set)
767    /// - Max connections is 0 (when explicitly set)
768    pub fn build(self) -> Result<Server, anyhow::Error> {
769        use crate::types::{HostName, ServerName};
770
771        let host = HostName::try_new(self.host.clone())?;
772        let port = self.port; // Already a Port type
773        let name_str = self
774            .name
775            .unwrap_or_else(|| format!("{}:{}", self.host, self.port.get()));
776        let name = ServerName::try_new(name_str)?;
777
778        let max_connections = self
779            .max_connections
780            .unwrap_or_else(super::defaults::max_connections);
781
782        let health_check_max_per_cycle = self
783            .health_check_max_per_cycle
784            .unwrap_or_else(super::defaults::health_check_max_per_cycle);
785
786        let health_check_pool_timeout = self
787            .health_check_pool_timeout
788            .unwrap_or_else(super::defaults::health_check_pool_timeout);
789
790        Ok(Server {
791            host,
792            port,
793            name,
794            username: self.username,
795            password: self.password,
796            max_connections,
797            use_tls: self.use_tls,
798            tls_verify_cert: self.tls_verify_cert,
799            tls_cert_path: self.tls_cert_path,
800            connection_keepalive: self.connection_keepalive,
801            replacement_cooldown: self
802                .replacement_cooldown
803                .or_else(super::defaults::replacement_cooldown_option),
804            health_check_max_per_cycle,
805            health_check_pool_timeout,
806            tier: self.tier,
807            compress: self.compress,
808            compress_level: self.compress_level,
809            backend_idle_timeout: self
810                .backend_idle_timeout
811                .unwrap_or_else(super::defaults::backend_idle_timeout),
812        })
813    }
814}
815
816impl Server {
817    /// Create a builder for configuring a backend server
818    ///
819    /// # Example
820    ///
821    /// ```
822    /// use nntp_proxy::config::Server;
823    /// use nntp_proxy::types::{Port, MaxConnections};
824    ///
825    /// let config = Server::builder("news.example.com", Port::try_new(119).unwrap())
826    ///     .name("Example Server")
827    ///     .max_connections(MaxConnections::try_new(15).unwrap())
828    ///     .build()
829    ///     .unwrap();
830    /// ```
831    #[must_use]
832    pub fn builder(host: impl Into<String>, port: Port) -> ServerBuilder {
833        ServerBuilder::new(host, port)
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    // RoutingMode tests
842    #[test]
843    fn test_routing_mode_default() {
844        assert_eq!(RoutingMode::default(), RoutingMode::Hybrid);
845    }
846
847    #[test]
848    fn test_routing_mode_supports_per_command() {
849        assert!(RoutingMode::PerCommand.supports_per_command_routing());
850        assert!(RoutingMode::Hybrid.supports_per_command_routing());
851        assert!(!RoutingMode::Stateful.supports_per_command_routing());
852    }
853
854    #[test]
855    fn test_routing_mode_supports_stateful() {
856        assert!(RoutingMode::Stateful.supports_stateful_commands());
857        assert!(RoutingMode::Hybrid.supports_stateful_commands());
858        assert!(!RoutingMode::PerCommand.supports_stateful_commands());
859    }
860
861    #[test]
862    fn test_routing_mode_as_str() {
863        assert_eq!(RoutingMode::Stateful.as_str(), "stateful 1:1 mode");
864        assert_eq!(
865            RoutingMode::PerCommand.as_str(),
866            "per-command routing mode (stateless)"
867        );
868        assert_eq!(RoutingMode::Hybrid.as_str(), "hybrid routing mode");
869    }
870
871    #[test]
872    fn test_routing_mode_display() {
873        assert_eq!(RoutingMode::Stateful.to_string(), "stateful 1:1 mode");
874        assert_eq!(RoutingMode::Hybrid.to_string(), "hybrid routing mode");
875    }
876
877    // Proxy tests
878    #[test]
879    fn test_proxy_default() {
880        let proxy = Proxy::default();
881        assert_eq!(proxy.host, "0.0.0.0");
882        assert_eq!(proxy.port.get(), 8119);
883    }
884
885    #[test]
886    fn test_proxy_default_host_constant() {
887        assert_eq!(Proxy::DEFAULT_HOST, "0.0.0.0");
888    }
889
890    // Cache tests
891    #[test]
892    fn test_cache_default() {
893        let cache = Cache::default();
894        assert_eq!(cache.article_cache_capacity.get(), 64 * 1024 * 1024); // 64 MB
895        assert_eq!(
896            cache.article_cache_ttl_secs,
897            crate::constants::duration_polyfill::from_hours(1)
898        );
899        assert!(!cache.store_article_bodies);
900    }
901
902    #[test]
903    fn test_memory_default() {
904        let memory = Memory::default();
905        assert_eq!(
906            memory.socket_recv_buffer_size,
907            crate::constants::socket::HIGH_THROUGHPUT_RECV_BUFFER
908        );
909        assert_eq!(
910            memory.socket_send_buffer_size,
911            crate::constants::socket::HIGH_THROUGHPUT_SEND_BUFFER
912        );
913        assert_eq!(memory.buffer_pool_size, crate::constants::buffer::POOL);
914        assert_eq!(
915            memory.buffer_pool_count,
916            crate::constants::buffer::POOL_COUNT
917        );
918        assert_eq!(memory.capture_pool_size, crate::constants::buffer::CAPTURE);
919        assert_eq!(
920            memory.capture_pool_count,
921            crate::constants::buffer::CAPTURE_COUNT
922        );
923    }
924
925    // HealthCheck tests
926    #[test]
927    fn test_health_check_default() {
928        let hc = HealthCheck::default();
929        assert_eq!(hc.interval, Duration::from_secs(30));
930        assert_eq!(hc.timeout, Duration::from_secs(5));
931        assert_eq!(hc.unhealthy_threshold.get(), 3);
932    }
933
934    // ClientAuth tests
935    #[test]
936    fn test_client_auth_is_enabled() {
937        let mut auth = ClientAuth::default();
938        assert!(!auth.is_enabled());
939
940        auth.users.push(UserCredentials {
941            username: "user".to_string(),
942            password: "pass".to_string(),
943        });
944        assert!(auth.is_enabled());
945    }
946
947    #[test]
948    fn test_client_auth_is_enabled_multi_user() {
949        let mut auth = ClientAuth::default();
950        auth.users.push(UserCredentials {
951            username: "alice".to_string(),
952            password: "secret".to_string(),
953        });
954        assert!(auth.is_enabled());
955    }
956
957    #[test]
958    fn test_client_auth_all_users_single() {
959        let mut auth = ClientAuth::default();
960        auth.users.push(UserCredentials {
961            username: "user".to_string(),
962            password: "pass".to_string(),
963        });
964
965        let users = auth.all_users();
966        assert_eq!(users.len(), 1);
967        assert_eq!(users[0], ("user", "pass"));
968    }
969
970    #[test]
971    fn test_client_auth_all_users_multi() {
972        let mut auth = ClientAuth::default();
973        auth.users.push(UserCredentials {
974            username: "alice".to_string(),
975            password: "alice_pw".to_string(),
976        });
977        auth.users.push(UserCredentials {
978            username: "bob".to_string(),
979            password: "bob_pw".to_string(),
980        });
981
982        let users = auth.all_users();
983        assert_eq!(users.len(), 2);
984        assert_eq!(users[0], ("alice", "alice_pw"));
985        assert_eq!(users[1], ("bob", "bob_pw"));
986    }
987
988    // ServerBuilder tests
989    #[test]
990    fn test_server_builder_minimal() {
991        let server = Server::builder("news.example.com", Port::try_new(119).unwrap())
992            .build()
993            .unwrap();
994
995        assert_eq!(server.host.as_str(), "news.example.com");
996        assert_eq!(server.port.get(), 119);
997        assert_eq!(server.name.as_str(), "news.example.com:119");
998        assert_eq!(server.max_connections.get(), 10);
999        assert!(!server.use_tls);
1000        assert!(server.tls_verify_cert); // Secure by default
1001    }
1002
1003    #[test]
1004    fn test_server_builder_with_name() {
1005        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1006            .name("Test Server")
1007            .build()
1008            .unwrap();
1009
1010        assert_eq!(server.name.as_str(), "Test Server");
1011    }
1012
1013    #[test]
1014    fn test_server_builder_with_auth() {
1015        let server = Server::builder("news.example.com", Port::try_new(119).unwrap())
1016            .username("testuser")
1017            .password("testpass")
1018            .build()
1019            .unwrap();
1020
1021        assert_eq!(server.username.as_ref().unwrap(), "testuser");
1022        assert_eq!(server.password.as_ref().unwrap(), "testpass");
1023    }
1024
1025    #[test]
1026    fn test_server_builder_with_max_connections() {
1027        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1028            .max_connections(MaxConnections::try_new(20).unwrap())
1029            .build()
1030            .unwrap();
1031
1032        assert_eq!(server.max_connections.get(), 20);
1033    }
1034
1035    #[test]
1036    fn test_server_builder_with_tls() {
1037        let server = Server::builder("secure.example.com", Port::try_new(563).unwrap())
1038            .use_tls(true)
1039            .tls_verify_cert(false)
1040            .tls_cert_path("/path/to/cert.pem")
1041            .build()
1042            .unwrap();
1043
1044        assert!(server.use_tls);
1045        assert!(!server.tls_verify_cert);
1046        assert_eq!(server.tls_cert_path.as_ref().unwrap(), "/path/to/cert.pem");
1047    }
1048
1049    #[test]
1050    fn test_server_builder_with_keepalive() {
1051        let keepalive = crate::constants::duration_polyfill::from_minutes(5);
1052        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1053            .connection_keepalive(keepalive)
1054            .build()
1055            .unwrap();
1056
1057        assert_eq!(server.connection_keepalive, Some(keepalive));
1058    }
1059
1060    #[test]
1061    fn test_server_builder_default_replacement_cooldown() {
1062        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1063            .build()
1064            .unwrap();
1065
1066        assert_eq!(
1067            server.replacement_cooldown,
1068            super::defaults::replacement_cooldown_option()
1069        );
1070    }
1071
1072    #[test]
1073    fn test_server_builder_with_replacement_cooldown() {
1074        let cooldown = Duration::from_secs(31);
1075        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1076            .replacement_cooldown(cooldown)
1077            .build()
1078            .unwrap();
1079
1080        assert_eq!(server.replacement_cooldown, Some(cooldown));
1081    }
1082
1083    #[test]
1084    fn test_server_builder_with_health_check_settings() {
1085        let timeout = Duration::from_millis(500);
1086        let server = Server::builder("localhost", Port::try_new(119).unwrap())
1087            .health_check_max_per_cycle(5)
1088            .health_check_pool_timeout(timeout)
1089            .build()
1090            .unwrap();
1091
1092        assert_eq!(server.health_check_max_per_cycle, 5);
1093        assert_eq!(server.health_check_pool_timeout, timeout);
1094    }
1095
1096    #[test]
1097    fn test_server_builder_chaining() {
1098        let server = Server::builder("news.example.com", Port::try_new(563).unwrap())
1099            .name("Production Server")
1100            .username("admin")
1101            .password("secret")
1102            .max_connections(MaxConnections::try_new(25).unwrap())
1103            .use_tls(true)
1104            .tls_verify_cert(true)
1105            .build()
1106            .unwrap();
1107
1108        assert_eq!(server.name.as_str(), "Production Server");
1109        assert_eq!(server.max_connections.get(), 25);
1110        assert!(server.use_tls);
1111    }
1112
1113    // Config tests
1114    #[test]
1115    fn test_config_default() {
1116        let config = Config::default();
1117        assert!(config.servers.is_empty());
1118        assert_eq!(config.proxy.host, "0.0.0.0");
1119        assert!(config.cache.is_none());
1120        assert!(!config.client_auth.is_enabled());
1121    }
1122
1123    // CompressionCodec tests
1124    #[test]
1125    fn test_compression_codec_serde_lz4() {
1126        let json = r#""lz4""#;
1127        let codec: CompressionCodec = serde_json::from_str(json).unwrap();
1128        assert_eq!(codec, CompressionCodec::Lz4);
1129        assert_eq!(serde_json::to_string(&codec).unwrap(), json);
1130    }
1131
1132    #[test]
1133    fn test_compression_codec_serde_zstd() {
1134        let json = r#""zstd""#;
1135        let codec: CompressionCodec = serde_json::from_str(json).unwrap();
1136        assert_eq!(codec, CompressionCodec::Zstd);
1137    }
1138
1139    #[test]
1140    fn test_compression_codec_serde_none() {
1141        let json = r#""none""#;
1142        let codec: CompressionCodec = serde_json::from_str(json).unwrap();
1143        assert_eq!(codec, CompressionCodec::None);
1144    }
1145
1146    #[test]
1147    fn test_compression_codec_default_is_lz4() {
1148        assert_eq!(CompressionCodec::default(), CompressionCodec::Lz4);
1149    }
1150
1151    #[test]
1152    fn test_compression_codec_display() {
1153        assert_eq!(CompressionCodec::Lz4.to_string(), "lz4");
1154        assert_eq!(CompressionCodec::Zstd.to_string(), "zstd");
1155        assert_eq!(CompressionCodec::None.to_string(), "none");
1156    }
1157
1158    // DiskCache tests with compression codec
1159    #[test]
1160    fn test_disk_cache_default_compression_is_lz4() {
1161        let disk_cache = DiskCache::default();
1162        assert_eq!(disk_cache.compression, CompressionCodec::Lz4);
1163    }
1164
1165    #[test]
1166    fn test_disk_cache_deserialize_compression_codec() {
1167        let toml = r#"
1168            path = "/tmp/cache"
1169            capacity = "100mb"
1170            compression = "zstd"
1171            shards = 4
1172        "#;
1173        let disk_cache: DiskCache = toml::from_str(toml).unwrap();
1174        assert_eq!(disk_cache.compression, CompressionCodec::Zstd);
1175    }
1176
1177    #[test]
1178    fn test_disk_cache_deserialize_compression_none() {
1179        let toml = r#"
1180            path = "/tmp/cache"
1181            capacity = "100mb"
1182            compression = "none"
1183            shards = 4
1184        "#;
1185        let disk_cache: DiskCache = toml::from_str(toml).unwrap();
1186        assert_eq!(disk_cache.compression, CompressionCodec::None);
1187    }
1188}