Skip to main content

netspeed_cli/
orchestrator.rs

1//! Orchestrates the full speed test lifecycle.
2//!
3//! Extracted from `main.rs` to follow single-responsibility and enable
4//! unit testing of the test flow independent of the binary entry point.
5
6use 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
22/// Orchestrates the full speed test lifecycle.
23pub struct SpeedTestOrchestrator {
24    args: CliArgs,
25    config: Config,
26    client: reqwest::Client,
27}
28
29impl SpeedTestOrchestrator {
30    /// Create a new orchestrator from CLI arguments.
31    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    /// Run the full speed test workflow.
42    pub async fn run(&self) -> Result<(), SpeedtestError> {
43        // Shell completion early-exit
44        if let Some(shell) = self.args.generate_completion {
45            Self::generate_shell_completion(shell);
46            return Ok(());
47        }
48
49        // History display early-exit
50        if self.args.history {
51            history::print_history()?;
52            return Ok(());
53        }
54
55        let is_verbose = self.is_verbose();
56
57        // Print header
58        if is_verbose {
59            Self::print_header();
60        }
61
62        // Fetch and filter servers
63        let servers = self.fetch_and_filter_servers(is_verbose).await?;
64
65        // Handle --list: format_list already printed, signal completion
66        if self.config.list {
67            return Ok(());
68        }
69
70        // Select best server
71        let server = select_best_server(&servers)?;
72
73        // Server info
74        if is_verbose {
75            Self::print_server_info(&server);
76        }
77
78        // Discover client IP
79        let client_ip = http::discover_client_ip(&self.client).await.ok();
80
81        // Run ping test
82        let (ping, jitter, packet_loss, ping_samples) =
83            self.run_ping_test(&server, is_verbose).await?;
84
85        // Run download test
86        let dl_result = self.run_download_test(&server, is_verbose).await?;
87
88        // Run upload test
89        let ul_result = self.run_upload_test(&server, is_verbose).await?;
90
91        // Build result
92        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        // Save to history (unless --json or --csv)
110        if !self.config.json && !self.config.csv {
111            history::save_result(&result).ok();
112        }
113
114        // Output — Strategy pattern dispatch
115        self.output_results(&result, &dl_result, &ul_result)?;
116
117        Ok(())
118    }
119
120    /// Whether verbose output should be shown.
121    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        // Handle --list option
179        if self.config.list {
180            format_list(&servers)?;
181            return Ok(Vec::new()); // caller checks config.list
182        }
183
184        // Filter servers
185        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        // --format flag takes precedence over legacy --json/--csv/--simple booleans
286        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                // Legacy boolean flag fallback
301                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        // Default args (no source IP) should always create successfully
395        let args = CliArgs::parse_from(["netspeed-cli"]);
396        let orch = SpeedTestOrchestrator::new(args);
397        assert!(orch.is_ok());
398    }
399}