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 futures::future::BoxFuture;
13
14use crate::orchestrator::Orchestrator;
15use crate::task_runner::TestRunResult;
16use crate::types::Server;
17
18/// Context passed between phases — holds all data accumulated during execution.
19pub struct PhaseContext {
20    client_location: Option<crate::types::ClientLocation>,
21    client_ip: Option<String>,
22    server: Option<Server>,
23    ping_result: Option<(f64, f64, f64, Vec<f64>)>,
24    download_result: Option<TestRunResult>,
25    upload_result: Option<TestRunResult>,
26    list_printed: bool,
27    elapsed: Option<std::time::Duration>,
28    services: std::sync::Arc<dyn Services>,
29}
30
31impl PhaseContext {
32    /// Create a new context with the given services.
33    pub fn new(services: std::sync::Arc<dyn Services>) -> Self {
34        Self {
35            client_location: None,
36            client_ip: None,
37            server: None,
38            ping_result: None,
39            download_result: None,
40            upload_result: None,
41            list_printed: false,
42            elapsed: None,
43            services,
44        }
45    }
46}
47
48impl std::fmt::Debug for PhaseContext {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("PhaseContext")
51            .field("client_location", &self.client_location)
52            .field("client_ip", &self.client_ip)
53            .field("server", &self.server)
54            .field("ping_result", &self.ping_result)
55            .field("download_result", &self.download_result)
56            .field("upload_result", &self.upload_result)
57            .field("list_printed", &self.list_printed)
58            .field("elapsed", &self.elapsed)
59            .field("services", &"dyn Services")
60            .finish()
61    }
62}
63
64/// Phase outcome.
65#[derive(Debug)]
66pub enum PhaseOutcome {
67    PhaseCompleted,
68    PhaseEarlyExit,
69    PhaseError(Error),
70}
71
72/// Async phase function signature.
73pub type PhaseFn =
74    for<'a> fn(&'a Orchestrator, &'a mut PhaseContext) -> BoxFuture<'a, PhaseOutcome>;
75
76impl Default for PhaseExecutor {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82pub struct PhaseExecutor {
83    phases: Vec<PhaseFn>,
84}
85
86impl PhaseExecutor {
87    pub fn new() -> Self {
88        Self { phases: Vec::new() }
89    }
90
91    pub fn register(mut self, phase: PhaseFn) -> Self {
92        self.phases.push(phase);
93        self
94    }
95
96    pub async fn execute_all(&self, orch: &Orchestrator) -> Result<(), Error> {
97        let mut ctx = PhaseContext::new(orch.services_arc());
98        for phase in &self.phases {
99            let outcome = phase(orch, &mut ctx).await;
100            match outcome {
101                PhaseOutcome::PhaseCompleted => {}
102                PhaseOutcome::PhaseEarlyExit => return Ok(()),
103                PhaseOutcome::PhaseError(e) => return Err(e),
104            }
105        }
106        Ok(())
107    }
108}
109
110pub type PhaseResults = (
111    Option<(f64, f64, f64, Vec<f64>)>,
112    Option<TestRunResult>,
113    Option<TestRunResult>,
114);
115
116/// PhaseContext accessor methods.
117impl PhaseContext {
118    pub fn client_location(&self) -> Option<&crate::types::ClientLocation> {
119        self.client_location.as_ref()
120    }
121
122    pub fn client_ip(&self) -> Option<&str> {
123        self.client_ip.as_deref()
124    }
125
126    pub fn server(&self) -> Option<&Server> {
127        self.server.as_ref()
128    }
129
130    pub fn ping_result(&self) -> Option<&(f64, f64, f64, Vec<f64>)> {
131        self.ping_result.as_ref()
132    }
133
134    pub fn download_result(&self) -> Option<&TestRunResult> {
135        self.download_result.as_ref()
136    }
137
138    pub fn upload_result(&self) -> Option<&TestRunResult> {
139        self.upload_result.as_ref()
140    }
141
142    pub fn is_list_printed(&self) -> bool {
143        self.list_printed
144    }
145
146    pub fn elapsed(&self) -> Option<std::time::Duration> {
147        self.elapsed
148    }
149
150    pub fn services(&self) -> &dyn Services {
151        self.services.as_ref()
152    }
153
154    pub fn services_arc(&self) -> std::sync::Arc<dyn Services> {
155        self.services.clone()
156    }
157
158    pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
159        self.client_ip = Some(ip.into());
160        self
161    }
162
163    pub fn with_client_location(mut self, location: crate::types::ClientLocation) -> Self {
164        self.client_location = Some(location);
165        self
166    }
167
168    pub fn with_server(mut self, server: Server) -> Self {
169        self.server = Some(server);
170        self
171    }
172
173    pub fn with_ping_result(mut self, ping: (f64, f64, f64, Vec<f64>)) -> Self {
174        self.ping_result = Some(ping);
175        self
176    }
177
178    pub fn with_download_result(mut self, result: TestRunResult) -> Self {
179        self.download_result = Some(result);
180        self
181    }
182
183    pub fn with_upload_result(mut self, result: TestRunResult) -> Self {
184        self.upload_result = Some(result);
185        self
186    }
187
188    pub fn mark_list_printed(&mut self) {
189        self.list_printed = true;
190    }
191
192    pub fn set_elapsed(&mut self, elapsed: std::time::Duration) {
193        self.elapsed = Some(elapsed);
194    }
195
196    pub fn take_results(&mut self) -> PhaseResults {
197        let ping = self.ping_result.take();
198        let download = self.download_result.take();
199        let upload = self.upload_result.take();
200        (ping, download, upload)
201    }
202
203    pub fn with_services(mut self, services: std::sync::Arc<dyn Services>) -> Self {
204        self.services = services;
205        self
206    }
207}
208
209// ============================================================================
210// Phase Implementations (use task_runner for async operations)
211// ============================================================================
212
213pub(crate) fn run_early_exit<'a>(
214    orch: &'a Orchestrator,
215    _ctx: &'a mut PhaseContext,
216) -> BoxFuture<'a, PhaseOutcome> {
217    let early_exit = orch.early_exit().clone();
218    Box::pin(async move {
219        // early_exit already cloned above
220
221        if early_exit.show_config_path {
222            match crate::config::get_config_path_internal() {
223                Some(path) => eprintln!("Configuration file: {}", path.display()),
224                None => eprintln!("No configuration path available."),
225            }
226            return PhaseOutcome::PhaseEarlyExit;
227        }
228
229        if let Some(shell) = early_exit.generate_completion {
230            let shell_name = match shell {
231                crate::cli::ShellType::Bash => "netspeed-cli.bash",
232                crate::cli::ShellType::Zsh => "_netspeed-cli",
233                crate::cli::ShellType::Fish => "netspeed-cli.fish",
234                crate::cli::ShellType::PowerShell => "_netspeed-cli.ps1",
235                crate::cli::ShellType::Elvish => "netspeed-cli.elv",
236            };
237            eprintln!("Shell completions for {shell:?}: {shell_name}");
238            return PhaseOutcome::PhaseEarlyExit;
239        }
240
241        if early_exit.history {
242            match crate::history::show() {
243                Ok(()) => PhaseOutcome::PhaseEarlyExit,
244                Err(e) => PhaseOutcome::PhaseError(e),
245            }
246        } else if early_exit.dry_run {
247            orch.run_dry_run();
248            PhaseOutcome::PhaseEarlyExit
249        } else {
250            PhaseOutcome::PhaseCompleted
251        }
252    })
253}
254
255pub(crate) fn run_header<'a>(
256    orch: &'a Orchestrator,
257    _ctx: &'a mut PhaseContext,
258) -> BoxFuture<'a, PhaseOutcome> {
259    Box::pin(async move {
260        if orch.is_verbose() {
261            let version = env!("CARGO_PKG_VERSION");
262            let nc = crate::terminal::no_color();
263
264            if nc {
265                eprintln!();
266                eprintln!("  NetSpeed CLI v{version}  ·  speedtest.net");
267                eprintln!();
268            } else {
269                use owo_colors::OwoColorize;
270                eprintln!();
271                eprintln!(
272                    "  {} v{}  {}  {}",
273                    "NetSpeed CLI".cyan().bold(),
274                    version.white(),
275                    "·".dimmed(),
276                    "speedtest.net".bright_black()
277                );
278                eprintln!();
279            }
280        }
281        PhaseOutcome::PhaseCompleted
282    })
283}
284
285pub(crate) fn run_server_discovery<'a>(
286    orch: &'a Orchestrator,
287    ctx: &'a mut PhaseContext,
288) -> BoxFuture<'a, PhaseOutcome> {
289    let is_verbose = orch.is_verbose();
290    let spinner = if is_verbose {
291        Some(crate::progress::create_spinner("Finding servers..."))
292    } else {
293        None
294    };
295
296    Box::pin(async move {
297        // Discover servers asynchronously using injected service
298        let result = ctx.services().server_service().fetch_servers().await;
299        let (mut servers, client_location) = match result {
300            Ok((servers, location)) => (servers, location),
301            Err(e) => return PhaseOutcome::PhaseError(e),
302        };
303        ctx.client_location = client_location;
304
305        if let Some(ref pb) = spinner {
306            crate::progress::finish_ok(pb, &format!("Found {} servers", servers.len()));
307            eprintln!();
308        }
309
310        if orch.config().list() {
311            if let Err(e) = crate::formatter::format_list(&servers) {
312                return PhaseOutcome::PhaseError(e.into());
313            }
314            ctx.mark_list_printed();
315            return PhaseOutcome::PhaseEarlyExit;
316        }
317
318        if !orch.config().server_ids().is_empty() {
319            servers.retain(|s| orch.config().server_ids().contains(&s.id));
320        }
321        if !orch.config().exclude_ids().is_empty() {
322            servers.retain(|s| !orch.config().exclude_ids().contains(&s.id));
323        }
324
325        if servers.is_empty() {
326            return PhaseOutcome::PhaseError(crate::error::Error::ServerNotFound(
327                "No servers match your criteria.".to_string(),
328            ));
329        }
330
331        let server = match ctx.services().server_service().select_best(&servers) {
332            Ok(s) => s,
333            Err(e) => return PhaseOutcome::PhaseError(e),
334        };
335
336        if is_verbose {
337            let dist = crate::common::format_distance(server.distance);
338            eprintln!();
339            if crate::terminal::no_color() {
340                eprintln!("  Server:   {} ({})", server.sponsor, server.name);
341                eprintln!("  Location: {} ({dist})", server.country);
342            } else {
343                use owo_colors::OwoColorize;
344                eprintln!(
345                    "  {}   {} ({})",
346                    "Server:".dimmed(),
347                    server.sponsor.white().bold(),
348                    server.name
349                );
350                eprintln!("  {} {} ({dist})", "Location:".dimmed(), server.country);
351            }
352            eprintln!();
353        }
354
355        ctx.server = Some(server);
356        PhaseOutcome::PhaseCompleted
357    })
358}
359
360pub(crate) fn run_ip_discovery<'a>(
361    orch: &'a Orchestrator,
362    ctx: &'a mut PhaseContext,
363) -> BoxFuture<'a, PhaseOutcome> {
364    Box::pin(async move {
365        let is_verbose = orch.is_verbose();
366        let result = ctx.services().ip_service().discover_ip().await;
367        match result {
368            Ok(ip) => ctx.client_ip = Some(ip),
369            Err(e) => {
370                if is_verbose {
371                    eprintln!("Warning: Could not discover client IP: {e}");
372                }
373            }
374        }
375        PhaseOutcome::PhaseCompleted
376    })
377}
378
379pub(crate) fn run_ping<'a>(
380    orch: &'a Orchestrator,
381    ctx: &'a mut PhaseContext,
382) -> BoxFuture<'a, PhaseOutcome> {
383    let no_download = orch.config().no_download();
384    let no_upload = orch.config().no_upload();
385    if no_download && no_upload {
386        return Box::pin(async { PhaseOutcome::PhaseCompleted });
387    }
388
389    let server = match ctx.server.take() {
390        Some(s) => s,
391        None => return Box::pin(async { PhaseOutcome::PhaseCompleted }),
392    };
393
394    let is_verbose = orch.is_verbose();
395    let spinner = if is_verbose {
396        Some(crate::progress::create_spinner("Testing latency..."))
397    } else {
398        None
399    };
400
401    let services = ctx.services_arc();
402
403    Box::pin(async move {
404        let result = services.server_service().ping_server(&server).await;
405        let ping_result = match result {
406            Ok(r) => r,
407            Err(e) => return PhaseOutcome::PhaseError(e),
408        };
409
410        if let Some(ref pb) = spinner {
411            let msg = if crate::terminal::no_color() {
412                format!("Latency: {:.2} ms", ping_result.0)
413            } else {
414                use owo_colors::OwoColorize;
415                format!(
416                    "Latency: {}",
417                    format!("{:.2} ms", ping_result.0).cyan().bold()
418                )
419            };
420            crate::progress::finish_ok(pb, &msg);
421        }
422
423        ctx.ping_result = Some((ping_result.0, ping_result.1, ping_result.2, ping_result.3));
424        PhaseOutcome::PhaseCompleted
425    })
426}
427
428// Bandwidth and result phases use async task_runner - handled in legacy for now
429
430pub(crate) fn run_result<'a>(
431    orch: &'a Orchestrator,
432    ctx: &'a mut PhaseContext,
433) -> BoxFuture<'a, PhaseOutcome> {
434    Box::pin(async move {
435        // Take server info before taking results
436        let server_info = match ctx.server.take() {
437            Some(s) => crate::types::ServerInfo {
438                id: s.id.clone(),
439                name: s.name.clone(),
440                sponsor: s.sponsor.clone(),
441                country: s.country.clone(),
442                distance: s.distance,
443            },
444            None => return PhaseOutcome::PhaseCompleted,
445        };
446
447        let (ping_result, download_result, upload_result) = ctx.take_results();
448
449        let (ping, jitter, packet_loss, ping_samples) = match ping_result {
450            Some((p, j, pl, s)) => (Some(p), Some(j), Some(pl), s),
451            None => (None, None, None, Vec::new()),
452        };
453
454        let dl_result = download_result.unwrap_or_default();
455        let ul_result = upload_result.unwrap_or_default();
456
457        let mut result = crate::types::TestResult::from_test_runs(
458            server_info,
459            ping,
460            jitter,
461            packet_loss,
462            &ping_samples,
463            &dl_result,
464            &ul_result,
465            ctx.client_ip().map(|s| s.to_string()),
466            ctx.client_location().cloned(),
467        );
468
469        let config = orch.config();
470        result.phases = crate::types::TestPhases {
471            ping: if config.no_download() && config.no_upload() {
472                crate::types::PhaseResult::skipped("both bandwidth phases disabled")
473            } else {
474                crate::types::PhaseResult::completed()
475            },
476            download: if config.no_download() {
477                crate::types::PhaseResult::skipped("disabled by user")
478            } else {
479                crate::types::PhaseResult::completed()
480            },
481            upload: if config.no_upload() {
482                crate::types::PhaseResult::skipped("disabled by user")
483            } else {
484                crate::types::PhaseResult::completed()
485            },
486        };
487
488        if config.should_save_history() {
489            if let Err(e) = orch.saver().save(&result) {
490                eprintln!("Warning: Failed to save test result: {e}");
491            }
492        }
493
494        // Delegate to orchestrator for output
495        match orch.output_results(
496            &mut result,
497            &dl_result,
498            &ul_result,
499            std::time::Duration::from_secs(0),
500        ) {
501            Ok(()) => PhaseOutcome::PhaseCompleted,
502            Err(e) => PhaseOutcome::PhaseError(e),
503        }
504    })
505}
506
507// ============================================================================
508// Default Phase Registry
509// ============================================================================
510
511pub fn create_default_executor() -> PhaseExecutor {
512    PhaseExecutor::new()
513        .register(run_early_exit)
514        .register(run_header)
515        .register(run_server_discovery)
516        .register(run_ip_discovery)
517        .register(run_ping)
518        .register(run_result)
519}
520
521/// Run all phases in order.
522pub async fn run_all_phases(orch: &Orchestrator) -> Result<(), Error> {
523    let executor = create_default_executor();
524    executor.execute_all(orch).await
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    fn make_test_services() -> std::sync::Arc<dyn Services> {
532        let client = reqwest::Client::new();
533        std::sync::Arc::new(crate::services::ServiceContainer::new(client))
534    }
535
536    #[test]
537    fn test_phase_context_default() {
538        let ctx = PhaseContext::new(make_test_services());
539        assert!(ctx.client_ip().is_none());
540        assert!(ctx.server().is_none());
541    }
542
543    #[test]
544    fn test_phase_context_builder() {
545        let ctx = PhaseContext::new(make_test_services()).with_client_ip("192.168.1.1");
546
547        assert_eq!(ctx.client_ip(), Some("192.168.1.1"));
548    }
549
550    #[test]
551    fn test_phase_executor_register() {
552        let _executor = PhaseExecutor::new()
553            .register(run_early_exit)
554            .register(run_header);
555    }
556}