Skip to main content

netspeed_cli/
phases.rs

1//! Phase definitions for the speed test lifecycle.
2//!
3//! ## Design
4//!
5//! - [`PhaseContext`] — shared state with private fields (ISP: clients use accessors)
6//! - [`PhaseOutcome`] — result of phase execution  
7//! - Each phase is an async function that takes (orch, ctx)
8//! - [`PhaseExecutor`] — runs phases in sequence
9
10use crate::error::Error;
11use crate::services::Services;
12use crate::theme::Colors;
13use futures::future::BoxFuture;
14use std::sync::Arc;
15
16use crate::orchestrator::Orchestrator;
17use crate::task_runner::TestRunResult;
18use crate::types::Server;
19
20/// Context passed between phases — holds all data accumulated during execution.
21pub struct PhaseContext {
22    client_location: Option<crate::types::ClientLocation>,
23    client_ip: Option<String>,
24    server: Option<Server>,
25    ping_result: Option<(f64, f64, f64, Vec<f64>)>,
26    download_result: Option<TestRunResult>,
27    upload_result: Option<TestRunResult>,
28    list_printed: bool,
29    elapsed: Option<std::time::Duration>,
30    services: std::sync::Arc<dyn Services>,
31}
32
33impl PhaseContext {
34    /// Create a new context with the given services.
35    pub fn new(services: std::sync::Arc<dyn Services>) -> Self {
36        Self {
37            client_location: None,
38            client_ip: None,
39            server: None,
40            ping_result: None,
41            download_result: None,
42            upload_result: None,
43            list_printed: false,
44            elapsed: None,
45            services,
46        }
47    }
48
49    // === New setter/taker methods for encapsulation ===
50
51    /// Take the server (removes from context).
52    pub fn take_server(&mut self) -> Option<Server> {
53        self.server.take()
54    }
55
56    /// Set the server.
57    pub fn set_server(&mut self, server: Server) {
58        self.server = Some(server);
59    }
60
61    /// Set client IP.
62    pub fn set_client_ip(&mut self, ip: String) {
63        self.client_ip = Some(ip);
64    }
65
66    /// Set client location.
67    pub fn set_client_location(&mut self, location: Option<crate::types::ClientLocation>) {
68        self.client_location = location;
69    }
70
71    /// Set ping result.
72    pub fn set_ping_result(&mut self, result: (f64, f64, f64, Vec<f64>)) {
73        self.ping_result = Some(result);
74    }
75
76    /// Take ping result.
77    pub fn take_ping_result(&mut self) -> Option<(f64, f64, f64, Vec<f64>)> {
78        self.ping_result.take()
79    }
80
81    /// Set download result.
82    pub fn set_download_result(&mut self, result: TestRunResult) {
83        self.download_result = Some(result);
84    }
85
86    /// Take download result.
87    pub fn take_download_result(&mut self) -> Option<TestRunResult> {
88        self.download_result.take()
89    }
90
91    /// Set upload result.
92    pub fn set_upload_result(&mut self, result: TestRunResult) {
93        self.upload_result = Some(result);
94    }
95
96    /// Take upload result.
97    pub fn take_upload_result(&mut self) -> Option<TestRunResult> {
98        self.upload_result.take()
99    }
100
101    /// Mark list as printed.
102    pub fn set_list_printed(&mut self) {
103        self.list_printed = true;
104    }
105}
106
107impl std::fmt::Debug for PhaseContext {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("PhaseContext")
110            .field("client_location", &self.client_location)
111            .field("client_ip", &self.client_ip)
112            .field("server", &self.server)
113            .field("ping_result", &self.ping_result)
114            .field("download_result", &self.download_result)
115            .field("upload_result", &self.upload_result)
116            .field("list_printed", &self.list_printed)
117            .field("elapsed", &self.elapsed)
118            .field("services", &"dyn Services")
119            .finish()
120    }
121}
122
123/// Phase outcome.
124#[derive(Debug)]
125pub enum PhaseOutcome {
126    PhaseCompleted,
127    PhaseEarlyExit,
128    PhaseError(Error),
129}
130
131/// Async phase function signature.
132pub type PhaseFn =
133    for<'a> fn(&'a Orchestrator, &'a mut PhaseContext) -> BoxFuture<'a, PhaseOutcome>;
134
135impl Default for PhaseExecutor {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141pub struct PhaseExecutor {
142    phases: Vec<PhaseFn>,
143}
144
145impl PhaseExecutor {
146    pub fn new() -> Self {
147        Self { phases: Vec::new() }
148    }
149
150    pub fn register(mut self, phase: PhaseFn) -> Self {
151        self.phases.push(phase);
152        self
153    }
154
155    pub async fn execute_all(&self, orch: &Orchestrator) -> Result<(), Error> {
156        let mut ctx = PhaseContext::new(orch.services_arc());
157        for phase in &self.phases {
158            let outcome = phase(orch, &mut ctx).await;
159            match outcome {
160                PhaseOutcome::PhaseCompleted => {}
161                PhaseOutcome::PhaseEarlyExit => return Ok(()),
162                PhaseOutcome::PhaseError(e) => return Err(e),
163            }
164        }
165        Ok(())
166    }
167}
168
169pub type PhaseResults = (
170    Option<(f64, f64, f64, Vec<f64>)>,
171    Option<TestRunResult>,
172    Option<TestRunResult>,
173);
174
175/// PhaseContext accessor methods.
176impl PhaseContext {
177    pub fn client_location(&self) -> Option<&crate::types::ClientLocation> {
178        self.client_location.as_ref()
179    }
180
181    pub fn client_ip(&self) -> Option<&str> {
182        self.client_ip.as_deref()
183    }
184
185    pub fn server(&self) -> Option<&Server> {
186        self.server.as_ref()
187    }
188
189    pub fn ping_result(&self) -> Option<&(f64, f64, f64, Vec<f64>)> {
190        self.ping_result.as_ref()
191    }
192
193    pub fn download_result(&self) -> Option<&TestRunResult> {
194        self.download_result.as_ref()
195    }
196
197    pub fn upload_result(&self) -> Option<&TestRunResult> {
198        self.upload_result.as_ref()
199    }
200
201    pub fn is_list_printed(&self) -> bool {
202        self.list_printed
203    }
204
205    pub fn elapsed(&self) -> Option<std::time::Duration> {
206        self.elapsed
207    }
208
209    pub fn services(&self) -> &dyn Services {
210        self.services.as_ref()
211    }
212
213    pub fn services_arc(&self) -> std::sync::Arc<dyn Services> {
214        self.services.clone()
215    }
216
217    pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
218        self.client_ip = Some(ip.into());
219        self
220    }
221
222    pub fn with_client_location(mut self, location: crate::types::ClientLocation) -> Self {
223        self.client_location = Some(location);
224        self
225    }
226
227    pub fn with_server(mut self, server: Server) -> Self {
228        self.server = Some(server);
229        self
230    }
231
232    pub fn with_ping_result(mut self, ping: (f64, f64, f64, Vec<f64>)) -> Self {
233        self.ping_result = Some(ping);
234        self
235    }
236
237    pub fn with_download_result(mut self, result: TestRunResult) -> Self {
238        self.download_result = Some(result);
239        self
240    }
241
242    pub fn with_upload_result(mut self, result: TestRunResult) -> Self {
243        self.upload_result = Some(result);
244        self
245    }
246
247    pub fn mark_list_printed(&mut self) {
248        self.list_printed = true;
249    }
250
251    pub fn set_elapsed(&mut self, elapsed: std::time::Duration) {
252        self.elapsed = Some(elapsed);
253    }
254
255    pub fn take_results(&mut self) -> PhaseResults {
256        let ping = self.ping_result.take();
257        let download = self.download_result.take();
258        let upload = self.upload_result.take();
259        (ping, download, upload)
260    }
261
262    pub fn with_services(mut self, services: std::sync::Arc<dyn Services>) -> Self {
263        self.services = services;
264        self
265    }
266}
267
268// ============================================================================
269// Phase Implementations (use task_runner for async operations)
270// ============================================================================
271
272pub(crate) fn run_early_exit<'a>(
273    orch: &'a Orchestrator,
274    _ctx: &'a mut PhaseContext,
275) -> BoxFuture<'a, PhaseOutcome> {
276    let early_exit = orch.early_exit().clone();
277    Box::pin(async move {
278        // early_exit already cloned above
279
280        if early_exit.show_config_path {
281            match crate::config::get_config_path_internal() {
282                Some(path) => eprintln!("Configuration file: {}", path.display()),
283                None => eprintln!("No configuration path available."),
284            }
285            return PhaseOutcome::PhaseEarlyExit;
286        }
287
288        if let Some(shell) = early_exit.generate_completion {
289            let shell_name = match shell {
290                crate::cli::ShellType::Bash => "netspeed-cli.bash",
291                crate::cli::ShellType::Zsh => "_netspeed-cli",
292                crate::cli::ShellType::Fish => "netspeed-cli.fish",
293                crate::cli::ShellType::PowerShell => "_netspeed-cli.ps1",
294                crate::cli::ShellType::Elvish => "netspeed-cli.elv",
295            };
296            eprintln!("Shell completions for {shell:?}: {shell_name}");
297            return PhaseOutcome::PhaseEarlyExit;
298        }
299
300        if early_exit.history {
301            match crate::history::show(orch.config().theme()) {
302                Ok(()) => PhaseOutcome::PhaseEarlyExit,
303                Err(e) => PhaseOutcome::PhaseError(e),
304            }
305        } else if early_exit.dry_run {
306            orch.run_dry_run();
307            PhaseOutcome::PhaseEarlyExit
308        } else {
309            PhaseOutcome::PhaseCompleted
310        }
311    })
312}
313
314pub(crate) fn run_header<'a>(
315    orch: &'a Orchestrator,
316    _ctx: &'a mut PhaseContext,
317) -> BoxFuture<'a, PhaseOutcome> {
318    Box::pin(async move {
319        if orch.is_verbose() {
320            let version = env!("CARGO_PKG_VERSION");
321            let nc = crate::terminal::no_color();
322            let theme = orch.config().theme();
323            eprintln!();
324            if nc {
325                eprintln!("  netspeed-cli v{version}  ·  speedtest.net");
326                eprintln!();
327            } else {
328                eprintln!(
329                    "  {} v{}  {}  {}",
330                    Colors::header("NetSpeed CLI", theme),
331                    version,
332                    Colors::dimmed("·", theme),
333                    Colors::muted("speedtest.net", theme)
334                );
335                eprintln!();
336            }
337        }
338        PhaseOutcome::PhaseCompleted
339    })
340}
341
342pub(crate) fn run_server_discovery<'a>(
343    orch: &'a Orchestrator,
344    ctx: &'a mut PhaseContext,
345) -> BoxFuture<'a, PhaseOutcome> {
346    let is_verbose = orch.is_verbose();
347    let spinner = if is_verbose {
348        Some(crate::progress::create_spinner("Finding servers..."))
349    } else {
350        None
351    };
352
353    Box::pin(async move {
354        // Discover servers asynchronously using injected service
355        let result = ctx.services().server_service().fetch_servers().await;
356        let (mut servers, client_location) = match result {
357            Ok((servers, location)) => (servers, location),
358            Err(e) => return PhaseOutcome::PhaseError(e),
359        };
360        ctx.set_client_location(client_location);
361
362        if let Some(ref pb) = spinner {
363            let theme = orch.config().theme();
364            crate::progress::finish_ok(pb, &format!("Found {} servers", servers.len()), theme);
365            eprintln!();
366        }
367
368        if orch.config().list() {
369            if let Err(e) = crate::formatter::format_list(&servers, orch.config().theme()) {
370                return PhaseOutcome::PhaseError(e.into());
371            }
372            ctx.set_list_printed();
373            return PhaseOutcome::PhaseEarlyExit;
374        }
375
376        if !orch.config().server_ids().is_empty() {
377            servers.retain(|s| orch.config().server_ids().contains(&s.id));
378        }
379        if !orch.config().exclude_ids().is_empty() {
380            servers.retain(|s| !orch.config().exclude_ids().contains(&s.id));
381        }
382
383        if servers.is_empty() {
384            return PhaseOutcome::PhaseError(crate::error::Error::ServerNotFound(
385                "No servers match your criteria.".to_string(),
386            ));
387        }
388
389        let server = match ctx.services().server_service().select_best(&servers) {
390            Ok(s) => s,
391            Err(e) => return PhaseOutcome::PhaseError(e),
392        };
393
394        if is_verbose {
395            let dist = crate::common::format_distance(server.distance);
396            eprintln!();
397            let theme = orch.config().theme();
398            if crate::terminal::no_color() {
399                eprintln!("  Server:   {} ({})", server.sponsor, server.name);
400                eprintln!("  Location: {} ({dist})", server.country);
401            } else {
402                eprintln!(
403                    "  {}   {} ({})",
404                    Colors::dimmed("Server:", theme),
405                    Colors::bold(&server.sponsor, theme),
406                    server.name
407                );
408                eprintln!(
409                    "  {} {} ({dist})",
410                    Colors::dimmed("Location:", theme),
411                    server.country
412                );
413            }
414            eprintln!();
415        }
416
417        ctx.set_server(server);
418        PhaseOutcome::PhaseCompleted
419    })
420}
421
422pub(crate) fn run_ip_discovery<'a>(
423    orch: &'a Orchestrator,
424    ctx: &'a mut PhaseContext,
425) -> BoxFuture<'a, PhaseOutcome> {
426    Box::pin(async move {
427        let is_verbose = orch.is_verbose();
428        let result = ctx.services().ip_service().discover_ip().await;
429        match result {
430            Ok(ip) => ctx.set_client_ip(ip),
431            Err(e) => {
432                if is_verbose {
433                    eprintln!("Warning: Could not discover client IP: {e}");
434                }
435            }
436        }
437        PhaseOutcome::PhaseCompleted
438    })
439}
440
441pub(crate) fn run_ping<'a>(
442    orch: &'a Orchestrator,
443    ctx: &'a mut PhaseContext,
444) -> BoxFuture<'a, PhaseOutcome> {
445    let no_download = orch.config().no_download();
446    let no_upload = orch.config().no_upload();
447    if no_download && no_upload {
448        return Box::pin(async { PhaseOutcome::PhaseCompleted });
449    }
450
451    let server = match ctx.take_server() {
452        Some(s) => s,
453        None => {
454            return Box::pin(async {
455                PhaseOutcome::PhaseError(crate::error::Error::context("No server selected"))
456            });
457        }
458    };
459
460    let is_verbose = orch.is_verbose();
461    let spinner = if is_verbose {
462        Some(crate::progress::create_spinner("Testing latency..."))
463    } else {
464        None
465    };
466
467    let services = ctx.services_arc();
468
469    Box::pin(async move {
470        let result = services.server_service().ping_server(&server).await;
471        let ping_result = match result {
472            Ok(r) => r,
473            Err(e) => return PhaseOutcome::PhaseError(e),
474        };
475
476        if let Some(ref pb) = spinner {
477            let theme = orch.config().theme();
478            let msg = if crate::terminal::no_color() {
479                format!("Latency: {:.2} ms", ping_result.0)
480            } else {
481                format!(
482                    "Latency: {}",
483                    Colors::info(&format!("{:.2} ms", ping_result.0), theme)
484                )
485            };
486            crate::progress::finish_ok(pb, &msg, theme);
487        }
488
489        ctx.set_ping_result((ping_result.0, ping_result.1, ping_result.2, ping_result.3));
490        // Put server back for download/upload phases
491        ctx.set_server(server);
492        PhaseOutcome::PhaseCompleted
493    })
494}
495
496pub(crate) fn run_download<'a>(
497    orch: &'a Orchestrator,
498    ctx: &'a mut PhaseContext,
499) -> BoxFuture<'a, PhaseOutcome> {
500    let single = orch.config().single();
501    let is_verbose = orch.is_verbose();
502    // Only show spinner in non-verbose mode (verbose mode has progress bar which is better)
503    let spinner = if !is_verbose {
504        Some(crate::progress::create_spinner("Testing download..."))
505    } else {
506        None
507    };
508
509    Box::pin(async move {
510        if orch.config().no_download() {
511            return PhaseOutcome::PhaseCompleted;
512        }
513
514        let server = match ctx.take_server() {
515            Some(s) => s,
516            None => {
517                return PhaseOutcome::PhaseError(crate::error::Error::context(
518                    "No server selected",
519                ));
520            }
521        };
522
523        let client = orch.http_client();
524        let progress = if is_verbose {
525            Arc::new(crate::progress::Tracker::new_animated("Download"))
526        } else {
527            Arc::new(crate::progress::Tracker::with_target(
528                "Download",
529                indicatif::ProgressDrawTarget::hidden(),
530            ))
531        };
532
533        match crate::download::run(client, &server, single, progress).await {
534            Ok((avg, peak, total_bytes, samples)) => {
535                if let Some(ref pb) = spinner {
536                    let theme = orch.config().theme();
537                    let msg = if crate::terminal::no_color() {
538                        format!("Download: {:.2} Mbps", avg / 1_000_000.0)
539                    } else {
540                        format!(
541                            "Download: {}",
542                            Colors::good(&format!("{:.2} Mbps", avg / 1_000_000.0), theme)
543                        )
544                    };
545                    crate::progress::finish_ok(pb, &msg, theme);
546                }
547                ctx.set_download_result(crate::task_runner::TestRunResult {
548                    avg_bps: avg,
549                    peak_bps: peak,
550                    total_bytes,
551                    duration_secs: 0.0,
552                    speed_samples: samples,
553                    latency_under_load: None,
554                });
555                // Put server back for upload phase
556                ctx.set_server(server);
557                PhaseOutcome::PhaseCompleted
558            }
559            Err(e) => PhaseOutcome::PhaseError(e),
560        }
561    })
562}
563
564pub(crate) fn run_upload<'a>(
565    orch: &'a Orchestrator,
566    ctx: &'a mut PhaseContext,
567) -> BoxFuture<'a, PhaseOutcome> {
568    let single = orch.config().single();
569    let is_verbose = orch.is_verbose();
570    // Only show spinner in non-verbose mode (verbose mode has progress bar which is better)
571    let spinner = if !is_verbose {
572        Some(crate::progress::create_spinner("Testing upload..."))
573    } else {
574        None
575    };
576
577    Box::pin(async move {
578        if orch.config().no_upload() {
579            return PhaseOutcome::PhaseCompleted;
580        }
581
582        let server = match ctx.take_server() {
583            Some(s) => s,
584            None => {
585                return PhaseOutcome::PhaseError(crate::error::Error::context(
586                    "No server selected",
587                ));
588            }
589        };
590
591        let client = orch.http_client();
592        let progress = if is_verbose {
593            Arc::new(crate::progress::Tracker::new_animated("Upload"))
594        } else {
595            Arc::new(crate::progress::Tracker::with_target(
596                "Upload",
597                indicatif::ProgressDrawTarget::hidden(),
598            ))
599        };
600
601        match crate::upload::run(client, &server, single, progress).await {
602            Ok((avg, peak, total_bytes, samples)) => {
603                if let Some(ref pb) = spinner {
604                    let theme = orch.config().theme();
605                    let msg = if crate::terminal::no_color() {
606                        format!("Upload: {:.2} Mbps", avg / 1_000_000.0)
607                    } else {
608                        format!(
609                            "Upload: {}",
610                            Colors::good(&format!("{:.2} Mbps", avg / 1_000_000.0), theme)
611                        )
612                    };
613                    crate::progress::finish_ok(pb, &msg, theme);
614                }
615                ctx.set_upload_result(crate::task_runner::TestRunResult {
616                    avg_bps: avg,
617                    peak_bps: peak,
618                    total_bytes,
619                    duration_secs: 0.0,
620                    speed_samples: samples,
621                    latency_under_load: None,
622                });
623                // Put server back for result phase
624                ctx.set_server(server);
625                PhaseOutcome::PhaseCompleted
626            }
627            Err(e) => PhaseOutcome::PhaseError(e),
628        }
629    })
630}
631
632// Bandwidth and result phases use async task_runner - handled in legacy for now
633
634pub(crate) fn run_result<'a>(
635    orch: &'a Orchestrator,
636    ctx: &'a mut PhaseContext,
637) -> BoxFuture<'a, PhaseOutcome> {
638    Box::pin(async move {
639        // Take server info before taking results
640        let server_info = match ctx.take_server() {
641            Some(s) => crate::types::ServerInfo {
642                id: s.id.clone(),
643                name: s.name.clone(),
644                sponsor: s.sponsor.clone(),
645                country: s.country.clone(),
646                distance: s.distance,
647            },
648            None => return PhaseOutcome::PhaseCompleted,
649        };
650
651        let (ping_result, download_result, upload_result) = ctx.take_results();
652
653        let (ping, jitter, packet_loss, ping_samples) = match ping_result {
654            Some((p, j, pl, s)) => (Some(p), Some(j), Some(pl), s),
655            None => (None, None, None, Vec::new()),
656        };
657
658        let dl_result = download_result.unwrap_or_default();
659        let ul_result = upload_result.unwrap_or_default();
660
661        let mut result = crate::types::TestResult::from_test_runs(
662            server_info,
663            ping,
664            jitter,
665            packet_loss,
666            &ping_samples,
667            &dl_result,
668            &ul_result,
669            ctx.client_ip().map(|s| s.to_string()),
670            ctx.client_location().cloned(),
671        );
672
673        let config = orch.config();
674        result.phases = crate::types::TestPhases {
675            ping: if config.no_download() && config.no_upload() {
676                crate::types::PhaseResult::skipped("both bandwidth phases disabled")
677            } else {
678                crate::types::PhaseResult::completed()
679            },
680            download: if config.no_download() {
681                crate::types::PhaseResult::skipped("disabled by user")
682            } else {
683                crate::types::PhaseResult::completed()
684            },
685            upload: if config.no_upload() {
686                crate::types::PhaseResult::skipped("disabled by user")
687            } else {
688                crate::types::PhaseResult::completed()
689            },
690        };
691
692        if config.should_save_history() {
693            if let Err(e) = orch.saver().save(&result) {
694                eprintln!("Warning: Failed to save test result: {e}");
695            }
696        }
697
698        // Delegate to orchestrator for output
699        match orch.output_results(
700            &mut result,
701            &dl_result,
702            &ul_result,
703            std::time::Duration::from_secs(0),
704        ) {
705            Ok(()) => PhaseOutcome::PhaseCompleted,
706            Err(e) => PhaseOutcome::PhaseError(e),
707        }
708    })
709}
710
711// ============================================================================
712// Default Phase Registry
713// ============================================================================
714
715pub fn create_default_executor() -> PhaseExecutor {
716    PhaseExecutor::new()
717        .register(run_early_exit)
718        .register(run_header)
719        .register(run_server_discovery)
720        .register(run_ip_discovery)
721        .register(run_ping)
722        .register(run_download)
723        .register(run_upload)
724        .register(run_result)
725}
726
727/// Run all phases in order.
728pub async fn run_all_phases(orch: &Orchestrator) -> Result<(), Error> {
729    let executor = create_default_executor();
730    executor.execute_all(orch).await
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    fn make_test_services() -> std::sync::Arc<dyn Services> {
738        let client = reqwest::Client::new();
739        std::sync::Arc::new(crate::services::ServiceContainer::new(client))
740    }
741
742    #[test]
743    fn test_phase_context_default() {
744        let ctx = PhaseContext::new(make_test_services());
745        assert!(ctx.client_ip().is_none());
746        assert!(ctx.server().is_none());
747    }
748
749    #[test]
750    fn test_phase_context_builder() {
751        let ctx = PhaseContext::new(make_test_services()).with_client_ip("192.168.1.1");
752
753        assert_eq!(ctx.client_ip(), Some("192.168.1.1"));
754    }
755
756    #[test]
757    fn test_phase_executor_register() {
758        let _executor = PhaseExecutor::new()
759            .register(run_early_exit)
760            .register(run_header);
761    }
762}