cli-speedtest 0.1.0

CLI based speedtest written on rust
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
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# Phase 2.5 Specification: Interactive Menu & Box Rendering Fix
**Project:** `cli-speedtest`
**Phase:** 2.5 (between Phase 2 polish and Phase 3 features)
**Status:** ✅ Completed
**Depends on:** Phase 2 (`theme.rs`, `AppConfig.color`, `owo-colors`)

---

## Overview

This phase adds two distinct improvements:

1. **Box rendering fix** — the summary box border collapses on the right side
   whenever colored values are present. This is a measurement bug, not a visual
   preference.
2. **Interactive main menu** — an ASCII art welcome screen with an arrow-key
   navigable menu that wraps the existing `run()` function. The menu is
   TTY-only; all existing CLI flag behaviour is completely unchanged.

---

## Root Cause: Why the Box Breaks

The screenshot shows `║` characters floating disconnected from the right border.
The cause is that Rust's `{:<N}` format padding measures **byte length**, not
**visible character width**. An ANSI-colored string like
`\x1b[32m401.74 Mbps\x1b[0m` is 22 bytes but only 11 visible characters.
When inserted into a `{:<23}` field, Rust pads it to 23 *bytes* — but the
terminal renders it 12 columns short, pushing the right border inward.

```
Expected:  ║  Download   :      401.74 Mbps  Great      ║
Actual:    ║  Download   :      401.74 Mbps  Great      ║  ← right border drifts
```

The fix is a `visible_len()` helper that strips ANSI escape sequences before
measuring, then manually constructs the padding string to the correct visible
width. `console::strip_ansi_codes()` already exists in a dependency you have.

---

## New Dependencies

Add to `[dependencies]` in `Cargo.toml`:

```toml
dialoguer = "0.11"   # arrow-key Select menu; internally uses `console` (already a dep)
```

`dialoguer` 0.11 requires no new transitive dependencies beyond `console`, which
`indicatif` already pulls in.

---

## File Changes Summary

```
src/
├── client.rs        (unchanged)
├── lib.rs           (unchanged — run() stays public and flag-driven)
├── main.rs          ← CHANGED: TTY check, route to menu or direct run
├── menu.rs          ← NEW: ASCII art, menu loop, Settings state
├── models.rs        ← CHANGED: MenuSettings struct added
├── theme.rs         ← CHANGED: visible_len() + pad_to() helpers; box renderer
└── utils.rs         (unchanged)

tests/
└── integration_test.rs   (unchanged — tests call run() directly, unaffected)
```

---

## Part 1 — Box Rendering Fix

### 1.1  New helpers in `src/theme.rs`

```rust
/// Returns the visible (printed) length of a string by stripping ANSI codes first.
/// Uses console::strip_ansi_codes which handles all standard SGR sequences.
pub fn visible_len(s: &str) -> usize {
    console::strip_ansi_codes(s).chars().count()
}

/// Right-pads `s` with spaces so its *visible* width equals `width`.
/// If the visible length already meets or exceeds `width`, returns `s` unchanged.
pub fn pad_to(s: &str, width: usize) -> String {
    let vlen = visible_len(s);
    if vlen >= width {
        s.to_string()
    } else {
        format!("{}{}", s, " ".repeat(width - vlen))
    }
}
```

### 1.2  Updated box renderer in `src/lib.rs`

Replace every `format!("{:<N}", colored_value)` call inside the summary box with
`pad_to(colored_value, N)`. The box width remains dynamic per the P2-5 spec
(clamped between 44 and 60 visible columns).

**Before (broken):**
```rust
println!("║  Download   : {:<18} Mbps ║", format!("{:.2}", down_speed));
```

**After (correct):**
```rust
let speed_str = color_speed(down_speed, &config);  // may contain ANSI codes
let rated_str = format!("{} {}", speed_str, speed_rating(down_speed, &config));
println!("║  Download   : {} ║", pad_to(&rated_str, field_width));
```

Where `field_width` is derived from the dynamic box width so the right border
always lands in the correct column regardless of color state.

### 1.3  Acceptance test

