Skip to main content

netspeed_cli/
cli.rs

1use clap::{Parser, ValueEnum};
2
3// Shared validation functions (also used by build.rs via include!)
4include!("validate.rs");
5
6#[derive(Parser, Debug)]
7#[allow(clippy::struct_excessive_bools)]
8#[command(name = "netspeed-cli")]
9#[command(version = env!("CARGO_PKG_VERSION"))]
10#[command(about = "Command line interface for testing internet bandwidth using speedtest.net")]
11#[command(after_help = "\
12Examples:
13  netspeed-cli                          Run a full speed test
14  netspeed-cli --simple                 Run with minimal output
15  netspeed-cli --json                   Output results as JSON
16  netspeed-cli --list                   List available servers
17  netspeed-cli --server 1234            Test against a specific server
18  netspeed-cli --no-upload              Skip upload test
19  netspeed-cli --bytes                  Show results in MB/s instead of Mbit/s
20  netspeed-cli --single                 Use a single connection (debugging)
21  netspeed-cli --generate-completion zsh > ~/.zsh/functions/_netspeed-cli
22                                        Generate Zsh shell completions
23")]
24pub struct CliArgs {
25    /// Do not perform download test
26    #[arg(long)]
27    pub no_download: bool,
28
29    /// Do not perform upload test
30    #[arg(long)]
31    pub no_upload: bool,
32
33    /// Only use a single connection instead of multiple
34    #[arg(long)]
35    pub single: bool,
36
37    /// Display values in bytes instead of bits
38    #[arg(long)]
39    pub bytes: bool,
40
41    /// Suppress verbose output, only show basic information
42    #[arg(long)]
43    pub simple: bool,
44
45    /// Output in CSV format
46    #[arg(long)]
47    pub csv: bool,
48
49    /// Single character delimiter for CSV output (default: ",")
50    #[arg(long, default_value = ",", value_parser = validate_csv_delimiter)]
51    pub csv_delimiter: char,
52
53    /// Print CSV headers
54    #[arg(long)]
55    pub csv_header: bool,
56
57    /// Output in JSON format
58    #[arg(long)]
59    pub json: bool,
60
61    /// Output format (supersedes --json, --csv, --simple)
62    #[arg(long, value_enum)]
63    pub format: Option<OutputFormatType>,
64
65    /// Display a list of speedtest.net servers sorted by distance
66    #[arg(long)]
67    pub list: bool,
68
69    /// Specify a server ID to test against (can be supplied multiple times)
70    #[arg(long)]
71    pub server: Vec<String>,
72
73    /// Exclude a server from selection (can be supplied multiple times)
74    #[arg(long)]
75    pub exclude: Vec<String>,
76
77    /// Source IP address to bind to
78    #[arg(long, value_parser = validate_ip_address)]
79    pub source: Option<String>,
80
81    /// HTTP timeout in seconds (default: 10)
82    #[arg(long, default_value = "10", value_parser = validate_timeout)]
83    pub timeout: u64,
84
85    /// Generate shell completion script
86    #[arg(long, value_enum)]
87    pub generate_completion: Option<ShellType>,
88
89    /// Display test history
90    #[arg(long)]
91    pub history: bool,
92}
93
94fn validate_csv_delimiter(s: &str) -> Result<char, String> {
95    let chars: Vec<char> = s.chars().collect();
96    if chars.len() != 1 {
97        return Err("CSV delimiter must be a single character".to_string());
98    }
99
100    let delimiter = chars[0];
101    if !",;|\\t".contains(delimiter) {
102        return Err(format!(
103            "Invalid CSV delimiter '{delimiter}'. Must be one of: comma, semicolon, pipe, or tab"
104        ));
105    }
106
107    Ok(delimiter)
108}
109
110fn validate_timeout(s: &str) -> Result<u64, String> {
111    let timeout: u64 = s
112        .parse()
113        .map_err(|_| format!("Invalid timeout value: '{s}'"))?;
114    if timeout == 0 {
115        return Err("Timeout must be greater than 0".to_string());
116    }
117    if timeout > 300 {
118        return Err("Timeout must be 300 seconds or less".to_string());
119    }
120    Ok(timeout)
121}
122
123#[derive(Clone, Copy, Debug, ValueEnum)]
124pub enum ShellType {
125    Bash,
126    Zsh,
127    Fish,
128    PowerShell,
129    Elvish,
130}
131
132/// Unified output format selection (supersedes --json, --csv, --simple).
133#[derive(Clone, Copy, Debug, ValueEnum)]
134pub enum OutputFormatType {
135    Json,
136    Csv,
137    Simple,
138    Detailed,
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_validate_csv_delimiter_comma() {
147        assert!(validate_csv_delimiter(",").is_ok());
148    }
149
150    #[test]
151    fn test_validate_csv_delimiter_semicolon() {
152        assert!(validate_csv_delimiter(";").is_ok());
153    }
154
155    #[test]
156    fn test_validate_csv_delimiter_pipe() {
157        assert!(validate_csv_delimiter("|").is_ok());
158    }
159
160    #[test]
161    fn test_validate_csv_delimiter_invalid() {
162        assert!(validate_csv_delimiter("a").is_err());
163    }
164
165    #[test]
166    fn test_validate_csv_delimiter_multiple_chars() {
167        assert!(validate_csv_delimiter(",,,").is_err());
168    }
169
170    #[test]
171    fn test_validate_ip_address_valid() {
172        assert!(validate_ip_address("192.168.1.1").is_ok());
173    }
174
175    #[test]
176    fn test_validate_ip_address_localhost() {
177        assert!(validate_ip_address("127.0.0.1").is_ok());
178    }
179
180    #[test]
181    fn test_validate_ip_address_invalid_format() {
182        assert!(validate_ip_address("192.168.1").is_err());
183    }
184
185    #[test]
186    fn test_validate_ip_address_invalid_octet() {
187        assert!(validate_ip_address("192.168.1.999").is_err());
188    }
189
190    #[test]
191    fn test_validate_timeout_valid() {
192        assert!(validate_timeout("10").is_ok());
193    }
194
195    #[test]
196    fn test_validate_timeout_min() {
197        assert!(validate_timeout("1").is_ok());
198    }
199
200    #[test]
201    fn test_validate_timeout_max() {
202        assert!(validate_timeout("300").is_ok());
203    }
204
205    #[test]
206    fn test_validate_timeout_zero() {
207        let result = validate_timeout("0");
208        assert!(result.is_err());
209        assert!(result.unwrap_err().contains("greater than 0"));
210    }
211
212    #[test]
213    fn test_validate_timeout_too_large() {
214        let result = validate_timeout("301");
215        assert!(result.is_err());
216        assert!(result.unwrap_err().contains("300 seconds or less"));
217    }
218
219    #[test]
220    fn test_validate_timeout_invalid() {
221        assert!(validate_timeout("abc").is_err());
222    }
223}