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