nd300 3.5.2

Cross-platform network diagnostic tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
use clap::Parser;
use nd_300::cli::{SpeedQXCli, SpeedQXCommand};
use nd_300::speedtest::display::{render_results, SpeedQXDisplay};
use nd_300::speedtest::{
    format_mbps, Phase, ProviderCompleteCallback, ProviderSet, SpeedTestConfig, TestDuration,
};
use std::sync::{Arc, Mutex};

/// Tracks which phase is currently active so the callback can manage
/// transitions. Steps are a running counter (advanced on every new phase)
/// rather than fixed indices, so the display adapts to any provider set
/// (FAST / FULL / skip flags) and the variable per-provider phase counts.
struct DisplayState {
    display: SpeedQXDisplay,
    current_phase: Option<Phase>,
    current_bar: Option<indicatif::ProgressBar>,
    /// Running step counter (1-based), advanced when a new phase begins.
    current_step: u32,
    total_steps: u32,
    /// How many providers run this session (for the "Provider X/N" banner).
    provider_count: u32,
    /// 1-based index of the provider currently being displayed.
    provider_index: u32,
    current_provider: Option<&'static str>,
    use_colors: bool,
    use_ascii: bool,
    json_mode: bool,
}

/// Human-facing provider name for a phase (drives the transition banner).
fn provider_name_for_phase(phase: Phase) -> &'static str {
    match phase {
        Phase::CfLatency | Phase::CfDownload | Phase::CfUpload => "Cloudflare",
        Phase::Ndt7Discovery | Phase::Ndt7Download | Phase::Ndt7Upload => "M-Lab NDT7",
        Phase::MsakDiscovery | Phase::MsakDownload | Phase::MsakUpload => "M-Lab MSAK",
        Phase::LsDiscovery | Phase::LsDownload | Phase::LsUpload => "LibreSpeed",
        Phase::FcDiscovery | Phase::FcDownload | Phase::FcUpload => "fast.com (Netflix)",
        Phase::CfyLatency | Phase::CfyDownload => "CacheFly",
        Phase::VultrDiscovery | Phase::VultrLatency | Phase::VultrDownload => "Vultr",
        Phase::AnqDiscovery | Phase::AnqDownload | Phase::AnqUpload => "Apple networkQuality",
        Phase::Computing => "Computing",
    }
}