```bash
# Both of these must produce a box with aligned right borders:
cargo run -- --ping-count 3 --duration 4          # color on
cargo run -- --ping-count 3 --duration 4 --no-color   # color off
NO_COLOR=1 cargo run -- --ping-count 3 --duration 4   # color off via env
```

---

## Part 2 — Interactive Main Menu

### 2.1  Entry-point routing in `src/main.rs`

At the start of `main()`, after building the `Client` and `AppConfig`, check
whether to show the menu or run directly:

```rust
let is_tty     = console::Term::stdout().is_term();
let has_flags  = args.has_any_action_flags();  // see §2.2
let show_menu  = is_tty && !has_flags && !args.json;

if show_menu {
    menu::run_menu(config, client).await?;
} else {
    // existing direct-run path — completely unchanged
    run_app(args, client).await?;
}
```

**Rule:** if *any* action flag is present (`--no-download`, `--no-upload`,
`--server`, `--connections`, `--duration`, `--ping-count`), the menu is skipped
and the tool behaves exactly as it does today. This preserves 100% backward
compatibility for scripting.

### 2.2  `has_any_action_flags()` on `Args`

```rust
impl Args {
    /// Returns true if the user passed any flag that customises run behaviour.
    /// Used to decide whether to show the interactive menu.
    fn has_any_action_flags(&self) -> bool {
        self.no_download
            || self.no_upload
            || self.server != DEFAULT_SERVER_URL
            || self.connections.is_some()
            || self.duration != 10          // 10 is the clap default_value_t
            || self.ping_count != 20        // 20 is the clap default_value_t
    }
}
```

### 2.3  ASCII art welcome screen

The art is a `const &str` baked directly into `menu.rs` — no font files, no
runtime rendering, no extra crates. It is printed once on menu entry and cleared
before each test run.

```
 ██████╗██╗     ██╗    ███████╗██████╗ ███████╗███████╗██████╗ ████████╗███████╗███████╗████████╗
██╔════╝██║     ██║    ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝
██║     ██║     ██║    ███████╗██████╔╝█████╗  █████╗  ██║  ██║   ██║   █████╗  ███████╗   ██║
██║     ██║     ██║    ╚════██║██╔═══╝ ██╔══╝  ██╔══╝  ██║  ██║   ██║   ██╔══╝  ╚════██║   ██║
╚██████╗███████╗██║    ███████║██║     ███████╗███████╗██████╔╝   ██║   ███████╗███████║   ██║
 ╚═════╝╚══════╝╚═╝    ╚══════╝╚═╝     ╚══════╝╚══════╝╚═════╝    ╚═╝   ╚══════╝╚══════╝   ╚═╝
```

Beneath the art, a subtitle line and version badge:

```
  A blazing fast network speed tester — written in Rust
  v0.1.0  •  Cloudflare backend  •  github.com/nazakun021/cli-speedtest
```

The full art + subtitle is stored in `menu.rs`:

```rust
const ASCII_ART: &str = r#"
 ██████╗██╗     ██╗    ███████╗██████╗ ███████╗███████╗██████╗ ████████╗███████╗███████╗████████╗
██╔════╝██║     ██║    ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝
██║     ██║     ██║    ███████╗██████╔╝█████╗  █████╗  ██║  ██║   ██║   █████╗  ███████╗   ██║
██║     ██║     ██║    ╚════██║██╔═══╝ ██╔══╝  ██╔══╝  ██║  ██║   ██║   ██╔══╝  ╚════██║   ██║
╚██████╗███████╗██║    ███████║██║     ███████╗███████╗██████╔╝   ██║   ███████╗███████║   ██║
 ╚═════╝╚══════╝╚═╝    ╚══════╝╚═╝     ╚══════╝╚══════╝╚═════╝    ╚═╝   ╚══════╝╚══════╝   ╚═╝
"#;
```

If the terminal is narrower than the art (< 95 columns), fall back to a compact
single-line title instead:

```
  CLI SPEEDTEST  •  v0.1.0
```

Width check uses `console::Term::stdout().size().1`.

### 2.4  Menu options

The `dialoguer::Select` widget renders a styled list. The user navigates with
`↑`/`↓` arrow keys (or `k`/`j`) and confirms with `Enter`. `Esc` or `q` exits.

