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 --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 #[arg(long)]
27 pub no_download: bool,
28
29 #[arg(long)]
31 pub no_upload: bool,
32
33 #[arg(long)]
35 pub single: bool,
36
37 #[arg(long)]
39 pub bytes: bool,
40
41 #[arg(long)]
43 pub simple: bool,
44
45 #[arg(long)]
47 pub csv: bool,
48
49 #[arg(long, default_value = ",", value_parser = validate_csv_delimiter)]
51 pub csv_delimiter: char,
52
53 #[arg(long)]
55 pub csv_header: bool,
56
57 #[arg(long)]
59 pub json: bool,
60
61 #[arg(long, value_enum)]
63 pub format: Option<OutputFormatType>,
64
65 #[arg(long)]
67 pub list: bool,
68
69 #[arg(long)]
71 pub server: Vec<String>,
72
73 #[arg(long)]
75 pub exclude: Vec<String>,
76
77 #[arg(long, value_parser = validate_ip_address)]
79 pub source: Option<String>,
80
81 #[arg(long, default_value = "10", value_parser = validate_timeout)]
83 pub timeout: u64,
84
85 #[arg(long, value_enum)]
87 pub generate_completion: Option<ShellType>,
88
89 #[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#[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}