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(
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#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
303pub enum OutputFormatType {
304 Json,
306 Jsonl,
308 Csv,
310 Minimal,
312 Simple,
314 Compact,
316 Detailed,
318 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 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 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 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}