**Menu items:**

```
  🚀  Start Full Speed Test
  📡  Quick Ping Only
  ⚙️   Settings
  📋  View Commands
  ❓  Help
  ──────────────────
  🚪  Exit
```

| Option | Behaviour |
|---|---|
| **Start Full Speed Test** | Clears screen, runs `run()` with current `MenuSettings`, shows results, then prompts "Press Enter to return to menu…" |
| **Quick Ping Only** | Runs `test_ping_stats()` directly (no download/upload), shows a compact ping result, returns to menu |
| **Settings** | Opens a settings submenu (§2.5); returns to main menu when done |
| **View Commands** | Prints a formatted reference of all CLI flags (§2.6), waits for Enter |
| **Help** | Prints an interpretation guide for results (§2.7), waits for Enter |
| **Exit** | Clears the welcome art and exits with code 0 |

The separator line between Help and Exit is a non-selectable cosmetic item
rendered via `dialoguer`'s item theming.

### 2.5  Settings submenu

Settings let the user configure the run parameters interactively without needing
to know the CLI flags. Changes persist for the duration of the session (stored in
`MenuSettings` in memory; never written to disk).

```
  ⚙️  Settings
  ───────────────────────────────
  Test Duration     : 10s    [← current value shown]
  Parallel Connections : 8 down / 4 up
  Ping Probe Count  : 20
  Color Output      : On
  ───────────────────────────────
  ↩  Back to Main Menu
```

Each setting uses `dialoguer::Select` to pick from a preset list:

| Setting | Options |
|---|---|
| Test Duration | 5s, 10s (default), 15s, 20s, 30s |
| Parallel Connections | 2, 4, 6, 8 (default down), 12, 16 |
| Ping Probe Count | 5, 10, 20 (default), 30, 50 |
| Color Output | On (default), Off |

`MenuSettings` is stored in `menu.rs` and converted to `RunArgs` before each
test run:

```rust
// src/models.rs — new struct
#[derive(Debug, Clone)]
pub struct MenuSettings {
    pub duration_secs: u64,     // default: 10
    pub connections: usize,     // default: 8 (down), 4 (up)
    pub ping_count: u32,        // default: 20
    pub color: bool,            // default: true
}

impl Default for MenuSettings {
    fn default() -> Self {
        Self {
            duration_secs: 10,
            connections: 8,
            ping_count: 20,
            color: true,
        }
    }
}
```

### 2.6  View Commands screen

A formatted, human-readable reference of every CLI flag. Shown in the terminal,
waits for Enter to return. Uses `pad_to()` for alignment so it looks correct
with or without color.

```
  ┌─────────────────────────────────────────────────────────┐
  │  📋  Available Commands                                  │
  ├─────────────────────────────────────────────────────────┤
  │  -d, --duration <SECS>       Test duration (default: 10) │
  │  -c, --connections <N>       Parallel connections         │
  │      --server <URL>          Custom server base URL       │
  │      --no-download           Skip download test           │
  │      --no-upload             Skip upload test             │
  │      --ping-count <N>        Ping probes (default: 20)    │
  │      --json                  Output results as JSON       │
  │      --no-color              Disable color output         │
  │      --debug                 Enable debug logging         │
  ├─────────────────────────────────────────────────────────┤
  │  Example:  cli-speedtest --duration 20 --connections 12  │
  │  Example:  cli-speedtest --json | jq .download_mbps      │
  └─────────────────────────────────────────────────────────┘

  Press Enter to return…
```

### 2.7  Help screen

Explains how to interpret results. No interactivity beyond "press Enter". Uses
the same `pad_to()` box renderer as the summary.