impl DisplayState {
    fn label_for_phase(&self, phase: Phase) -> &'static str {
        match phase {
            Phase::CfLatency => "Measuring latency (Cloudflare)",
            Phase::CfDownload => "Download (Cloudflare)",
            Phase::CfUpload => "Upload (Cloudflare)",
            Phase::Ndt7Discovery => "Finding nearest M-Lab server",
            Phase::Ndt7Download => "Download (M-Lab NDT7)",
            Phase::Ndt7Upload => "Upload (M-Lab NDT7)",
            Phase::MsakDiscovery => "Finding nearest M-Lab MSAK server",
            Phase::MsakDownload => "Download (M-Lab MSAK, multi-stream)",
            Phase::MsakUpload => "Upload (M-Lab MSAK, multi-stream)",
            Phase::LsDiscovery => "Finding nearest LibreSpeed server",
            Phase::LsDownload => "Download (LibreSpeed)",
            Phase::LsUpload => "Upload (LibreSpeed)",
            Phase::FcDiscovery => "Connecting to Netflix CDN",
            Phase::FcDownload => "Download (fast.com)",
            Phase::FcUpload => "Upload (fast.com)",
            Phase::CfyLatency => "Measuring latency (CacheFly)",
            Phase::CfyDownload => "Download (CacheFly)",
            Phase::VultrDiscovery => "Selecting nearest Vultr PoP",
            Phase::VultrLatency => "Measuring latency (Vultr)",
            Phase::VultrDownload => "Download (Vultr)",
            Phase::AnqDiscovery => "Connecting to Apple edge",
            Phase::AnqDownload => "Download (Apple networkQuality)",
            Phase::AnqUpload => "Upload (Apple networkQuality)",
            Phase::Computing => "Results computed",
        }
    }

    fn is_progress_phase(&self, phase: Phase) -> bool {
        matches!(
            phase,
            Phase::CfDownload
                | Phase::CfUpload
                | Phase::Ndt7Download
                | Phase::Ndt7Upload
                | Phase::MsakDownload
                | Phase::MsakUpload
                | Phase::LsDownload
                | Phase::LsUpload
                | Phase::FcDownload
                | Phase::FcUpload
                | Phase::CfyDownload
                | Phase::VultrDownload
                | Phase::AnqDownload
                | Phase::AnqUpload
        )
    }

    fn handle_phase(&mut self, phase: Phase, progress: f64) {
        // Phase transition: finish the previous bar and open a new one.
        if self.current_phase != Some(phase) {
            // Don't re-enter a phase that was already finished (a spurious
            // trailing progress >= 1.0 for a completed phase).
            if self.current_bar.is_none() && self.current_phase.is_none() && progress >= 1.0 {
                return;
            }

            self.finish_current();

            // Provider transition banner when entering a new provider.
            let name = provider_name_for_phase(phase);
            if phase != Phase::Computing && self.current_provider != Some(name) {
                self.current_provider = Some(name);
                self.provider_index += 1;
                if !self.json_mode {
                    let sep = if self.use_ascii { "-" } else { "\u{2500}" };
                    let banner = format!(
                        "  {} Provider {}/{}: {} {}",
                        sep.repeat(2),
                        self.provider_index,
                        self.provider_count,
                        name,
                        sep.repeat(30usize.saturating_sub(name.len()))
                    );
                    if self.use_colors {
                        println!("{}", owo_colors::OwoColorize::dimmed(&banner));
                    } else {
                        println!("{}", banner);
                    }
                }
            }

            self.current_step = (self.current_step + 1).min(self.total_steps);
            self.current_phase = Some(phase);

            let label = self.label_for_phase(phase);
            if self.is_progress_phase(phase) {
                let bar =
                    self.display
                        .create_progress_bar(self.current_step, self.total_steps, label);
                self.current_bar = Some(bar);
            } else {
                let spinner =
                    self.display
                        .create_spinner(self.current_step, self.total_steps, label);
                self.current_bar = Some(spinner);
            }
        }

        // Update the active progress bar.
        if let Some(ref bar) = self.current_bar {
            if self.is_progress_phase(phase) {
                let pct = (progress * 100.0).min(100.0) as u64;
                bar.set_position(pct);
            }
        }

        // Finish immediately once the phase reports completion.
        if progress >= 1.0 {
            self.finish_current();
        }
    }

    fn finish_current(&mut self) {
        if let Some(bar) = self.current_bar.take() {
            bar.finish_and_clear();
        }
        if let Some(phase) = self.current_phase.take() {
            let label = self.label_for_phase(phase);
            self.display
                .finish_step(self.current_step, self.total_steps, label);
        }
    }
}

/// Compute the running-provider count and the total number of phase steps for
/// the header + the [step/total] display, mirroring the plan `speedtest::run`
/// executes. Phase counts per provider: Cloudflare 3, NDT7 3, MSAK 3,
/// LibreSpeed 3, fast.com 3, CacheFly 2 (download-only), Vultr 3, Apple 3,
/// plus 1 for the final Computing step.
fn plan_counts(fast: bool, skip_msak: bool, skip_apple: bool) -> (u32, u32) {
    let mut provider_count: u32 = 2; // Cloudflare + NDT7
    let mut phase_steps: u32 = 6; // 3 + 3

    // MSAK: FAST always runs it; FULL runs it unless skipped.
    if fast || !skip_msak {
        provider_count += 1;
        phase_steps += 3;
    }

    if !fast {
        // FULL adds LibreSpeed (3), fast.com (3), CacheFly (2), Vultr (3).
        provider_count += 4;
        phase_steps += 3 + 3 + 2 + 3;
        if !skip_apple {
            provider_count += 1;
            phase_steps += 3;
        }
    }

    (provider_count, phase_steps + 1) // + Computing
}

