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
20pub 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 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 pub fn take_server(&mut self) -> Option<Server> {
53 self.server.take()
54 }
55
56 pub fn set_server(&mut self, server: Server) {
58 self.server = Some(server);
59 }
60
61 pub fn set_client_ip(&mut self, ip: String) {
63 self.client_ip = Some(ip);
64 }
65
66 pub fn set_client_location(&mut self, location: Option<crate::types::ClientLocation>) {
68 self.client_location = location;
69 }
70
71 pub fn set_ping_result(&mut self, result: (f64, f64, f64, Vec<f64>)) {
73 self.ping_result = Some(result);
74 }
75
76 pub fn take_ping_result(&mut self) -> Option<(f64, f64, f64, Vec<f64>)> {
78 self.ping_result.take()
79 }
80
81 pub fn set_download_result(&mut self, result: TestRunResult) {
83 self.download_result = Some(result);
84 }
85
86 pub fn take_download_result(&mut self) -> Option<TestRunResult> {
88 self.download_result.take()
89 }
90
91 pub fn set_upload_result(&mut self, result: TestRunResult) {
93 self.upload_result = Some(result);
94 }
95
96 pub fn take_upload_result(&mut self) -> Option<TestRunResult> {
98 self.upload_result.take()
99 }
100
101 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#[derive(Debug)]
125pub enum PhaseOutcome {
126 PhaseCompleted,
127 PhaseEarlyExit,
128 PhaseError(Error),
129}
130
131pub 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
175impl 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
268pub(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 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 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 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 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 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 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 ctx.set_server(server);
625 PhaseOutcome::PhaseCompleted
626 }
627 Err(e) => PhaseOutcome::PhaseError(e),
628 }
629 })
630}
631
632pub(crate) fn run_result<'a>(
635 orch: &'a Orchestrator,
636 ctx: &'a mut PhaseContext,
637) -> BoxFuture<'a, PhaseOutcome> {
638 Box::pin(async move {
639 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 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
711pub 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
727pub 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}