1use 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
18pub 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 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#[derive(Debug)]
66pub enum PhaseOutcome {
67 PhaseCompleted,
68 PhaseEarlyExit,
69 PhaseError(Error),
70}
71
72pub 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
116impl 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
209pub(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 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 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
428pub(crate) fn run_result<'a>(
431 orch: &'a Orchestrator,
432 ctx: &'a mut PhaseContext,
433) -> BoxFuture<'a, PhaseOutcome> {
434 Box::pin(async move {
435 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 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
507pub 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
521pub 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}