#[tokio::main]
async fn main() {
    let cli = SpeedQXCli::parse();

    #[cfg(windows)]
    enable_utf8_console();

    // Subcommand form takes precedence over the legacy --update flag.
    if let Some(cmd) = cli.command.clone() {
        match cmd {
            SpeedQXCommand::Update => {
                let mut config = nd_300::config::Config::new().with_colors(!cli.no_color);
                if cli.json {
                    config = config.with_json();
                }
                let exit_code = nd_300::actions::update::run(&config).await;
                std::process::exit(exit_code);
            }
        }
    }

    // Legacy flag form: `speedqx --update`.
    if cli.update {
        let mut config = nd_300::config::Config::new().with_colors(!cli.no_color);
        if cli.json {
            config = config.with_json();
        }
        let exit_code = nd_300::actions::update::run(&config).await;
        std::process::exit(exit_code);
    }

    let use_ascii = cli.ascii;
    let use_colors = !cli.no_color;
    let json_mode = cli.json;
    let fast = cli.fast;

    let provider_set = if fast {
        ProviderSet::Fast
    } else {
        ProviderSet::All
    };

    let config = SpeedTestConfig {
        duration: cli.duration,
        fastcom_duration: cli.fastcom_duration,
        latency_probes: cli.latency_probes,
        provider_set,
        // In FAST mode the fixed subset (CF + NDT7 + MSAK) always runs; the
        // skip flags only apply to the FULL run.
        msak_enabled: !cli.skip_msak,
        apple_enabled: !cli.skip_apple,
        use_colors,
    };

    let (provider_count, total_steps) = plan_counts(fast, cli.skip_msak, cli.skip_apple);

    // Outer wall-clock cap. The providers run sequentially; each fixed-duration
    // provider does `duration` per direction plus a dense latency phase, and
    // fast.com uses `fastcom_duration`. FAST providers are additionally capped
    // at 25 s each with early stopping. This generous ceiling only trips on a
    // genuinely stuck provider.
    let cap_dur_secs = match &config.duration {
        TestDuration::Seconds(s) => *s,
        TestDuration::Auto => 15,
    };
    let cap_fc_secs = match &config.fastcom_duration {
        TestDuration::Seconds(s) => *s,
        TestDuration::Auto => 15,
    };
    let outer_cap = std::time::Duration::from_secs(
        (provider_count as u64) * (2 * cap_dur_secs + 25) + 2 * cap_fc_secs + 90,
    )
    .max(std::time::Duration::from_secs(120));

    // Print header with an estimated time.
    if !json_mode {
        let display = SpeedQXDisplay::new(use_ascii, use_colors, json_mode);
        display.print_header();

        let per_dir_secs = if fast { 8 } else { cap_dur_secs };
        let total_est = (provider_count as u64) * (2 * per_dir_secs + 6);
        let mins = total_est / 60;
        let secs = total_est % 60;
        let mode = if fast { "FAST" } else { "FULL" };

        if use_colors {
            println!(
                "  {}",
                owo_colors::OwoColorize::dimmed(&format!(
                    "Estimated test time: ~{}:{:02} ({} mode, {} providers, {}s/direction)",
                    mins, secs, mode, provider_count, per_dir_secs
                ))
            );
        } else {
            println!(
                "  Estimated test time: ~{}:{:02} ({} mode, {} providers, {}s/direction)",
                mins, secs, mode, provider_count, per_dir_secs
            );
        }
        println!();
    }

    let state = Arc::new(Mutex::new(DisplayState {
        display: SpeedQXDisplay::new(use_ascii, use_colors, json_mode),
        current_phase: None,
        current_bar: None,
        current_step: 0,
        total_steps,
        provider_count,
        provider_index: 0,
        current_provider: None,
        use_colors,
        use_ascii,
        json_mode,
    }));

    // Provider completion callback — prints a summary after each provider.
    let summary_colors = use_colors;
    let summary_ascii = use_ascii;
    let summary_json = json_mode;
    let on_complete: ProviderCompleteCallback = Arc::new(move |result| {
        if summary_json {
            return;
        }

        let sep = if summary_ascii {
            "---"
        } else {
            "\u{2500}\u{2500}\u{2500}"
        };

        let dl = result
            .download_mbps
            .map(|d| format!("{} \u{2193}", format_mbps(d)))
            .unwrap_or_else(|| "N/A \u{2193}".to_string());
        let ul = result
            .upload_mbps
            .map(|u| format!("{} \u{2191}", format_mbps(u)))
            .unwrap_or_else(|| "N/A \u{2191}".to_string());
        let ping = result
            .ping_ms
            .map(|p| format!(" ({}ms)", p.round() as u64))
            .unwrap_or_default();

        if let Some(ref err) = result.error {
            if summary_colors {
                println!(
                    "  {} {}: {} {}",
                    sep,
                    result.provider,
                    owo_colors::OwoColorize::red(&err.as_str()),
                    sep
                );
            } else {
                println!("  {} {}: {} {}", sep, result.provider, err, sep);
            }
        } else if summary_colors {
            println!(
                "  {} {}: {} / {}{} {}",
                sep,
                owo_colors::OwoColorize::bold(&result.provider.as_str()),
                owo_colors::OwoColorize::green(&dl.as_str()),
                owo_colors::OwoColorize::cyan(&ul.as_str()),
                owo_colors::OwoColorize::dimmed(&ping.as_str()),
                sep
            );
        } else {
            println!(
                "  {} {}: {} / {}{} {}",
                sep, result.provider, dl, ul, ping, sep
            );
        }
        println!();
    });

    let state_clone = state.clone();
    let result = match tokio::time::timeout(
        outer_cap,
        nd_300::speedtest::run(
            config,
            move |phase, progress| {
                if let Ok(mut s) = state_clone.lock() {
                    s.handle_phase(phase, progress);
                }
            },
            Some(on_complete),
        ),
    )
    .await
    {
        Ok(r) => r,
        Err(_) => {
            if json_mode {
                println!(
                    "{{\"error\":\"timeout\",\"timed_out\":true,\"timeout_secs\":{}}}",
                    outer_cap.as_secs()
                );
            } else {
                eprintln!();
                eprintln!(
                    "Speed test timed out after {}s — a provider appears to be stuck or the \
                     network is severely degraded. Try again, or use --duration to shorten the test.",
                    outer_cap.as_secs()
                );
            }
            std::process::exit(2);
        }
    };

    if json_mode {
        match serde_json::to_string_pretty(&result) {
            Ok(json) => println!("{}", json),
            Err(e) => {
                eprintln!("Error serializing results: {}", e);
                std::process::exit(1);
            }
        }
    } else {
        println!();
        print!("{}", render_results(&result, use_ascii, use_colors));
    }

    // Honest exit code: non-zero when no provider produced positive throughput
    // in either direction (mirrors diagnostics/speed.rs).
    let measured = result.providers.iter().any(|p| {
        p.error.is_none()
            && (p.download_mbps.unwrap_or(0.0) > 0.0 || p.upload_mbps.unwrap_or(0.0) > 0.0)
    });
    if !measured {
        std::process::exit(2);
    }
}

#[cfg(windows)]
fn enable_utf8_console() {
    use std::io::IsTerminal;
    if std::io::stdout().is_terminal() {
        unsafe {
            winapi::um::wincon::SetConsoleOutputCP(65001);
        }
    }
}