1pub use clap::ArgAction;
2use clap::{Parser, ValueEnum};
3
4include!("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 #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
45 pub no_download: Option<bool>,
46
47 #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
49 pub no_upload: Option<bool>,
50
51 #[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 #[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 #[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 #[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 #[arg(long, default_value = ",", value_parser = validate_csv_delimiter)]
105 pub csv_delimiter: char,
106
107 #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
109 pub csv_header: Option<bool>,
110
111 #[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 #[arg(long, value_enum)]
126 pub format: Option<OutputFormatType>,
127
128 #[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 #[arg(long)]
137 pub server: Vec<String>,
138
139 #[arg(long)]
141 pub exclude: Vec<String>,
142
143 #[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 #[arg(long, default_value = "10", value_parser = validate_timeout)]
149 pub timeout: u64,
150
151 #[arg(long, value_enum)]
153 pub generate_completion: Option<ShellType>,
154
155 #[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 #[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 #[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 #[arg(long)]
181 pub no_emoji: bool,
182
183 #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
185 pub minimal: Option<bool>,
186
187 #[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 #[arg(long, value_name = "THEME", default_value = "dark")]
200 pub theme: String,
201
202 #[arg(long)]
204 pub show_config_path: bool,
205
206 #[arg(long, action = ArgAction::Set, default_missing_value = "true", num_args = 0..=1)]
208 pub strict_config: Option<bool>,
209
210 #[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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
304pub enum OutputFormatType {
305 Json,
307 Jsonl,
309 Csv,
311 Minimal,
313 Simple,
315 Compact,
317 Detailed,
319 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 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 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")); }
454
455 #[test]
456 fn test_validate_ca_cert_path_is_directory() {
457 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}