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    /// Enable certificate pinning for speedtest.net servers
225    ///
226    /// When enabled, the client only accepts connections to speedtest.net
227    /// and ookla.com domains. This provides some protection against MITM attacks
228    /// but does NOT verify actual certificate hashes (domain-only pinning).
229    #[arg(
230        long,
231        action = ArgAction::Set,
232        default_missing_value = "true",
233        num_args = 0..=1,
234        long_help = "Enable certificate pinning for speedtest.net servers.\nWhen enabled, the client only accepts connections to speedtest.net\nand ookla.com domains.\n\n⚠ SECURITY LIMITATION: This is DOMAIN-ONLY pinning. It only validates\nthat the server hostname ends with .speedtest.net or .ookla.com.\nIt does NOT verify certificate hashes or the certificate chain.\nAn attacker with a valid certificate from any CA for these domains\ncould still perform a man-in-the-middle (MITM) attack.\n\nFor production security, use a custom CA certificate (--ca-cert) instead."
235    )]
236    pub pin_certs: Option<bool>,
237}
238
239fn validate_csv_delimiter(s: &str) -> Result<char, String> {
240    let chars: Vec<char> = s.chars().collect();
241    if chars.len() != 1 {
242        return Err("CSV delimiter must be a single character".to_string());
243    }
244
245    let delimiter = chars[0];
246    if !",;|\\t".contains(delimiter) {
247        return Err(format!(
248            "Invalid CSV delimiter '{delimiter}'. Must be one of: comma, semicolon, pipe, or tab"
249        ));
250    }
251
252    Ok(delimiter)
253}
254
255fn validate_timeout(s: &str) -> Result<u64, String> {
256    let timeout: u64 = s
257        .parse()
258        .map_err(|_| format!("Invalid timeout value: '{s}'"))?;
259    if timeout == 0 {
260        return Err("Timeout must be greater than 0".to_string());
261    }
262    if timeout > 300 {
263        return Err("Timeout must be 300 seconds or less".to_string());
264    }
265    Ok(timeout)
266}
267
268fn validate_tls_version(s: &str) -> Result<String, String> {
269    let normalized = s.to_lowercase();
270    if normalized == "1.2" || normalized == "1.3" {
271        Ok(normalized)
272    } else {
273        Err("TLS version must be '1.2' or '1.3'".to_string())
274    }
275}
276
277fn validate_ca_cert_path(s: &str) -> Result<String, String> {
278    let path = std::path::Path::new(s);
279    if !path.exists() {
280        return Err(format!(
281            "CA certificate file not found: {s}\nUse --pin-certs for domain-only pinning instead."
282        ));
283    }
284    if !path.is_file() {
285        return Err(format!(
286            "CA certificate path is not a file: {s}\nUse --pin-certs for domain-only pinning instead."
287        ));
288    }
289    Ok(s.to_string())
290}
291
292#[derive(Clone, Copy, Debug, ValueEnum)]
293pub enum ShellType {
294    Bash,
295    Zsh,
296    Fish,
297    #[value(name = "powershell")]
298    PowerShell,
299    Elvish,
300}
301
302/// Unified output format selection (supersedes --json, --csv, --simple).
303#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
304pub enum OutputFormatType {
305    /// Machine-readable JSON output
306    Json,
307    /// JSON Lines for logging (one JSON object per line)
308    Jsonl,
309    /// CSV format for spreadsheet analysis
310    Csv,
311    /// Ultra-minimal: just grade + speeds (e.g., "B+ 150.5↓ 25.3↑ 12ms")
312    Minimal,
313    /// Minimal one-line summary
314    Simple,
315    /// Key metrics with quality ratings
316    Compact,
317    /// Full analysis with per-metric grades (default)
318    Detailed,
319    /// Rich terminal dashboard with capability matrix
320    Dashboard,
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_validate_csv_delimiter_comma() {
329        assert!(validate_csv_delimiter(",").is_ok());
330    }
331
332    #[test]
333    fn test_validate_csv_delimiter_semicolon() {
334        assert!(validate_csv_delimiter(";").is_ok());
335    }
336
337    #[test]
338    fn test_validate_csv_delimiter_pipe() {
339        assert!(validate_csv_delimiter("|").is_ok());
340    }
341
342    #[test]
343    fn test_validate_csv_delimiter_invalid() {
344        assert!(validate_csv_delimiter("a").is_err());
345    }
346
347    #[test]
348    fn test_validate_csv_delimiter_multiple_chars() {
349        assert!(validate_csv_delimiter(",,,").is_err());
350    }
351
352    #[test]
353    fn test_validate_ip_address_valid() {
354        assert!(validate_ip_address("192.168.1.1").is_ok());
355    }
356
357    #[test]
358    fn test_validate_ip_address_localhost() {
359        assert!(validate_ip_address("127.0.0.1").is_ok());
360    }
361
362    #[test]
363    fn test_validate_ip_address_invalid_format() {
364        assert!(validate_ip_address("192.168.1").is_err());
365    }
366
367    #[test]
368    fn test_validate_ip_address_invalid_octet() {
369        assert!(validate_ip_address("192.168.1.999").is_err());
370    }
371
372    #[test]
373    fn test_validate_timeout_valid() {
374        assert!(validate_timeout("10").is_ok());
375    }
376
377    #[test]
378    fn test_validate_timeout_min() {
379        assert!(validate_timeout("1").is_ok());
380    }
381
382    #[test]
383    fn test_validate_timeout_max() {
384        assert!(validate_timeout("300").is_ok());
385    }
386
387    #[test]
388    fn test_validate_timeout_zero() {
389        let result = validate_timeout("0");
390        assert!(result.is_err());
391        assert!(result.unwrap_err().contains("greater than 0"));
392    }
393
394    #[test]
395    fn test_validate_timeout_too_large() {
396        let result = validate_timeout("301");
397        assert!(result.is_err());
398        assert!(result.unwrap_err().contains("300 seconds or less"));
399    }
400
401    #[test]
402    fn test_validate_timeout_invalid() {
403        assert!(validate_timeout("abc").is_err());
404    }
405
406    #[test]
407    fn test_validate_tls_version_valid_12() {
408        assert_eq!(validate_tls_version("1.2"), Ok("1.2".to_string()));
409    }
410
411    #[test]
412    fn test_validate_tls_version_valid_13() {
413        assert_eq!(validate_tls_version("1.3"), Ok("1.3".to_string()));
414    }
415
416    #[test]
417    fn test_validate_tls_version_case_insensitive() {
418        assert_eq!(validate_tls_version("1.2"), Ok("1.2".to_string()));
419        assert_eq!(validate_tls_version("1.3"), Ok("1.3".to_string()));
420    }
421
422    #[test]
423    fn test_validate_tls_version_invalid() {
424        assert!(validate_tls_version("1.1").is_err());
425        assert!(validate_tls_version("2.0").is_err());
426        assert!(validate_tls_version("TLS1.2").is_err());
427        assert!(validate_tls_version("").is_err());
428    }
429
430    #[test]
431    fn test_validate_ca_cert_path_valid() {
432        // Create a temp file to test the success path
433        let temp_dir = std::env::temp_dir();
434        let cert_path = temp_dir.join("test_ca_cert_validate.pem");
435        std::fs::write(&cert_path, "dummy cert content").ok();
436
437        let result = validate_ca_cert_path(cert_path.to_str().unwrap());
438        assert!(result.is_ok());
439        assert_eq!(result.unwrap(), cert_path.to_str().unwrap());
440
441        // Clean up
442        std::fs::remove_file(&cert_path).ok();
443    }
444
445    #[test]
446    fn test_validate_ca_cert_path_not_found() {
447        let result = validate_ca_cert_path("/nonexistent/path/to/cert.pem");
448        assert!(result.is_err());
449        let err = result.unwrap_err();
450        assert!(err.contains("not found"));
451        assert!(err.contains("/nonexistent/path/to/cert.pem"));
452        assert!(err.contains("--pin-certs")); // Suggest alternative
453    }
454
455    #[test]
456    fn test_validate_ca_cert_path_is_directory() {
457        // Use temp directory (cross-platform - works on Windows too)
458        // Use tempfile::TempDir for guaranteed unique directory
459        let temp_dir = tempfile::TempDir::new().unwrap();
460        let result = validate_ca_cert_path(temp_dir.path().to_str().unwrap());
461        assert!(result.is_err());
462        let err = result.unwrap_err();
463        assert!(err.contains("not a file"));
464    }
465}