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        // Quiet mode suppresses all stderr output
124        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        // Handle --list option
188        if self.config.list {
189            format_list(&servers)?;
190            return Ok(Vec::new()); // caller checks config.list
191        }
192
193        // Filter servers
194        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        // --format flag takes precedence over legacy --json/--csv/--simple booleans
295        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                // Legacy boolean flag fallback
322                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        // Default args (no source IP) should always create successfully
418        let args = CliArgs::parse_from(["netspeed-cli"]);
419        let orch = SpeedTestOrchestrator::new(args);
420        assert!(orch.is_ok());
421    }
422}