Skip to main content

netspeed_cli/
orchestrator.rs

1//! Orchestrates the full speed test lifecycle.
2//!
3//! Delegates phase execution to the [`phases`](crate::phases) module,
4//! which provides OCP via function-based phase definitions.
5
6use crate::config::{Config, ConfigProvider, ConfigSource};
7
8use crate::phase_runner::{DefaultPhaseRunner, PhaseRunner};
9use crate::result_processor::{DefaultResultProcessor, ResultProcessor};
10
11use crate::error::Error;
12use crate::http;
13// HttpClient and ReqwestClient are injected via DI; no direct import needed
14use crate::profiles::UserProfile;
15use crate::storage::{LoadHistory, SaveResult};
16use crate::task_runner::TestRunResult;
17use crate::terminal;
18use crate::types::TestResult;
19
20/// Early-exit flags extracted from Args — these control flow, not configuration.
21#[derive(Clone)]
22pub(crate) struct EarlyExitFlags {
23    pub(crate) show_config_path: bool,
24    pub(crate) generate_completion: Option<crate::cli::ShellType>,
25    pub(crate) history: bool,
26    pub(crate) dry_run: bool,
27}
28
29impl EarlyExitFlags {
30    pub(crate) fn from_args(args: &crate::cli::Args) -> Self {
31        Self {
32            show_config_path: args.show_config_path,
33            generate_completion: args.generate_completion,
34            history: args.history,
35            dry_run: args.dry_run,
36        }
37    }
38}
39
40/// Builder for storage components - enables dependency injection.
41pub struct StorageBuilder {
42    saver: Option<std::sync::Arc<dyn SaveResult + Send + Sync>>,
43    history: Option<std::sync::Arc<dyn LoadHistory + Send + Sync>>,
44}
45
46impl StorageBuilder {
47    pub fn new() -> Self {
48        Self {
49            saver: None,
50            history: None,
51        }
52    }
53
54    pub fn with_saver(mut self, saver: impl SaveResult + 'static) -> Self {
55        self.saver = Some(std::sync::Arc::new(saver));
56        self
57    }
58
59    pub fn with_saver_arc(mut self, saver: std::sync::Arc<dyn SaveResult + Send + Sync>) -> Self {
60        self.saver = Some(saver);
61        self
62    }
63
64    pub fn with_history(mut self, history: impl LoadHistory + 'static) -> Self {
65        self.history = Some(std::sync::Arc::new(history));
66        self
67    }
68
69    pub fn with_history_arc(
70        mut self,
71        history: std::sync::Arc<dyn LoadHistory + Send + Sync>,
72    ) -> Self {
73        self.history = Some(history);
74        self
75    }
76
77    /// Build storage components, defaulting to FileStorage if not provided.
78    fn build(
79        self,
80    ) -> (
81        std::sync::Arc<dyn SaveResult + Send + Sync>,
82        std::sync::Arc<dyn LoadHistory + Send + Sync>,
83    ) {
84        let saver = self.saver.unwrap_or_else(|| {
85            std::sync::Arc::new(crate::storage::FileStorage::new())
86                as std::sync::Arc<dyn SaveResult + Send + Sync>
87        });
88        let history = self.history.unwrap_or_else(|| {
89            std::sync::Arc::new(crate::storage::FileStorage::new())
90                as std::sync::Arc<dyn LoadHistory + Send + Sync>
91        });
92        (saver, history)
93    }
94}
95
96impl Default for StorageBuilder {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102/// Orchestrates the full speed test lifecycle.
103///
104/// A thin wrapper that holds configuration/resources and delegates
105/// phase execution to the phases module.
106pub struct Orchestrator {
107    pub(crate) config: std::sync::Arc<dyn ConfigProvider>,
108    pub(crate) client: reqwest::Client,
109
110    early_exit: EarlyExitFlags,
111    saver: std::sync::Arc<dyn SaveResult + Send + Sync>,
112    history: std::sync::Arc<dyn LoadHistory + Send + Sync>,
113    processor: std::sync::Arc<dyn ResultProcessor + Send + Sync>,
114
115    phase_runner: std::sync::Arc<dyn PhaseRunner + Send + Sync>,
116    services: std::sync::Arc<dyn crate::services::Services>,
117}
118
119impl Orchestrator {
120    // Internal shortcut to the underlying Config
121    fn cfg(&self) -> &Config {
122        self.config.config()
123    }
124
125    /// Create a new orchestrator from CLI arguments.
126    pub fn new(
127        args: crate::cli::Args,
128        file_config: Option<crate::config::File>,
129    ) -> Result<Self, Error> {
130        let source = ConfigSource::from_args(&args);
131
132        let (config, profile_validation) =
133            Config::from_args_with_file(&source, file_config.clone());
134
135        for warning in &profile_validation.warnings {
136            eprintln!("Warning: {warning}");
137        }
138
139        let file_validation = config.validate_and_report(&source, file_config);
140        for error in &file_validation.errors {
141            eprintln!("Error: {error}");
142        }
143
144        let combined_valid = profile_validation.valid && file_validation.valid;
145        if config.strict() && !combined_valid {
146            return Err(Error::Context {
147                msg: "Configuration validation failed".to_string(),
148                source: None,
149            });
150        }
151
152        let early_exit = EarlyExitFlags::from_args(&args);
153        Self::from_config(config, early_exit)
154    }
155
156    /// Create an orchestrator from pre-built config.
157    pub(crate) fn from_config(config: Config, early_exit: EarlyExitFlags) -> Result<Self, Error> {
158        Self::from_config_with_storage(config, early_exit, StorageBuilder::new())
159    }
160
161    /// Create an orchestrator from pre-built config with custom storage.
162    pub(crate) fn from_config_with_storage(
163        config: Config,
164        early_exit: EarlyExitFlags,
165        storage: StorageBuilder,
166    ) -> Result<Self, Error> {
167        let http_settings = http::Settings::from(&config);
168        let client = http::create_client(&http_settings)?;
169
170        let (saver, history) = storage.build();
171        let services = std::sync::Arc::new(crate::services::ServiceContainer::new(client.clone()));
172
173        Ok(Self {
174            config: std::sync::Arc::new(config),
175            client,
176
177            early_exit,
178            saver,
179            history,
180            processor: std::sync::Arc::new(DefaultResultProcessor),
181            phase_runner: std::sync::Arc::new(DefaultPhaseRunner::new()),
182            services,
183        })
184    }
185
186    /// Access the service container.
187    #[must_use]
188    pub fn services(&self) -> &dyn crate::services::Services {
189        self.services.as_ref()
190    }
191
192    /// Clone the services Arc for creating PhaseContext.
193    pub fn services_arc(&self) -> std::sync::Arc<dyn crate::services::Services> {
194        self.services.clone()
195    }
196
197    /// Run the full speed test workflow.
198    pub async fn run(&self) -> Result<(), Error> {
199        self.phase_runner.run_all(self).await
200    }
201
202    /// Whether verbose output should be shown.
203    #[must_use]
204    pub fn is_verbose(&self) -> bool {
205        if self.cfg().quiet() {
206            return false;
207        }
208        let format_non_verbose = self.cfg().format().is_some_and(|f| f.is_non_verbose());
209        !self.cfg().simple()
210            && !self.cfg().json()
211            && !self.cfg().csv()
212            && !self.cfg().list()
213            && !format_non_verbose
214    }
215
216    /// Check if this is a simple/quiet mode.
217    #[must_use]
218    pub fn is_simple_mode(&self) -> bool {
219        self.cfg().simple()
220            || self.cfg().quiet()
221            || self.cfg().format() == Some(crate::config::Format::Simple)
222    }
223
224    /// Access the configuration (read-only).
225    #[must_use]
226    pub fn config(&self) -> &Config {
227        self.cfg()
228    }
229
230    /// Access early-exit flags.
231    #[must_use]
232    pub(crate) fn early_exit(&self) -> &EarlyExitFlags {
233        &self.early_exit
234    }
235
236    /// Access result saver (for persisting a result).
237    #[must_use]
238    pub fn saver(&self) -> &dyn SaveResult {
239        self.saver.as_ref()
240    }
241
242    /// Access history provider (optional).
243    #[must_use]
244    pub fn history(&self) -> &dyn LoadHistory {
245        self.history.as_ref()
246    }
247
248    /// Access the HTTP client for async operations.
249    pub fn http_client(&self) -> &reqwest::Client {
250        &self.client
251    }
252
253    /// Output results after test completion.
254    pub(crate) fn output_results(
255        &self,
256        result: &mut TestResult,
257        dl_result: &TestRunResult,
258        ul_result: &TestRunResult,
259        elapsed: std::time::Duration,
260    ) -> Result<(), Error> {
261        let profile = UserProfile::from_name(self.cfg().profile().unwrap_or("power-user"))
262            .unwrap_or(UserProfile::PowerUser);
263        // Grade results via injected processor (OCP)
264        self.processor.process(result, profile);
265
266        let output_format = crate::output_strategy::resolve_output_format(
267            self.cfg(),
268            dl_result,
269            ul_result,
270            elapsed,
271        );
272
273        if self.is_verbose() {
274            self.reveal_results(result, self.cfg().theme(), profile);
275        }
276
277        output_format.format(result, self.cfg().bytes())?;
278        Ok(())
279    }
280
281    /// Show the scan completion reveal before outputting detailed results.
282    fn reveal_results(
283        &self,
284        result: &TestResult,
285        theme: crate::theme::Theme,
286        profile: UserProfile,
287    ) {
288        let nc = terminal::no_color();
289
290        let sample_count = result.download_samples.as_ref().map_or(0, Vec::len)
291            + result.upload_samples.as_ref().map_or(0, Vec::len)
292            + result.ping_samples.as_ref().map_or(0, Vec::len);
293
294        let overall_grade = crate::grades::grade_overall(
295            result.ping,
296            result.jitter,
297            result.download,
298            result.upload,
299            profile,
300        );
301
302        let grade_badge = overall_grade.color_str(nc, theme);
303        let grade_plain = overall_grade.as_str().to_string();
304        crate::progress::reveal_scan_complete(sample_count, &grade_badge, &grade_plain, nc, theme);
305        crate::progress::reveal_pause();
306    }
307
308    fn print_kv(nc: bool, key: &str, value: &str) {
309        if nc {
310            eprintln!("  {key}: {value}");
311        } else {
312            use owo_colors::OwoColorize;
313            eprintln!("  {}: {}", key.dimmed(), value.cyan());
314        }
315    }
316
317    /// Validate configuration and print confirmation without running tests.
318    pub(crate) fn run_dry_run(&self) {
319        let nc = terminal::no_color();
320        let config = self.config();
321
322        if nc {
323            eprintln!("Configuration valid:");
324        } else {
325            use owo_colors::OwoColorize;
326            eprintln!("{}", "Configuration valid:".green().bold());
327        }
328
329        Self::print_kv(nc, "Timeout", &format!("{}s", config.timeout()));
330        Self::print_kv(nc, "Format", self.format_description());
331        if config.quiet() {
332            Self::print_kv(nc, "Quiet", "enabled");
333        }
334        if let Some(source) = config.source() {
335            Self::print_kv(nc, "Source IP", source);
336        }
337        if config.no_download() {
338            Self::print_kv(nc, "Download test", "disabled");
339        }
340        if config.no_upload() {
341            Self::print_kv(nc, "Upload test", "disabled");
342        }
343        if config.single() {
344            Self::print_kv(nc, "Streams", "single");
345        }
346        if let Some(ca_cert) = config.ca_cert() {
347            Self::print_kv(nc, "CA cert", ca_cert);
348        }
349        if let Some(tls_version) = config.tls_version() {
350            Self::print_kv(nc, "TLS version", tls_version);
351        }
352        if config.pin_certs() {
353            Self::print_kv(nc, "TLS domain restriction", "speedtest.net/ookla.com");
354        }
355
356        if nc {
357            eprintln!("\nDry run complete. Run without --dry-run to perform speed test.");
358        } else {
359            use owo_colors::OwoColorize;
360            eprintln!(
361                "\n{}",
362                "Dry run complete. Run without --dry-run to perform speed test.".bright_black()
363            );
364        }
365    }
366
367    /// Return a human-readable description of the output format.
368    fn format_description(&self) -> &'static str {
369        match self.cfg().format() {
370            Some(f) => f.label(),
371            None => "Detailed (default)",
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::config::{ConfigSource, OutputSource};
380
381    fn orch_from_source(source: &ConfigSource, early_exit: EarlyExitFlags) -> Orchestrator {
382        let config = Config::from_source(source);
383        Orchestrator::from_config(config, early_exit).unwrap()
384    }
385
386    fn default_early_exit() -> EarlyExitFlags {
387        EarlyExitFlags {
388            show_config_path: false,
389            generate_completion: None,
390            history: false,
391            dry_run: false,
392        }
393    }
394
395    #[test]
396    fn test_orchestrator_creation() {
397        let source = ConfigSource::default();
398        let config = Config::from_source(&source);
399        let orch = Orchestrator::from_config(config, default_early_exit());
400        assert!(orch.is_ok());
401    }
402
403    #[test]
404    fn test_is_verbose_default() {
405        let source = ConfigSource::default();
406        let orch = orch_from_source(&source, default_early_exit());
407        assert!(orch.is_verbose());
408    }
409
410    #[test]
411    fn test_is_verbose_quiet() {
412        let source = ConfigSource {
413            output: OutputSource {
414                quiet: Some(true),
415                ..Default::default()
416            },
417            ..Default::default()
418        };
419        let orch = orch_from_source(&source, default_early_exit());
420        assert!(!orch.is_verbose());
421    }
422
423    #[test]
424    fn test_is_simple_mode_default() {
425        let source = ConfigSource::default();
426        let orch = orch_from_source(&source, default_early_exit());
427        assert!(!orch.is_simple_mode());
428    }
429
430    #[test]
431    fn test_is_simple_mode_simple() {
432        let source = ConfigSource {
433            output: OutputSource {
434                simple: Some(true),
435                ..Default::default()
436            },
437            ..Default::default()
438        };
439        let orch = orch_from_source(&source, default_early_exit());
440        assert!(orch.is_simple_mode());
441    }
442
443    #[test]
444    fn test_dry_run_succeeds() {
445        let source = ConfigSource::default();
446        let early_exit = EarlyExitFlags {
447            dry_run: true,
448            ..default_early_exit()
449        };
450        let orch = orch_from_source(&source, early_exit);
451        orch.run_dry_run();
452    }
453
454    #[test]
455    fn test_early_exit_flags_default() {
456        let flags = default_early_exit();
457        assert!(!flags.show_config_path);
458        assert!(flags.generate_completion.is_none());
459        assert!(!flags.history);
460        assert!(!flags.dry_run);
461    }
462
463    #[test]
464    fn test_storage_builder_defaults() {
465        let shared = std::sync::Arc::new(crate::storage::MockStorage::new());
466        let saver = shared.clone() as std::sync::Arc<dyn crate::storage::SaveResult + Send + Sync>;
467        let history =
468            shared.clone() as std::sync::Arc<dyn crate::storage::LoadHistory + Send + Sync>;
469
470        let builder = StorageBuilder::new()
471            .with_saver_arc(saver)
472            .with_history_arc(history);
473        let (saver, history) = builder.build();
474        <dyn crate::storage::SaveResult>::save(&*saver, &crate::types::TestResult::default())
475            .unwrap();
476        let _ = <dyn crate::storage::LoadHistory>::load_recent(&*history, 1);
477    }
478
479    #[test]
480    fn test_storage_builder_custom() {
481        let shared = std::sync::Arc::new(crate::storage::MockStorage::new());
482        let saver = shared.clone() as std::sync::Arc<dyn crate::storage::SaveResult + Send + Sync>;
483        let history =
484            shared.clone() as std::sync::Arc<dyn crate::storage::LoadHistory + Send + Sync>;
485
486        let builder = StorageBuilder::new()
487            .with_saver_arc(saver)
488            .with_history_arc(history);
489
490        let (saver, history) = builder.build();
491
492        let result = crate::types::TestResult::default();
493        <dyn crate::storage::SaveResult>::save(&*saver, &result).unwrap();
494        let loaded = <dyn crate::storage::LoadHistory>::load_recent(&*history, 10).unwrap();
495        assert_eq!(loaded.len(), 1);
496    }
497
498    #[test]
499    fn test_orchestrator_exposes_services() {
500        let args = crate::cli::Args::default();
501        let orch = Orchestrator::new(args, None).unwrap();
502        let _services = orch.services();
503    }
504
505    // run_dry_run branch coverage — one test per conditional field.
506    // We don't capture stderr; the goal is to exercise each branch without panic.
507    fn dry_run_orch(
508        output: OutputSource,
509        test: crate::config::TestSource,
510        network: crate::config::NetworkSource,
511    ) -> Orchestrator {
512        let source = ConfigSource {
513            output,
514            test,
515            network,
516            ..Default::default()
517        };
518        orch_from_source(
519            &source,
520            EarlyExitFlags {
521                dry_run: true,
522                ..default_early_exit()
523            },
524        )
525    }
526
527    #[test]
528    fn test_dry_run_no_color_mode() {
529        // NO_COLOR is set by the serial test suite; exercise the nc=true branch explicitly
530        let orch = dry_run_orch(Default::default(), Default::default(), Default::default());
531        orch.run_dry_run(); // must not panic
532    }
533
534    #[test]
535    fn test_dry_run_quiet_branch() {
536        let orch = dry_run_orch(
537            OutputSource {
538                quiet: Some(true),
539                ..Default::default()
540            },
541            Default::default(),
542            Default::default(),
543        );
544        orch.run_dry_run();
545    }
546
547    #[test]
548    fn test_dry_run_no_download_branch() {
549        let orch = dry_run_orch(
550            Default::default(),
551            crate::config::TestSource {
552                no_download: Some(true),
553                ..Default::default()
554            },
555            Default::default(),
556        );
557        orch.run_dry_run();
558    }
559
560    #[test]
561    fn test_dry_run_no_upload_branch() {
562        let orch = dry_run_orch(
563            Default::default(),
564            crate::config::TestSource {
565                no_upload: Some(true),
566                ..Default::default()
567            },
568            Default::default(),
569        );
570        orch.run_dry_run();
571    }
572
573    #[test]
574    fn test_dry_run_single_stream_branch() {
575        let orch = dry_run_orch(
576            Default::default(),
577            crate::config::TestSource {
578                single: Some(true),
579                ..Default::default()
580            },
581            Default::default(),
582        );
583        orch.run_dry_run();
584    }
585
586    #[test]
587    #[ignore = "requires a bound local IP; tested in http::tests"]
588    fn test_dry_run_source_ip_branch() {
589        let orch = dry_run_orch(
590            Default::default(),
591            Default::default(),
592            crate::config::NetworkSource {
593                source: Some("127.0.0.1:0".to_string()),
594                ..Default::default()
595            },
596        );
597        orch.run_dry_run();
598    }
599
600    #[test]
601    #[ignore = "requires Rustls CryptoProvider; tested in http::tests"]
602    fn test_dry_run_tls_version_branch() {
603        let orch = dry_run_orch(
604            Default::default(),
605            Default::default(),
606            crate::config::NetworkSource {
607                tls_version: Some("1.3".to_string()),
608                ..Default::default()
609            },
610        );
611        orch.run_dry_run();
612    }
613
614    #[test]
615    #[ignore = "requires Rustls CryptoProvider; tested in http::tests"]
616    fn test_dry_run_pin_certs_branch() {
617        let orch = dry_run_orch(
618            Default::default(),
619            Default::default(),
620            crate::config::NetworkSource {
621                pin_certs: Some(true),
622                ..Default::default()
623            },
624        );
625        orch.run_dry_run();
626    }
627}