1use crate::cli::{CliArgs, ShellType};
7use crate::common;
8use crate::config::Config;
9use crate::error::SpeedtestError;
10use crate::formatter::{OutputFormat, format_list};
11use crate::history;
12use crate::http;
13use crate::progress::{create_spinner, finish_ok, no_color};
14use crate::servers::{fetch_servers, ping_test, select_best_server};
15use crate::test_runner::{self, TestRunResult};
16use crate::types::Server;
17use crate::types::{self, TestResult};
18use crate::{download, upload};
19
20use owo_colors::OwoColorize;
21
22pub struct SpeedTestOrchestrator {
24 args: CliArgs,
25 config: Config,
26 client: reqwest::Client,
27}
28
29impl SpeedTestOrchestrator {
30 pub fn new(args: CliArgs) -> Result<Self, SpeedtestError> {
32 let config = Config::from_args(&args);
33 let client = http::create_client(&config)?;
34 Ok(Self {
35 args,
36 config,
37 client,
38 })
39 }
40
41 pub async fn run(&self) -> Result<(), SpeedtestError> {
43 if let Some(shell) = self.args.generate_completion {
45 Self::generate_shell_completion(shell);
46 return Ok(());
47 }
48
49 if self.args.history {
51 history::print_history()?;
52 return Ok(());
53 }
54
55 let is_verbose = self.is_verbose();
56
57 if is_verbose {
59 Self::print_header();
60 }
61
62 let servers = self.fetch_and_filter_servers(is_verbose).await?;
64
65 if self.config.list {
67 return Ok(());
68 }
69
70 let server = select_best_server(&servers)?;
72
73 if is_verbose {
75 Self::print_server_info(&server);
76 }
77
78 let client_ip = http::discover_client_ip(&self.client).await.ok();
80
81 let (ping, jitter, packet_loss, ping_samples) =
83 self.run_ping_test(&server, is_verbose).await?;
84
85 let dl_result = self.run_download_test(&server, is_verbose).await?;
87
88 let ul_result = self.run_upload_test(&server, is_verbose).await?;
90
91 let result = TestResult::from_test_runs(
93 types::ServerInfo {
94 id: server.id.clone(),
95 name: server.name.clone(),
96 sponsor: server.sponsor.clone(),
97 country: server.country.clone(),
98 distance: server.distance,
99 },
100 ping,
101 jitter,
102 packet_loss,
103 ping_samples,
104 &dl_result,
105 &ul_result,
106 client_ip,
107 );
108
109 if !self.config.json && !self.config.csv {
111 history::save_result(&result).ok();
112 }
113
114 self.output_results(&result, &dl_result, &ul_result)?;
116
117 Ok(())
118 }
119
120 pub fn is_verbose(&self) -> bool {
122 use crate::cli::OutputFormatType;
123 let format_non_verbose = matches!(
124 self.args.format,
125 Some(OutputFormatType::Simple | OutputFormatType::Json | OutputFormatType::Csv)
126 );
127 !self.config.simple
128 && !self.config.json
129 && !self.config.csv
130 && !self.config.list
131 && !format_non_verbose
132 }
133
134 fn print_header() {
135 eprintln!(
136 "{}",
137 format!(" ═══ NetSpeed CLI v{} ═══", env!("CARGO_PKG_VERSION"))
138 .dimmed()
139 .bold()
140 );
141 eprintln!("{}", " Bandwidth test · speedtest.net".dimmed());
142 eprintln!();
143 }
144
145 fn print_server_info(server: &Server) {
146 let dist = common::format_distance(server.distance);
147 eprintln!();
148 if no_color() {
149 eprintln!(" Server: {} ({})", server.sponsor, server.name);
150 eprintln!(" Location: {} ({dist})", server.country);
151 } else {
152 eprintln!(
153 " {} {} ({})",
154 "Server:".dimmed(),
155 server.sponsor.white().bold(),
156 server.name
157 );
158 eprintln!(" {} {} ({dist})", "Location:".dimmed(), server.country);
159 }
160 eprintln!();
161 }
162
163 async fn fetch_and_filter_servers(
164 &self,
165 is_verbose: bool,
166 ) -> Result<Vec<Server>, SpeedtestError> {
167 let fetch_spinner = if is_verbose {
168 Some(create_spinner("Finding servers..."))
169 } else {
170 None
171 };
172 let mut servers = fetch_servers(&self.client).await?;
173 if let Some(ref pb) = fetch_spinner {
174 finish_ok(pb, &format!("Found {} servers", servers.len()));
175 eprintln!();
176 }
177
178 if self.config.list {
180 format_list(&servers)?;
181 return Ok(Vec::new()); }
183
184 if !self.config.server_ids.is_empty() {
186 servers.retain(|s| self.config.server_ids.contains(&s.id));
187 }
188 if !self.config.exclude_ids.is_empty() {
189 servers.retain(|s| !self.config.exclude_ids.contains(&s.id));
190 }
191
192 if servers.is_empty() {
193 return Err(SpeedtestError::ServerNotFound(
194 "No servers match your criteria. Try running without --server/--exclude filters, or use --list to see available servers.".to_string(),
195 ));
196 }
197
198 Ok(servers)
199 }
200
201 async fn run_ping_test(
202 &self,
203 server: &Server,
204 is_verbose: bool,
205 ) -> Result<(Option<f64>, Option<f64>, Option<f64>, Vec<f64>), SpeedtestError> {
206 if self.config.no_download && self.config.no_upload {
207 return Ok((None, None, None, Vec::new()));
208 }
209
210 let ping_spinner = if is_verbose {
211 Some(create_spinner("Testing latency..."))
212 } else {
213 None
214 };
215 let ping_result = ping_test(&self.client, server).await?;
216 if let Some(ref pb) = ping_spinner {
217 let msg = if no_color() {
218 format!("Latency: {:.2} ms", ping_result.0)
219 } else {
220 format!(
221 "Latency: {}",
222 format!("{:.2} ms", ping_result.0).cyan().bold()
223 )
224 };
225 finish_ok(pb, &msg);
226 }
227 Ok((
228 Some(ping_result.0),
229 Some(ping_result.1),
230 Some(ping_result.2),
231 ping_result.3,
232 ))
233 }
234
235 async fn run_download_test(
236 &self,
237 server: &Server,
238 is_verbose: bool,
239 ) -> Result<TestRunResult, SpeedtestError> {
240 if self.config.no_download {
241 return Ok(TestRunResult::default());
242 }
243
244 test_runner::run_bandwidth_test(
245 &self.config,
246 server,
247 "Download",
248 is_verbose,
249 |progress| async {
250 download::download_test(&self.client, server, self.config.single, progress).await
251 },
252 )
253 .await
254 }
255
256 async fn run_upload_test(
257 &self,
258 server: &Server,
259 is_verbose: bool,
260 ) -> Result<TestRunResult, SpeedtestError> {
261 if self.config.no_upload {
262 return Ok(TestRunResult::default());
263 }
264
265 test_runner::run_bandwidth_test(
266 &self.config,
267 server,
268 "Upload",
269 is_verbose,
270 |progress| async {
271 upload::upload_test(&self.client, server, self.config.single, progress).await
272 },
273 )
274 .await
275 }
276
277 fn output_results(
278 &self,
279 result: &TestResult,
280 dl_result: &TestRunResult,
281 ul_result: &TestRunResult,
282 ) -> Result<(), SpeedtestError> {
283 use crate::cli::OutputFormatType;
284
285 let output_format = match self.args.format {
287 Some(OutputFormatType::Json) => OutputFormat::Json,
288 Some(OutputFormatType::Csv) => OutputFormat::Csv {
289 delimiter: self.config.csv_delimiter,
290 header: self.config.csv_header,
291 },
292 Some(OutputFormatType::Simple) => OutputFormat::Simple,
293 Some(OutputFormatType::Detailed) => OutputFormat::Detailed {
294 dl_bytes: dl_result.total_bytes,
295 ul_bytes: ul_result.total_bytes,
296 dl_duration: dl_result.duration_secs,
297 ul_duration: ul_result.duration_secs,
298 },
299 None => {
300 if self.config.json {
302 OutputFormat::Json
303 } else if self.config.csv {
304 OutputFormat::Csv {
305 delimiter: self.config.csv_delimiter,
306 header: self.config.csv_header,
307 }
308 } else if self.config.simple {
309 OutputFormat::Simple
310 } else {
311 OutputFormat::Detailed {
312 dl_bytes: dl_result.total_bytes,
313 ul_bytes: ul_result.total_bytes,
314 dl_duration: dl_result.duration_secs,
315 ul_duration: ul_result.duration_secs,
316 }
317 }
318 }
319 };
320 output_format.format(result, self.config.bytes)?;
321
322 Ok(())
323 }
324
325 fn generate_shell_completion(shell: ShellType) {
326 use clap::CommandFactory;
327 use clap_complete::{Shell as CompleteShell, generate};
328 use std::io;
329
330 let shell_type = match shell {
331 ShellType::Bash => CompleteShell::Bash,
332 ShellType::Zsh => CompleteShell::Zsh,
333 ShellType::Fish => CompleteShell::Fish,
334 ShellType::PowerShell => CompleteShell::PowerShell,
335 ShellType::Elvish => CompleteShell::Elvish,
336 };
337
338 let mut cmd = CliArgs::command();
339 let bin_name = "netspeed-cli";
340 generate(shell_type, &mut cmd, bin_name, &mut io::stdout());
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::cli::CliArgs;
348 use clap::Parser;
349
350 #[test]
351 fn test_is_verbose_default() {
352 let args = CliArgs::parse_from(["netspeed-cli"]);
353 let orch = SpeedTestOrchestrator::new(args).unwrap();
354 assert!(orch.is_verbose());
355 }
356
357 #[test]
358 fn test_is_verbose_simple() {
359 let args = CliArgs::parse_from(["netspeed-cli", "--simple"]);
360 let orch = SpeedTestOrchestrator::new(args).unwrap();
361 assert!(!orch.is_verbose());
362 }
363
364 #[test]
365 fn test_is_verbose_json() {
366 let args = CliArgs::parse_from(["netspeed-cli", "--json"]);
367 let orch = SpeedTestOrchestrator::new(args).unwrap();
368 assert!(!orch.is_verbose());
369 }
370
371 #[test]
372 fn test_is_verbose_csv() {
373 let args = CliArgs::parse_from(["netspeed-cli", "--csv"]);
374 let orch = SpeedTestOrchestrator::new(args).unwrap();
375 assert!(!orch.is_verbose());
376 }
377
378 #[test]
379 fn test_is_verbose_list() {
380 let args = CliArgs::parse_from(["netspeed-cli", "--list"]);
381 let orch = SpeedTestOrchestrator::new(args).unwrap();
382 assert!(!orch.is_verbose());
383 }
384
385 #[test]
386 fn test_orchestrator_creation() {
387 let args = CliArgs::parse_from(["netspeed-cli"]);
388 let orch = SpeedTestOrchestrator::new(args);
389 assert!(orch.is_ok());
390 }
391
392 #[test]
393 fn test_orchestrator_creation_default() {
394 let args = CliArgs::parse_from(["netspeed-cli"]);
396 let orch = SpeedTestOrchestrator::new(args);
397 assert!(orch.is_ok());
398 }
399}