```
  ┌─────────────────────────────────────────────────────────┐
  │  ❓  Interpreting Your Results                           │
  ├─────────────────────────────────────────────────────────┤
  │  SPEED                                                   │
  │    ≥ 500 Mbps  Excellent — fiber / high-end cable        │
  │    100–499     Great     — HD streaming, fast downloads  │
  │     25–99      Good      — video calls, light streaming  │
  │      5–24      Fair      — basic browsing, email         │
  │       < 5      Poor      — may struggle with modern web  │
  ├─────────────────────────────────────────────────────────┤
  │  PING                                                    │
  │    ≤  20 ms   Excellent — real-time gaming, VoIP         │
  │    21–80 ms   Good      — video calls, general use       │
  │    > 80 ms    High      — noticeable in latency-sensitive │
  │               applications                               │
  ├─────────────────────────────────────────────────────────┤
  │  JITTER  (variation in ping)                             │
  │    ≤  5 ms   Stable — voice/video calls unaffected       │
  │    6–20 ms   Moderate — occasional stutter possible      │
  │    > 20 ms   Unstable — real-time apps will be impacted  │
  ├─────────────────────────────────────────────────────────┤
  │  PACKET LOSS                                             │
  │    0.0%      Ideal — no retransmission overhead          │
  │    > 0.0%    Lossy — investigate ISP or local network    │
  └─────────────────────────────────────────────────────────┘

  Press Enter to return…
```

---

## Part 3 — `src/menu.rs` Structure

```rust
// src/menu.rs

use crate::models::{AppConfig, MenuSettings, RunArgs};
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use reqwest::Client;
use std::sync::Arc;

const DEFAULT_SERVER_URL: &str = "https://speed.cloudflare.com";
const ASCII_ART: &str = r#"..."#;          // full art from §2.3
const ASCII_ART_COMPACT: &str = "  CLI SPEEDTEST";

pub async fn run_menu(config: Arc<AppConfig>, client: Client) -> anyhow::Result<()> {
    let mut settings = MenuSettings::default();

    loop {
        // Clear screen, print art, print menu
        print_welcome(&config);

        let choice = show_main_menu(&config)?;

        match choice {
            0 => run_full_test(&settings, &config, &client).await?,
            1 => run_quick_ping(&settings, &config, &client).await?,
            2 => show_settings(&mut settings, &config)?,
            3 => show_commands(&config),
            4 => show_help(&config),
            5 => { clear_screen(); break; }
            _ => unreachable!(),
        }
    }

    Ok(())
}

fn print_welcome(config: &AppConfig) { /* clears screen, prints ASCII art + subtitle */ }
fn show_main_menu(config: &AppConfig) -> anyhow::Result<usize> { /* dialoguer::Select */ }
async fn run_full_test(settings: &MenuSettings, config: &AppConfig, client: &Client) -> anyhow::Result<()> { /* calls crate::run() */ }
async fn run_quick_ping(settings: &MenuSettings, config: &AppConfig, client: &Client) -> anyhow::Result<()> { /* calls client::test_ping_stats() */ }
fn show_settings(settings: &mut MenuSettings, config: &AppConfig) -> anyhow::Result<()> { /* settings submenu */ }
fn show_commands(config: &AppConfig) { /* prints commands box, waits for Enter */ }
fn show_help(config: &AppConfig) { /* prints help box, waits for Enter */ }
fn clear_screen() { print!("\x1b[2J\x1b[H"); }
fn wait_for_enter() { /* reads a single Enter keystroke */ }
```

### Menu settings → RunArgs conversion

```rust
impl From<&MenuSettings> for RunArgs {
    fn from(s: &MenuSettings) -> Self {
        RunArgs {
            server_url: DEFAULT_SERVER_URL.to_string(),
            duration_secs: s.duration_secs,
            connections: Some(s.connections),
            ping_count: s.ping_count,
            no_download: false,
            no_upload: false,
        }
    }
}
```

---

## Routing Decision Tree

```
main() starts
    ├─ stdout is NOT a TTY?          ──→  direct run (existing path, unchanged)
    ├─ --json flag set?              ──→  direct run
    ├─ any action flag set?          ──→  direct run
    └─ interactive TTY, no flags    ──→  menu::run_menu()
                                              ├─ Start Full Test ──→ crate::run() ──→ results ──→ "press Enter" ──→ menu
                                              ├─ Quick Ping      ──→ test_ping_stats() ──→ "press Enter" ──→ menu
                                              ├─ Settings        ──→ settings submenu ──→ menu
                                              ├─ View Commands   ──→ commands box ──→ "press Enter" ──→ menu
                                              ├─ Help            ──→ help box ──→ "press Enter" ──→ menu
                                              └─ Exit            ──→ clear screen ──→ exit(0)
```

