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    /// Validate configuration and print confirmation without running tests.
309    pub(crate) fn run_dry_run(&self) {
310        let nc = terminal::no_color();
311        let config = self.config();
312
313        if nc {
314            eprintln!("Configuration valid:");
315            eprintln!("  Timeout: {}s", config.timeout());
316            eprintln!("  Format: {}", self.format_description());
317            if config.quiet() {
318                eprintln!("  Quiet: enabled");
319            }
320            if let Some(source) = config.source() {
321                eprintln!("  Source IP: {source}");
322            }
323            if config.no_download() {
324                eprintln!("  Download test: disabled");
325            }
326            if config.no_upload() {
327                eprintln!("  Upload test: disabled");
328            }
329            if config.single() {
330                eprintln!("  Streams: single");
331            }
332            if let Some(ca_cert) = config.ca_cert() {
333                eprintln!("  CA cert: {ca_cert}");
334            }
335            if let Some(tls_version) = config.tls_version() {
336                eprintln!("  TLS version: {tls_version}");
337            }
338            if config.pin_certs() {
339                eprintln!("  Cert pinning: enabled");
340            }
341            eprintln!("\nDry run complete. Run without --dry-run to perform speed test.");
342        } else {
343            use owo_colors::OwoColorize;
344
345            eprintln!("{}", "Configuration valid:".green().bold());
346            eprintln!(
347                "  {}: {}s",
348                "Timeout".dimmed(),
349                config.timeout().to_string().cyan()
350            );
351            eprintln!(
352                "  {}: {}",
353                "Format".dimmed(),
354                self.format_description().white()
355            );
356            if config.quiet() {
357                eprintln!("  {}: {}", "Quiet".dimmed(), "enabled".green());
358            }
359            if let Some(source) = config.source() {
360                eprintln!("  {}: {source}", "Source IP".dimmed());
361            }
362            if config.no_download() {
363                eprintln!("  {}: {}", "Download test".dimmed(), "disabled".yellow());
364            }
365            if config.no_upload() {
366                eprintln!("  {}: {}", "Upload test".dimmed(), "disabled".yellow());
367            }
368            if config.single() {
369                eprintln!("  {}: {}", "Streams".dimmed(), "single".yellow());
370            }
371            if let Some(ca_cert) = config.ca_cert() {
372                eprintln!("  {}: {ca_cert}", "CA cert".dimmed());
373            }
374            if let Some(tls_version) = config.tls_version() {
375                eprintln!("  {}: {tls_version}", "TLS version".dimmed());
376            }
377            if config.pin_certs() {
378                eprintln!("  {}: {}", "Cert pinning".dimmed(), "enabled".yellow());
379            }
380            eprintln!(
381                "\n{}",
382                "Dry run complete. Run without --dry-run to perform speed test.".bright_black()
383            );
384        }
385    }
386
387    /// Return a human-readable description of the output format.
388    fn format_description(&self) -> &'static str {
389        match self.cfg().format() {
390            Some(f) => f.label(),
391            None => "Detailed (default)",
392        }
393    }
394}
395
396// =============================================================================
397// Orchestrator Traits - SOLID: Interface Segregation & Dependency Inversion
398// =============================================================================
399
400/// Trait for configuration access (ISP: clients only need config, not full Orchestrator).
401pub trait ConfigAccessor: Send {
402    fn config(&self) -> &Config;
403    fn is_verbose(&self) -> bool;
404}
405
406/// Trait for HTTP client access.
407pub trait HttpAccessor: Send {
408    fn client(&self) -> &reqwest::Client;
409}
410
411/// Trait for phase execution (allows swapping execution strategies).
412pub trait TestExecutor: Send + Sync {
413    fn execute(
414        &self,
415        orch: &Orchestrator,
416    ) -> impl std::future::Future<Output = Result<(), Error>> + Send;
417}
418
419/// Implementation that runs all phases.
420pub struct PhaseTestExecutor;
421
422impl TestExecutor for PhaseTestExecutor {
423    async fn execute(&self, orch: &Orchestrator) -> Result<(), Error> {
424        crate::phases::run_all_phases(orch).await
425    }
426}
427
428impl ConfigAccessor for Orchestrator {
429    fn config(&self) -> &Config {
430        self.cfg()
431    }
432    fn is_verbose(&self) -> bool {
433        self.is_verbose()
434    }
435}
436
437impl HttpAccessor for Orchestrator {
438    fn client(&self) -> &reqwest::Client {
439        &self.client
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::config::{ConfigSource, OutputSource};
447
448    fn orch_from_source(source: &ConfigSource, early_exit: EarlyExitFlags) -> Orchestrator {
449        let config = Config::from_source(source);
450        Orchestrator::from_config(config, early_exit).unwrap()
451    }
452
453    fn default_early_exit() -> EarlyExitFlags {
454        EarlyExitFlags {
455            show_config_path: false,
456            generate_completion: None,
457            history: false,
458            dry_run: false,
459        }
460    }
461
462    #[test]
463    fn test_orchestrator_creation() {
464        let source = ConfigSource::default();
465        let config = Config::from_source(&source);
466        let orch = Orchestrator::from_config(config, default_early_exit());
467        assert!(orch.is_ok());
468    }
469
470    #[test]
471    fn test_is_verbose_default() {
472        let source = ConfigSource::default();
473        let orch = orch_from_source(&source, default_early_exit());
474        assert!(orch.is_verbose());
475    }
476
477    #[test]
478    fn test_is_verbose_quiet() {
479        let source = ConfigSource {
480            output: OutputSource {
481                quiet: Some(true),
482                ..Default::default()
483            },
484            ..Default::default()
485        };
486        let orch = orch_from_source(&source, default_early_exit());
487        assert!(!orch.is_verbose());
488    }
489
490    #[test]
491    fn test_is_simple_mode_default() {
492        let source = ConfigSource::default();
493        let orch = orch_from_source(&source, default_early_exit());
494        assert!(!orch.is_simple_mode());
495    }
496
497    #[test]
498    fn test_is_simple_mode_simple() {
499        let source = ConfigSource {
500            output: OutputSource {
501                simple: Some(true),
502                ..Default::default()
503            },
504            ..Default::default()
505        };
506        let orch = orch_from_source(&source, default_early_exit());
507        assert!(orch.is_simple_mode());
508    }
509
510    #[test]
511    fn test_dry_run_succeeds() {
512        let source = ConfigSource::default();
513        let early_exit = EarlyExitFlags {
514            dry_run: true,
515            ..default_early_exit()
516        };
517        let orch = orch_from_source(&source, early_exit);
518        orch.run_dry_run();
519    }
520
521    #[test]
522    fn test_early_exit_flags_default() {
523        let flags = default_early_exit();
524        assert!(!flags.show_config_path);
525        assert!(flags.generate_completion.is_none());
526        assert!(!flags.history);
527        assert!(!flags.dry_run);
528    }
529
530    #[test]
531    fn test_storage_builder_defaults() {
532        let builder = StorageBuilder::new();
533        let (saver, history) = builder.build();
534        <dyn crate::storage::SaveResult>::save(&*saver, &crate::types::TestResult::default())
535            .unwrap();
536        let _ = <dyn crate::storage::LoadHistory>::load_recent(&*history, 1);
537    }
538
539    #[test]
540    fn test_storage_builder_custom() {
541        let shared = std::sync::Arc::new(crate::storage::MockStorage::new());
542        let saver = shared.clone() as std::sync::Arc<dyn crate::storage::SaveResult + Send + Sync>;
543        let history =
544            shared.clone() as std::sync::Arc<dyn crate::storage::LoadHistory + Send + Sync>;
545
546        let builder = StorageBuilder::new()
547            .with_saver_arc(saver)
548            .with_history_arc(history);
549
550        let (saver, history) = builder.build();
551
552        let result = crate::types::TestResult::default();
553        <dyn crate::storage::SaveResult>::save(&*saver, &result).unwrap();
554        let loaded = <dyn crate::storage::LoadHistory>::load_recent(&*history, 10).unwrap();
555        assert_eq!(loaded.len(), 1);
556    }
557
558    #[test]
559    fn test_orchestrator_exposes_services() {
560        let args = crate::cli::Args::default();
561        let orch = Orchestrator::new(args, None).unwrap();
562        let _services = orch.services();
563    }
564}