nntp_proxy/
args.rs

1//! Command-line argument parsing for NNTP proxy binaries
2//!
3//! Provides shared argument structures to avoid duplication across binaries.
4
5use crate::RoutingMode;
6use crate::types::{CacheCapacity, ConfigPath, Port, ThreadCount};
7use clap::Parser;
8
9/// Common command-line arguments for NNTP proxy binaries
10///
11/// Use `#[command(flatten)]` in binary-specific Args to include these fields.
12#[derive(Parser, Debug, Clone)]
13pub struct CommonArgs {
14    /// Port to listen on (overrides config file)
15    #[arg(short, long, env)]
16    pub port: Option<Port>,
17
18    /// Host to bind to (overrides config file)
19    #[arg(long, env)]
20    pub host: Option<String>,
21
22    /// Routing mode: stateful, per-command, or hybrid
23    ///
24    /// - stateful: 1:1 mode, each client gets a dedicated backend connection
25    /// - per-command: Each command can use a different backend (stateless only)
26    /// - hybrid: Starts in per-command mode, auto-switches to stateful on first stateful command
27    #[arg(
28        short = 'm',
29        long = "routing-mode",
30        value_enum,
31        default_value = "hybrid",
32        env
33    )]
34    pub routing_mode: RoutingMode,
35
36    /// Configuration file path
37    #[arg(short, long, default_value = "config.toml", env)]
38    pub config: ConfigPath,
39
40    /// Number of worker threads (default: 1, use 0 for CPU cores)
41    #[arg(short, long, env)]
42    pub threads: Option<ThreadCount>,
43}
44
45impl CommonArgs {
46    /// Default port when none specified
47    const DEFAULT_PORT: u16 = 8119;
48
49    /// Default host when none specified
50    const DEFAULT_HOST: &'static str = "0.0.0.0";
51
52    /// Get listen address from args, using config as fallback
53    ///
54    /// # Arguments
55    /// * `config_port` - Port from config file (if any)
56    ///
57    /// # Returns
58    /// Formatted listen address (e.g., "0.0.0.0:8119")
59    #[must_use]
60    pub fn listen_addr(&self, config_port: Option<Port>) -> String {
61        let port = self
62            .effective_port(config_port)
63            .map_or(Self::DEFAULT_PORT, |p| p.get());
64
65        format!("{}:{}", self.effective_host(), port)
66    }
67
68    /// Get effective port (from args or config)
69    #[must_use]
70    pub const fn effective_port(&self, config_port: Option<Port>) -> Option<Port> {
71        match self.port {
72            Some(p) => Some(p),
73            None => config_port,
74        }
75    }
76
77    /// Get effective host
78    #[must_use]
79    pub fn effective_host(&self) -> &str {
80        self.host.as_deref().unwrap_or(Self::DEFAULT_HOST)
81    }
82}
83
84/// Cache-specific arguments
85#[derive(Parser, Debug, Clone)]
86pub struct CacheArgs {
87    /// Cache max capacity (number of articles)
88    #[arg(long, default_value = "10000", env)]
89    pub cache_capacity: CacheCapacity,
90
91    /// Cache TTL in seconds
92    #[arg(long, default_value = "3600", env)]
93    pub cache_ttl: u64,
94}
95
96impl CacheArgs {
97    /// Get cache TTL as Duration
98    #[must_use]
99    pub const fn ttl(&self) -> std::time::Duration {
100        std::time::Duration::from_secs(self.cache_ttl)
101    }
102
103    /// Get cache capacity as usize
104    #[must_use]
105    pub fn capacity(&self) -> usize {
106        self.cache_capacity.get()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_common_args_defaults() {
116        let args = CommonArgs {
117            port: None,
118            host: None,
119            routing_mode: RoutingMode::Hybrid,
120            config: ConfigPath::new("config.toml").unwrap(),
121            threads: None,
122        };
123
124        assert_eq!(args.listen_addr(None), "0.0.0.0:8119");
125        assert_eq!(args.effective_host(), "0.0.0.0");
126        assert!(args.effective_port(None).is_none());
127    }
128
129    #[test]
130    fn test_common_args_with_port() {
131        let args = CommonArgs {
132            port: Some(Port::new(9119).unwrap()),
133            host: None,
134            routing_mode: RoutingMode::Hybrid,
135            config: ConfigPath::new("config.toml").unwrap(),
136            threads: None,
137        };
138
139        assert_eq!(args.listen_addr(None), "0.0.0.0:9119");
140        assert_eq!(args.effective_port(None), Some(Port::new(9119).unwrap()));
141    }
142
143    #[test]
144    fn test_common_args_with_host() {
145        let args = CommonArgs {
146            port: None,
147            host: Some("127.0.0.1".to_string()),
148            routing_mode: RoutingMode::Hybrid,
149            config: ConfigPath::new("config.toml").unwrap(),
150            threads: None,
151        };
152
153        assert_eq!(args.listen_addr(None), "127.0.0.1:8119");
154        assert_eq!(args.effective_host(), "127.0.0.1");
155    }
156
157    #[test]
158    fn test_common_args_port_override() {
159        let args = CommonArgs {
160            port: Some(Port::new(9119).unwrap()),
161            host: None,
162            routing_mode: RoutingMode::Hybrid,
163            config: ConfigPath::new("config.toml").unwrap(),
164            threads: None,
165        };
166
167        let config_port = Some(Port::new(7119).unwrap());
168
169        // Args port should override config port
170        assert_eq!(args.listen_addr(config_port), "0.0.0.0:9119");
171        assert_eq!(
172            args.effective_port(config_port),
173            Some(Port::new(9119).unwrap())
174        );
175    }
176
177    #[test]
178    fn test_common_args_config_port_fallback() {
179        let args = CommonArgs {
180            port: None,
181            host: None,
182            routing_mode: RoutingMode::Hybrid,
183            config: ConfigPath::new("config.toml").unwrap(),
184            threads: None,
185        };
186
187        let config_port = Some(Port::new(7119).unwrap());
188
189        // Should use config port when args port is None
190        assert_eq!(args.listen_addr(config_port), "0.0.0.0:7119");
191        assert_eq!(
192            args.effective_port(config_port),
193            Some(Port::new(7119).unwrap())
194        );
195    }
196
197    #[test]
198    fn test_common_args_custom_host_and_port() {
199        let args = CommonArgs {
200            port: Some(Port::new(9119).unwrap()),
201            host: Some("192.168.1.1".to_string()),
202            routing_mode: RoutingMode::Hybrid,
203            config: ConfigPath::new("config.toml").unwrap(),
204            threads: None,
205        };
206
207        assert_eq!(args.listen_addr(None), "192.168.1.1:9119");
208        assert_eq!(args.effective_host(), "192.168.1.1");
209        assert_eq!(args.effective_port(None), Some(Port::new(9119).unwrap()));
210    }
211
212    #[test]
213    fn test_cache_args_defaults() {
214        let args = CacheArgs {
215            cache_capacity: CacheCapacity::new(10000).unwrap(),
216            cache_ttl: 3600,
217        };
218
219        assert_eq!(args.capacity(), 10000);
220        assert_eq!(args.cache_ttl, 3600);
221        assert_eq!(args.ttl(), std::time::Duration::from_secs(3600));
222    }
223
224    #[test]
225    fn test_cache_args_custom_values() {
226        let args = CacheArgs {
227            cache_capacity: CacheCapacity::new(50000).unwrap(),
228            cache_ttl: 7200,
229        };
230
231        assert_eq!(args.capacity(), 50000);
232        assert_eq!(args.cache_ttl, 7200);
233        assert_eq!(args.ttl(), std::time::Duration::from_secs(7200));
234    }
235
236    #[test]
237    fn test_routing_modes() {
238        let hybrid = CommonArgs {
239            routing_mode: RoutingMode::Hybrid,
240            ..default_args()
241        };
242        assert_eq!(hybrid.routing_mode, RoutingMode::Hybrid);
243
244        let stateful = CommonArgs {
245            routing_mode: RoutingMode::Stateful,
246            ..default_args()
247        };
248        assert_eq!(stateful.routing_mode, RoutingMode::Stateful);
249
250        let per_command = CommonArgs {
251            routing_mode: RoutingMode::PerCommand,
252            ..default_args()
253        };
254        assert_eq!(per_command.routing_mode, RoutingMode::PerCommand);
255    }
256
257    #[test]
258    fn test_thread_count() {
259        let default_threads = CommonArgs {
260            threads: None,
261            ..default_args()
262        };
263        assert!(default_threads.threads.is_none());
264
265        let single_thread = CommonArgs {
266            threads: Some(ThreadCount::new(1).unwrap()),
267            ..default_args()
268        };
269        assert_eq!(single_thread.threads, Some(ThreadCount::new(1).unwrap()));
270
271        let multi_thread = CommonArgs {
272            threads: Some(ThreadCount::new(4).unwrap()),
273            ..default_args()
274        };
275        assert_eq!(multi_thread.threads, Some(ThreadCount::new(4).unwrap()));
276    }
277
278    // Helper to create default args for testing
279    fn default_args() -> CommonArgs {
280        CommonArgs {
281            port: None,
282            host: None,
283            routing_mode: RoutingMode::Hybrid,
284            config: ConfigPath::new("config.toml").unwrap(),
285            threads: None,
286        }
287    }
288}