Skip to main content

netspeed_cli/
cli.rs

1pub use clap::ArgAction;
2use clap::{Parser, ValueEnum};
3
4// Shared validation functions (also used by build.rs via include!)
5include!("validate.rs");
6
7#[derive(Parser, Debug)]
8#[allow(clippy::struct_excessive_bools)]
9#[allow(deprecated)]
10#[command(name = "netspeed-cli")]
11#[command(version = env!("CARGO_PKG_VERSION"))]
12#[command(
13    about = "Test internet bandwidth via speedtest.net servers",
14    long_about = "Test internet bandwidth via speedtest.net servers.
15
16The default workflow runs a full bandwidth test:
17  1. Discover nearest speedtest.net servers
18  2. Measure latency (8 ping samples → latency, jitter, packet loss)
19  3. Measure download speed (multi-stream, concurrent downloads)
20  4. Measure upload speed (multi-stream, concurrent uploads)
21  5. Grade results (A+ to F) and show real-world usage estimates
22
23Configuration precedence: CLI flags override config file values, which override built-in defaults.
24Results are saved to a local history file for trend tracking."
25)]
26#[command(
27    after_help = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28Examples:
29  netspeed-cli                          Run a full speed test
30  netspeed-cli --format compact         Key metrics with ratings
31  netspeed-cli --format dashboard       Rich dashboard with history
32  netspeed-cli --format json            Machine-readable output
33  netspeed-cli --list                   List available servers
34  netspeed-cli --history                Show test history
35  netspeed-cli --profile gamer          Optimize output for gaming
36  netspeed-cli --theme light            Light terminal background
37  netspeed-cli --no-emoji               Disable emoji output
38  netspeed-cli --quiet                  Suppress progress output
39━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
40)]
41#[derive(Default)]
42pub struct Args {
43    /// Do not perform download test
44    #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
45    pub no_download: Option<bool>,
46
47    /// Do not perform upload test
48    #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
49    pub no_upload: Option<bool>,
50
51    /// Only use a single connection instead of multiple
52    ///
53    /// A single connection measures sustained throughput.
54    /// The default uses multiple connections to measure burst/bandwidth capacity.
55    #[arg(
56        long,
57        action = ArgAction::Set,
58        default_missing_value = "true",
59        num_args = 0..=1,
60        long_help = "Use a single TCP connection for testing (measures sustained throughput).\nThe default uses multiple connections (measures burst/bandwidth capacity)."
61    )]
62    pub single: Option<bool>,
63
64    /// Display values in bytes instead of bits
65    ///
66    /// The default displays values in bits (standard for ISP advertising).
67    #[arg(
68        long,
69        action = ArgAction::Set,
70        default_missing_value = "true",
71        num_args = 0..=1,
72        long_help = "Display values in bytes instead of bits per second.\nThe default uses bits (standard for ISP advertising)."
73    )]
74    pub bytes: Option<bool>,
75
76    /// Suppress verbose output, only show basic information
77    ///
78    /// Basic information = one-line summary: latency, download, upload.
79    #[deprecated(since = "0.9.0", note = "Use --format simple instead")]
80    #[arg(
81        long,
82        action = ArgAction::Set,
83        default_missing_value = "true",
84        num_args = 0..=1,
85        long_help = "Suppress verbose output, only show basic information.\nBasic information = one-line summary: latency, download, upload.\nDeprecated: use --format simple instead."
86    )]
87    pub simple: Option<bool>,
88
89    /// Output in CSV format
90    ///
91    /// CSV output is suitable for spreadsheet analysis.
92    /// Use --csv-header to include column names.
93    #[deprecated(since = "0.9.0", note = "Use --format csv instead")]
94    #[arg(
95        long,
96        action = ArgAction::Set,
97        default_missing_value = "true",
98        num_args = 0..=1,
99        long_help = "Output in CSV format for spreadsheet analysis.\nDeprecated: use --format csv instead."
100    )]
101    pub csv: Option<bool>,
102
103    /// Single character delimiter for CSV output (default: ",")
104    #[arg(long, default_value = ",", value_parser = validate_csv_delimiter)]
105    pub csv_delimiter: char,
106
107    /// Print CSV headers
108    #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
109    pub csv_header: Option<bool>,
110
111    /// Output in JSON format
112    ///
113    /// JSON output is machine-readable and includes all measured values.
114    #[deprecated(since = "0.9.0", note = "Use --format json instead")]
115    #[arg(
116        long,
117        action = ArgAction::Set,
118        default_missing_value = "true",
119        num_args = 0..=1,
120        long_help = "Output in JSON format (machine-readable).\nDeprecated: use --format json instead."
121    )]
122    pub json: Option<bool>,
123
124    /// Output format (supersedes --json, --csv, --simple)
125    #[arg(long, value_enum)]
126    pub format: Option<OutputFormatType>,
127
128    /// Display a list of speedtest.net servers sorted by distance
129    #[arg(
130        long,
131        long_help = "Display a list of nearby speedtest.net servers sorted by distance.\nDoes not run a bandwidth test."
132    )]
133    pub list: bool,
134
135    /// Specify a server ID to test against (can be supplied multiple times)
136    #[arg(long)]
137    pub server: Vec<String>,
138
139    /// Exclude a server from selection (can be supplied multiple times)
140    #[arg(long)]
141    pub exclude: Vec<String>,
142
143    /// Source IP address to bind to (IPv4 or IPv6)
144    #[arg(long, value_parser = validate_ip_address, long_help = "Source IP address to bind to (IPv4 or IPv6).\nUseful on multi-homed systems to select a specific interface.")]
145    pub source: Option<String>,
146
147    /// HTTP timeout in seconds (default: 10)
148    #[arg(long, default_value = "10", value_parser = validate_timeout)]
149    pub timeout: u64,
150
151    /// Generate shell completion script
152    #[arg(long, value_enum)]
153    pub generate_completion: Option<ShellType>,
154
155    /// Display test history
156    #[arg(
157        long,
158        long_help = "Display test history from the local JSON file.\nDoes not run a bandwidth test."
159    )]
160    pub history: bool,
161
162    /// Suppress all progress output (JSON/CSV still go to stdout)
163    #[arg(
164        long,
165        action = ArgAction::Set,
166        default_missing_value = "true",
167        num_args = 0..=1,
168        long_help = "Suppress all progress output during the test.\nJSON/CSV output still goes to stdout."
169    )]
170    pub quiet: Option<bool>,
171
172    /// Validate configuration and exit without running tests
173    #[arg(
174        long,
175        long_help = "Validate configuration and exit without running tests.\nPrints the server that would be selected and confirms connectivity."
176    )]
177    pub dry_run: bool,
178
179    /// Disable emoji output (for environments where emojis don't render well)
180    #[arg(long)]
181    pub no_emoji: bool,
182
183    /// Minimal ASCII-only output (no Unicode box-drawing characters)
184    #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
185    pub minimal: Option<bool>,
186
187    /// User profile for customized output (gamer, streamer, remote-worker, power-user, casual)
188    ///
189    /// Profiles control which sections are shown and grading thresholds.
190    /// gamer = latency-focused, streamer = download-focused, etc.
191    #[arg(
192        long,
193        value_name = "PROFILE",
194        long_help = "User profile for customized output.\nProfiles control displayed sections and grading thresholds:\n  gamer:          Latency-focused (ping/jitter weighted higher)\n  streamer:       Download-focused (download weighted higher)\n  remote-worker:  Upload-focused (upload weighted higher)\n  power-user:     All metrics with full detail\n  casual:         Simple pass/fail view"
195    )]
196    pub profile: Option<String>,
197
198    /// Output color theme (dark, light, high-contrast, monochrome)
199    #[arg(long, value_name = "THEME", default_value = "dark")]
200    pub theme: String,
201
202    /// Show the configuration file path and exit
203    #[arg(long)]
204    pub show_config_path: bool,
205
206    /// Enable strict config mode - show warnings for invalid config values
207    #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
208    pub strict_config: Option<bool>,
209
210    /// Path to a custom CA certificate file (PEM/DER format)
211    ///
212    /// When specified, the client will use this certificate for TLS verification
213    /// instead of the system default certificates.
214    #[arg(long, value_name = "PATH", value_parser = validate_ca_cert_path, long_help = "Path to a custom CA certificate file (PEM/DER format).\nWhen specified, the client uses this certificate for TLS verification\ninstead of the system default certificates.")]
215    pub ca_cert: Option<String>,
216
217    /// Minimum TLS version to use (1.2 or 1.3)
218    ///
219    /// The default allows both TLS 1.2 and 1.3. Use this to restrict
220    /// connections to a specific TLS version for testing or compliance.
221    #[arg(long, value_name = "VERSION", value_parser = validate_tls_version, long_help = "Minimum TLS version to use (1.2 or 1.3).\nThe default allows both TLS 1.2 and 1.3.\nUse this to restrict connections to a specific TLS version.")]
222    pub tls_version: Option<String>,
223
224    /// Restrict TLS connections to speedtest.net and ookla.com domains
225    ///
226    /// Normal rustls/webpki certificate-chain and hostname validation still run.
227    /// This option adds a speedtest.net/ookla.com domain allowlist on top.
228    #[arg(
229        long,
230        action = ArgAction::Set,
231        default_missing_value = "true",
232        num_args = 0..=1,
233        long_help = "Restrict TLS connections to speedtest.net and ookla.com domains.\nNormal rustls/webpki certificate-chain and hostname validation still run;\nthis option adds a domain allowlist on top. It does not bypass TLS verification."
234    )]
235    pub pin_certs: Option<bool>,
236}
237
238fn validate_csv_delimiter(s: &str) -> Result<char, String> {
239    let chars: Vec<char> = s.chars().collect();
240    if chars.len() != 1 {
241        return Err("CSV delimiter must be a single character".to_string());
242    }
243
244    let delimiter = chars[0];
245    if !",;|\\t".contains(delimiter) {
246        return Err(format!(
247            "Invalid CSV delimiter '{delimiter}'. Must be one of: comma, semicolon, pipe, or tab"
248        ));
249    }
250
251    Ok(delimiter)
252}
253
254fn validate_timeout(s: &str) -> Result<u64, String> {
255    let timeout: u64 = s
256        .parse()
257        .map_err(|_| format!("Invalid timeout value: '{s}'"))?;
258    if timeout == 0 {
259        return Err("Timeout must be greater than 0".to_string());
260    }
261    if timeout > 300 {
262        return Err("Timeout must be 300 seconds or less".to_string());
263    }
264    Ok(timeout)
265}
266
267fn validate_tls_version(s: &str) -> Result<String, String> {
268    let normalized = s.to_lowercase();
269    if normalized == "1.2" || normalized == "1.3" {
270        Ok(normalized)
271    } else {
272        Err("TLS version must be '1.2' or '1.3'".to_string())
273    }
274}
275
276fn validate_ca_cert_path(s: &str) -> Result<String, String> {
277    let path = std::path::Path::new(s);
278    if !path.exists() {
279        return Err(format!(
280            "CA certificate file not found: {s}\nUse a valid PEM/DER file or omit --ca-cert to use the bundled trust roots."
281        ));
282    }
283    if !path.is_file() {
284        return Err(format!(
285            "CA certificate path is not a file: {s}\nUse a valid PEM/DER file or omit --ca-cert to use the bundled trust roots."
286        ));
287    }
288    Ok(s.to_string())
289}
290
291#[derive(Clone, Copy, Debug, ValueEnum)]
292pub enum ShellType {
293    Bash,
294    Zsh,
295    Fish,
296    #[value(name = "powershell")]
297    PowerShell,
298    Elvish,
299}
300
301/// Unified output format selection (supersedes --json, --csv, --simple).
302#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
303pub enum OutputFormatType {
304    /// Machine-readable JSON output
305    Json,
306    /// JSON Lines for logging (one JSON object per line)
307    Jsonl,
308    /// CSV format for spreadsheet analysis
309    Csv,
310    /// Ultra-minimal: just grade + speeds (e.g., "B+ 150.5↓ 25.3↑ 12ms")
311    Minimal,
312    /// Minimal one-line summary
313    Simple,
314    /// Key metrics with quality ratings
315    Compact,
316    /// Full analysis with per-metric grades (default)
317    Detailed,
318    /// Rich terminal dashboard with capability matrix
319    Dashboard,
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_validate_csv_delimiter_comma() {
328        assert!(validate_csv_delimiter(",").is_ok());
329    }
330
331    #[test]
332    fn test_validate_csv_delimiter_semicolon() {
333        assert!(validate_csv_delimiter(";").is_ok());
334    }
335
336    #[test]
337    fn test_validate_csv_delimiter_pipe() {
338        assert!(validate_csv_delimiter("|").is_ok());
339    }
340
341    #[test]
342    fn test_validate_csv_delimiter_invalid() {
343        assert!(validate_csv_delimiter("a").is_err());
344    }
345
346    #[test]
347    fn test_validate_csv_delimiter_multiple_chars() {
348        assert!(validate_csv_delimiter(",,,").is_err());
349    }
350
351    #[test]
352    fn test_validate_ip_address_valid() {
353        assert!(validate_ip_address("192.168.1.1").is_ok());
354    }
355
356    #[test]
357    fn test_validate_ip_address_localhost() {
358        assert!(validate_ip_address("127.0.0.1").is_ok());
359    }
360
361    #[test]
362    fn test_validate_ip_address_invalid_format() {
363        assert!(validate_ip_address("192.168.1").is_err());
364    }
365
366    #[test]
367    fn test_validate_ip_address_invalid_octet() {
368        assert!(validate_ip_address("192.168.1.999").is_err());
369    }
370
371    #[test]
372    fn test_validate_timeout_valid() {
373        assert!(validate_timeout("10").is_ok());
374    }
375
376    #[test]
377    fn test_validate_timeout_min() {
378        assert!(validate_timeout("1").is_ok());
379    }
380
381    #[test]
382    fn test_validate_timeout_max() {
383        assert!(validate_timeout("300").is_ok());
384    }
385
386    #[test]
387    fn test_validate_timeout_zero() {
388        let result = validate_timeout("0");
389        assert!(result.is_err());
390        assert!(result.unwrap_err().contains("greater than 0"));
391    }
392
393    #[test]
394    fn test_validate_timeout_too_large() {
395        let result = validate_timeout("301");
396        assert!(result.is_err());
397        assert!(result.unwrap_err().contains("300 seconds or less"));
398    }
399
400    #[test]
401    fn test_validate_timeout_invalid() {
402        assert!(validate_timeout("abc").is_err());
403    }
404
405    #[test]
406    fn test_validate_tls_version_valid_12() {
407        assert_eq!(validate_tls_version("1.2"), Ok("1.2".to_string()));
408    }
409
410    #[test]
411    fn test_validate_tls_version_valid_13() {
412        assert_eq!(validate_tls_version("1.3"), Ok("1.3".to_string()));
413    }
414
415    #[test]
416    fn test_validate_tls_version_case_insensitive() {
417        assert_eq!(validate_tls_version("1.2"), Ok("1.2".to_string()));
418        assert_eq!(validate_tls_version("1.3"), Ok("1.3".to_string()));
419    }
420
421    #[test]
422    fn test_validate_tls_version_invalid() {
423        assert!(validate_tls_version("1.1").is_err());
424        assert!(validate_tls_version("2.0").is_err());
425        assert!(validate_tls_version("TLS1.2").is_err());
426        assert!(validate_tls_version("").is_err());
427    }
428
429    #[test]
430    fn test_validate_ca_cert_path_valid() {
431        // Create a temp file to test the success path
432        let temp_dir = std::env::temp_dir();
433        let cert_path = temp_dir.join("test_ca_cert_validate.pem");
434        std::fs::write(&cert_path, "dummy cert content").ok();
435
436        let result = validate_ca_cert_path(cert_path.to_str().unwrap());
437        assert!(result.is_ok());
438        assert_eq!(result.unwrap(), cert_path.to_str().unwrap());
439
440        // Clean up
441        std::fs::remove_file(&cert_path).ok();
442    }
443
444    #[test]
445    fn test_validate_ca_cert_path_not_found() {
446        let result = validate_ca_cert_path("/nonexistent/path/to/cert.pem");
447        assert!(result.is_err());
448        let err = result.unwrap_err();
449        assert!(err.contains("not found"));
450        assert!(err.contains("/nonexistent/path/to/cert.pem"));
451        assert!(err.contains("bundled trust roots"));
452    }
453
454    #[test]
455    fn test_validate_ca_cert_path_is_directory() {
456        // Use temp directory (cross-platform - works on Windows too)
457        // Use tempfile::TempDir for guaranteed unique directory
458        let temp_dir = tempfile::TempDir::new().unwrap();
459        let result = validate_ca_cert_path(temp_dir.path().to_str().unwrap());
460        assert!(result.is_err());
461        let err = result.unwrap_err();
462        assert!(err.contains("not a file"));
463    }
464}