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 if self.config.quiet {
125 return false;
126 }
127 let format_non_verbose = matches!(
128 self.args.format,
129 Some(
130 OutputFormatType::Simple
131 | OutputFormatType::Json
132 | OutputFormatType::Csv
133 | OutputFormatType::Dashboard
134 )
135 );
136 !self.config.simple
137 && !self.config.json
138 && !self.config.csv
139 && !self.config.list
140 && !format_non_verbose
141 }
142
143 fn print_header() {
144 eprintln!(
145 "{}",
146 format!(" ═══ NetSpeed CLI v{} ═══", env!("CARGO_PKG_VERSION"))
147 .dimmed()
148 .bold()
149 );
150 eprintln!("{}", " Bandwidth test · speedtest.net".dimmed());
151 eprintln!();
152 }
153
154 fn print_server_info(server: &Server) {
155 let dist = common::format_distance(server.distance);
156 eprintln!();
157 if no_color() {
158 eprintln!(" Server: {} ({})", server.sponsor, server.name);
159 eprintln!(" Location: {} ({dist})", server.country);
160 } else {
161 eprintln!(
162 " {} {} ({})",
163 "Server:".dimmed(),
164 server.sponsor.white().bold(),
165 server.name
166 );
167 eprintln!(" {} {} ({dist})", "Location:".dimmed(), server.country);
168 }
169 eprintln!();
170 }
171
172 async fn fetch_and_filter_servers(
173 &self,
174 is_verbose: bool,
175 ) -> Result<Vec<Server>, SpeedtestError> {
176 let fetch_spinner = if is_verbose {
177 Some(create_spinner("Finding servers..."))
178 } else {
179 None
180 };
181 let mut servers = fetch_servers(&self.client).await?;
182 if let Some(ref pb) = fetch_spinner {
183 finish_ok(pb, &format!("Found {} servers", servers.len()));
184 eprintln!();
185 }
186
187 if self.config.list {
189 format_list(&servers)?;
190 return Ok(Vec::new()); }
192
193 if !self.config.server_ids.is_empty() {
195 servers.retain(|s| self.config.server_ids.contains(&s.id));
196 }
197 if !self.config.exclude_ids.is_empty() {
198 servers.retain(|s| !self.config.exclude_ids.contains(&s.id));
199 }
200
201 if servers.is_empty() {
202 return Err(SpeedtestError::ServerNotFound(
203 "No servers match your criteria. Try running without --server/--exclude filters, or use --list to see available servers.".to_string(),
204 ));
205 }
206
207 Ok(servers)
208 }
209
210 async fn run_ping_test(
211 &self,
212 server: &Server,
213 is_verbose: bool,
214 ) -> Result<(Option<f64>, Option<f64>, Option<f64>, Vec<f64>), SpeedtestError> {
215 if self.config.no_download && self.config.no_upload {
216 return Ok((None, None, None, Vec::new()));
217 }
218
219 let ping_spinner = if is_verbose {
220 Some(create_spinner("Testing latency..."))
221 } else {
222 None
223 };
224 let ping_result = ping_test(&self.client, server).await?;
225 if let Some(ref pb) = ping_spinner {
226 let msg = if no_color() {
227 format!("Latency: {:.2} ms", ping_result.0)
228 } else {
229 format!(
230 "Latency: {}",
231 format!("{:.2} ms", ping_result.0).cyan().bold()
232 )
233 };
234 finish_ok(pb, &msg);
235 }
236 Ok((
237 Some(ping_result.0),
238 Some(ping_result.1),
239 Some(ping_result.2),
240 ping_result.3,
241 ))
242 }
243
244 async fn run_download_test(
245 &self,
246 server: &Server,
247 is_verbose: bool,
248 ) -> Result<TestRunResult, SpeedtestError> {
249 if self.config.no_download {
250 return Ok(TestRunResult::default());
251 }
252
253 test_runner::run_bandwidth_test(
254 &self.config,
255 server,
256 "Download",
257 is_verbose,
258 |progress| async {
259 download::download_test(&self.client, server, self.config.single, progress).await
260 },
261 )
262 .await
263 }
264
265 async fn run_upload_test(
266 &self,
267 server: &Server,
268 is_verbose: bool,
269 ) -> Result<TestRunResult, SpeedtestError> {
270 if self.config.no_upload {
271 return Ok(TestRunResult::default());
272 }
273
274 test_runner::run_bandwidth_test(
275 &self.config,
276 server,
277 "Upload",
278 is_verbose,
279 |progress| async {
280 upload::upload_test(&self.client, server, self.config.single, progress).await
281 },
282 )
283 .await
284 }
285
286 fn output_results(
287 &self,
288 result: &TestResult,
289 dl_result: &TestRunResult,
290 ul_result: &TestRunResult,
291 ) -> Result<(), SpeedtestError> {
292 use crate::cli::OutputFormatType;
293
294 let output_format = match self.args.format {
296 Some(OutputFormatType::Json) => OutputFormat::Json,
297 Some(OutputFormatType::Csv) => OutputFormat::Csv {
298 delimiter: self.config.csv_delimiter,
299 header: self.config.csv_header,
300 },
301 Some(OutputFormatType::Simple) => OutputFormat::Simple,
302 Some(OutputFormatType::Dashboard) => OutputFormat::Dashboard {
303 dl_mbps: dl_result.avg_bps / 1_000_000.0,
304 dl_peak_mbps: dl_result.peak_bps / 1_000_000.0,
305 dl_bytes: dl_result.total_bytes,
306 dl_duration: dl_result.duration_secs,
307 ul_mbps: ul_result.avg_bps / 1_000_000.0,
308 ul_peak_mbps: ul_result.peak_bps / 1_000_000.0,
309 ul_bytes: ul_result.total_bytes,
310 ul_duration: ul_result.duration_secs,
311 },
312 Some(OutputFormatType::Detailed) => OutputFormat::Detailed {
313 dl_bytes: dl_result.total_bytes,
314 ul_bytes: ul_result.total_bytes,
315 dl_duration: dl_result.duration_secs,
316 ul_duration: ul_result.duration_secs,
317 dl_skipped: self.config.no_download,
318 ul_skipped: self.config.no_upload,
319 },
320 None => {
321 if self.config.json {
323 OutputFormat::Json
324 } else if self.config.csv {
325 OutputFormat::Csv {
326 delimiter: self.config.csv_delimiter,
327 header: self.config.csv_header,
328 }
329 } else if self.config.simple {
330 OutputFormat::Simple
331 } else {
332 OutputFormat::Detailed {
333 dl_bytes: dl_result.total_bytes,
334 ul_bytes: ul_result.total_bytes,
335 dl_duration: dl_result.duration_secs,
336 ul_duration: ul_result.duration_secs,
337 dl_skipped: self.config.no_download,
338 ul_skipped: self.config.no_upload,
339 }
340 }
341 }
342 };
343 output_format.format(result, self.config.bytes)?;
344
345 Ok(())
346 }
347
348 fn generate_shell_completion(shell: ShellType) {
349 use clap::CommandFactory;
350 use clap_complete::{Shell as CompleteShell, generate};
351 use std::io;
352
353 let shell_type = match shell {
354 ShellType::Bash => CompleteShell::Bash,
355 ShellType::Zsh => CompleteShell::Zsh,
356 ShellType::Fish => CompleteShell::Fish,
357 ShellType::PowerShell => CompleteShell::PowerShell,
358 ShellType::Elvish => CompleteShell::Elvish,
359 };
360
361 let mut cmd = CliArgs::command();
362 let bin_name = "netspeed-cli";
363 generate(shell_type, &mut cmd, bin_name, &mut io::stdout());
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::cli::CliArgs;
371 use clap::Parser;
372
373 #[test]
374 fn test_is_verbose_default() {
375 let args = CliArgs::parse_from(["netspeed-cli"]);
376 let orch = SpeedTestOrchestrator::new(args).unwrap();
377 assert!(orch.is_verbose());
378 }
379
380 #[test]
381 fn test_is_verbose_simple() {
382 let args = CliArgs::parse_from(["netspeed-cli", "--simple"]);
383 let orch = SpeedTestOrchestrator::new(args).unwrap();
384 assert!(!orch.is_verbose());
385 }
386
387 #[test]
388 fn test_is_verbose_json() {
389 let args = CliArgs::parse_from(["netspeed-cli", "--json"]);
390 let orch = SpeedTestOrchestrator::new(args).unwrap();
391 assert!(!orch.is_verbose());
392 }
393
394 #[test]
395 fn test_is_verbose_csv() {
396 let args = CliArgs::parse_from(["netspeed-cli", "--csv"]);
397 let orch = SpeedTestOrchestrator::new(args).unwrap();
398 assert!(!orch.is_verbose());
399 }
400
401 #[test]
402 fn test_is_verbose_list() {
403 let args = CliArgs::parse_from(["netspeed-cli", "--list"]);
404 let orch = SpeedTestOrchestrator::new(args).unwrap();
405 assert!(!orch.is_verbose());
406 }
407
408 #[test]
409 fn test_orchestrator_creation() {
410 let args = CliArgs::parse_from(["netspeed-cli"]);
411 let orch = SpeedTestOrchestrator::new(args);
412 assert!(orch.is_ok());
413 }
414
415 #[test]
416 fn test_orchestrator_creation_default() {
417 let args = CliArgs::parse_from(["netspeed-cli"]);
419 let orch = SpeedTestOrchestrator::new(args);
420 assert!(orch.is_ok());
421 }
422}