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