1use clap::{Parser, ValueEnum};
2
3include!("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 #[arg(long)]
30 pub no_download: bool,
31
32 #[arg(long)]
34 pub no_upload: bool,
35
36 #[arg(long)]
38 pub single: bool,
39
40 #[arg(long)]
42 pub bytes: bool,
43
44 #[arg(long)]
46 pub simple: bool,
47
48 #[arg(long)]
50 pub csv: bool,
51
52 #[arg(long, default_value = ",", value_parser = validate_csv_delimiter)]
54 pub csv_delimiter: char,
55
56 #[arg(long)]
58 pub csv_header: bool,
59
60 #[arg(long)]
62 pub json: bool,
63
64 #[arg(long, value_enum)]
66 pub format: Option<OutputFormatType>,
67
68 #[arg(long)]
70 pub list: bool,
71
72 #[arg(long)]
74 pub server: Vec<String>,
75
76 #[arg(long)]
78 pub exclude: Vec<String>,
79
80 #[arg(long, value_parser = validate_ip_address)]
82 pub source: Option<String>,
83
84 #[arg(long, default_value = "10", value_parser = validate_timeout)]
86 pub timeout: u64,
87
88 #[arg(long, value_enum)]
90 pub generate_completion: Option<ShellType>,
91
92 #[arg(long)]
94 pub history: bool,
95
96 #[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#[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}