Skip to main content

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