---

## Implementation Order

```
1. Box fix (theme.rs: visible_len, pad_to, updated box renderer)
      — standalone, no new deps, verifiable immediately

2. models.rs: add MenuSettings struct + Default impl
      — needed by menu.rs

3. menu.rs: skeleton + ASCII art + print_welcome()
      — visible immediately, no behaviour yet

4. menu.rs: show_main_menu() with dialoguer::Select
      — requires dialoguer dep to be added

5. menu.rs: run_full_test() and run_quick_ping()
      — wires menu to existing run() and test_ping_stats()

6. menu.rs: show_settings() submenu
      — purely UI, no measurement logic

7. menu.rs: show_commands() and show_help() screens
      — purely informational, uses pad_to() box renderer

8. main.rs: TTY check + routing logic
      — last step; everything else must work before this is wired up
```

---

## Testing Requirements

### Unit tests (`src/theme.rs`)

| Test | Asserts |
|---|---|
| `visible_len_plain_string` | `visible_len("hello")` == 5 |
| `visible_len_colored_string` | `visible_len("\x1b[32m401.74\x1b[0m")` == 6 |
| `pad_to_short_string_pads_correctly` | `pad_to("hi", 5)` == `"hi   "` |
| `pad_to_colored_string_pads_to_visible_width` | visible_len of result == 10 |
| `pad_to_already_at_width_unchanged` | `pad_to("hello", 5)` == `"hello"` |
| `pad_to_over_width_unchanged` | `pad_to("toolong", 4)` == `"toolong"` |

### Unit tests (`src/menu.rs` or `tests/menu_test.rs`)

| Test | Asserts |
|---|---|
| `menu_settings_default_values` | All fields match documented defaults |
| `menu_settings_converts_to_run_args` | `RunArgs::from(&MenuSettings::default())` has correct fields |
| `has_any_action_flags_false_for_defaults` | Default `Args` returns false |
| `has_any_action_flags_true_when_no_download` | Returns true |
| `has_any_action_flags_true_when_custom_server` | Returns true |

### Manual acceptance tests

```bash
# 1. Menu appears in normal interactive terminal
cargo run

# 2. Menu does NOT appear with any flag
cargo run -- --duration 5
cargo run -- --no-download
cargo run -- --json
cargo run -- --server https://speed.cloudflare.com

# 3. Box alignment — right border must be flush with color on and off
cargo run   # select "Start Full Speed Test", inspect summary box
NO_COLOR=1 cargo run   # same, verify no drift

# 4. Narrow terminal — resize to ~80 cols, run full test
#    Box must not overflow or wrap

# 5. Settings persist within session
#    Change duration to 5s in Settings, run full test — test must complete in ~5s

# 6. Quick Ping Only — must not trigger any download/upload requests
#    Verify by running with --debug and checking stderr

# 7. Exit option — must clear welcome art and return cleanly to shell prompt

# 8. ASCII art fallback — resize terminal to < 95 columns
#    Compact title must appear instead of full block art
```

---

## Definition of Done

- [x] `visible_len()` and `pad_to()` exist in `theme.rs`
- [x] Summary box right border is flush with color on, off, and piped
- [x] `MenuSettings` struct exists in `models.rs` with `Default` impl
- [x] `From<&MenuSettings> for RunArgs` is implemented
- [x] `menu.rs` exists with all 7 functions implemented
- [x] ASCII art displays at ≥ 95 columns; compact title at < 95 columns
- [x] All 6 menu options are navigable with arrow keys and Enter
- [x] Settings submenu persists changes for the session
- [x] Menu is completely bypassed when any action flag is set or stdout is non-TTY
- [x] All unit tests in the testing requirements table pass
- [x] All 8 manual acceptance tests pass
- [x] `cargo clippy -- -D warnings` passes
- [x] `cargo fmt --check` passes
- [x] Phase 2.5 items added to `TODO.md` and marked ✅ on completion