Skip to main content

netspeed_cli/formatter/
estimates.rs

1//! Usage check targets and real-world download time estimates.
2
3#![allow(
4    clippy::cast_precision_loss,
5    clippy::cast_possible_truncation,
6    clippy::cast_sign_loss
7)]
8
9use crate::common;
10use crate::terminal;
11use crate::theme::{Colors, Theme};
12
13// ── Target benchmarks ────────────────────────────────────────────────
14
15struct Target {
16    name: &'static str,
17    required_mbps: f64,
18}
19
20const TARGETS: &[Target] = &[
21    Target {
22        name: "Video calls (1080p)",
23        required_mbps: 3.0,
24    },
25    Target {
26        name: "HD streaming",
27        required_mbps: 5.0,
28    },
29    Target {
30        name: "4K streaming",
31        required_mbps: 25.0,
32    },
33    Target {
34        name: "Cloud gaming",
35        required_mbps: 35.0,
36    },
37    Target {
38        name: "Large file transfers",
39        required_mbps: 100.0,
40    },
41];
42
43/// Build target usage check output as a string.
44#[must_use]
45pub fn build_targets(download_bps: Option<f64>, nc: bool, theme: Theme) -> String {
46    let targets: Vec<crate::profiles::UsageTarget> = TARGETS
47        .iter()
48        .map(|t| crate::profiles::UsageTarget {
49            name: t.name,
50            required_mbps: t.required_mbps,
51            icon: "",
52        })
53        .collect();
54    let dl_mbps = download_bps.map(|d| d / 1_000_000.0);
55    build_profile_targets(download_bps, nc, theme, &targets, dl_mbps)
56}
57
58/// Build profile-specific target usage check output.
59#[must_use]
60pub fn build_profile_targets(
61    download_bps: Option<f64>,
62    nc: bool,
63    theme: Theme,
64    targets: &[crate::profiles::UsageTarget],
65    dl_mbps: Option<f64>,
66) -> String {
67    let Some(dl) = download_bps else {
68        return String::new();
69    };
70    let dl_mbps = dl_mbps.unwrap_or_else(|| dl / 1_000_000.0);
71
72    let mut lines = Vec::new();
73
74    if nc {
75        lines.push("\n  ◈ USAGE CHECK".to_string());
76    } else {
77        lines.push(format!(
78            "\n  {} {}",
79            Colors::muted("◈", theme),
80            Colors::header("USAGE CHECK", theme)
81        ));
82    }
83
84    for target in targets {
85        let met = dl_mbps >= target.required_mbps;
86        let ratio = dl_mbps / target.required_mbps;
87        let suffix = if ratio >= 10.0 {
88            format!("{ratio:.0}x")
89        } else {
90            format!("{ratio:.1}x")
91        };
92        let hide_emoji = terminal::no_emoji();
93        let icon = if target.icon.is_empty() {
94            "🎯"
95        } else {
96            target.icon
97        };
98        if met {
99            let status = if hide_emoji { "✓" } else { "✅" };
100            let line = format!("{icon} {:<24} {status} {} above", target.name, suffix);
101            if nc || hide_emoji {
102                lines.push(format!("  {line}"));
103            } else {
104                lines.push(format!("  {}", Colors::good(&line, theme)));
105            }
106        } else {
107            let shortfall = target.required_mbps - dl_mbps;
108            let status = if hide_emoji { "✗" } else { "❌" };
109            let line = format!(
110                "{icon} {:<24} {status} {:.1} Mb/s short",
111                target.name, shortfall
112            );
113            if nc || hide_emoji {
114                lines.push(format!("  {line}"));
115            } else {
116                lines.push(format!("  {}", Colors::bad(&line, theme)));
117            }
118        }
119    }
120
121    lines.join("\n")
122}
123
124/// Format target usage check against download speed.
125pub fn format_targets(download_bps: Option<f64>, nc: bool, theme: Theme) {
126    let output = build_targets(download_bps, nc, theme);
127    if !output.is_empty() {
128        eprintln!("{output}");
129    }
130}
131
132// ── Real-world download estimates ──────────────────────────────────────
133
134struct FileEstimate {
135    name: &'static str,
136    size_bytes: u64,
137}
138
139const ESTIMATES: &[FileEstimate] = &[
140    FileEstimate {
141        name: "Song / Podcast episode",
142        size_bytes: 8 * 1024 * 1024,
143    },
144    FileEstimate {
145        name: "Photo (RAW)",
146        size_bytes: 30 * 1024 * 1024,
147    },
148    FileEstimate {
149        name: "App install",
150        size_bytes: 300 * 1024 * 1024,
151    },
152    FileEstimate {
153        name: "HD movie (1080p)",
154        size_bytes: 8 * 1024 * 1024 * 1024,
155    },
156    FileEstimate {
157        name: "4K movie (HDR)",
158        size_bytes: 25 * 1024 * 1024 * 1024,
159    },
160    FileEstimate {
161        name: "Game install (AAA)",
162        size_bytes: 120 * 1024 * 1024 * 1024,
163    },
164];
165
166fn format_time_estimate(secs: f64, _nc: bool) -> String {
167    if secs < 1.0 {
168        format!("{secs:.1}s")
169    } else if secs < 60.0 {
170        format!("{secs:.0}s")
171    } else if secs < 3600.0 {
172        // Safe: secs is 60..3600, results fit in u64.
173        format!(
174            "{}m {:02}s",
175            (secs / 60.0).clamp(0.0, u64::MAX as f64) as u64,
176            (secs % 60.0).clamp(0.0, u64::MAX as f64) as u64
177        )
178    } else {
179        // Safe: secs is ≥3600 but bounded by test duration (minutes), fits u64.
180        format!(
181            "{}h {:02}m",
182            (secs / 3600.0).clamp(0.0, u64::MAX as f64) as u64,
183            ((secs % 3600.0) / 60.0).clamp(0.0, u64::MAX as f64) as u64
184        )
185    }
186}
187
188/// Build real-world download time estimates as a string.
189#[must_use]
190pub fn build(download_bps: Option<f64>, nc: bool, theme: Theme) -> String {
191    let Some(dl) = download_bps else {
192        return String::new();
193    };
194    let dl_bytes_per_sec = dl / 8.0;
195
196    let mut lines = Vec::new();
197
198    if nc {
199        lines.push("\n  ◈ ESTIMATES".to_string());
200    } else {
201        lines.push(format!(
202            "\n  {} {}",
203            Colors::muted("◈", theme),
204            Colors::header("ESTIMATES", theme)
205        ));
206    }
207
208    for file in ESTIMATES {
209        // Safe: file sizes are at most ~120 GB, well under 2^53 (~9 PB).
210        let secs = file.size_bytes as f64 / dl_bytes_per_sec;
211        let time_str = format_time_estimate(secs, nc);
212        let size_str = common::format_data_size(file.size_bytes);
213        let label = format!("{:<24} {:>8}   ~{time_str}", file.name, size_str);
214        if nc {
215            lines.push(format!("  {label}"));
216        } else {
217            lines.push(format!("  {}", Colors::good(&label, theme)));
218        }
219    }
220
221    lines.join("\n")
222}
223
224pub fn show(download_bps: Option<f64>, nc: bool, theme: Theme) {
225    let output = build(download_bps, nc, theme);
226    if !output.is_empty() {
227        eprintln!("{output}");
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::profiles::UsageTarget;
235    use crate::theme::Theme;
236
237    #[test]
238    fn test_format_time_estimate() {
239        assert!(format_time_estimate(0.5, false).contains("0.5s"));
240        assert!(format_time_estimate(30.0, false).contains("30s"));
241        assert!(format_time_estimate(120.0, false).contains("2m"));
242    }
243
244    #[test]
245    fn test_format_time_estimate_sub_second() {
246        // Sub-second times should show 1 decimal
247        assert_eq!(format_time_estimate(0.1, false), "0.1s");
248        assert_eq!(format_time_estimate(0.9, false), "0.9s");
249    }
250
251    #[test]
252    fn test_format_time_estimate_seconds() {
253        // 1 to 59 seconds should show whole number
254        assert_eq!(format_time_estimate(1.0, false), "1s");
255        assert_eq!(format_time_estimate(45.5, false), "46s");
256        assert_eq!(format_time_estimate(59.9, false), "60s");
257    }
258
259    #[test]
260    fn test_format_time_estimate_minutes() {
261        // 60+ seconds should show minutes and seconds
262        let result = format_time_estimate(90.0, false);
263        assert!(result.contains('m'));
264
265        let result = format_time_estimate(125.5, false);
266        assert!(result.contains('m'));
267        assert!(result.contains('s'));
268    }
269
270    #[test]
271    fn test_format_time_estimate_hours() {
272        // 3600+ seconds should show hours and minutes
273        let result = format_time_estimate(3661.0, false);
274        assert!(result.contains('h'));
275        assert!(result.contains('m'));
276    }
277
278    #[test]
279    fn test_build_targets_none_download() {
280        // Should return empty string when download is None
281        let result = build_targets(None, false, Theme::Dark);
282        assert_eq!(result, "");
283    }
284
285    #[test]
286    fn test_build_targets_with_download() {
287        // 100 Mbps download
288        let result = build_targets(Some(100_000_000.0), false, Theme::Dark);
289        assert!(!result.is_empty());
290        assert!(result.contains("USAGE CHECK"));
291    }
292
293    #[test]
294    fn test_build_targets_all_targets_present() {
295        let result = build_targets(Some(100_000_000.0), false, Theme::Dark);
296        // All target names should be present
297        assert!(result.contains("Video calls"));
298        assert!(result.contains("HD streaming"));
299        assert!(result.contains("4K streaming"));
300        assert!(result.contains("Cloud gaming"));
301        assert!(result.contains("Large file transfers"));
302    }
303
304    #[test]
305    fn test_build_targets_excellent_speed() {
306        // 500 Mbps should meet all targets
307        let result = build_targets(Some(500_000_000.0), false, Theme::Dark);
308        // Should show passing indicators for all targets (or error count)
309        assert!(!result.is_empty());
310    }
311
312    #[test]
313    fn test_build_targets_poor_speed() {
314        // 1 Mbps should fail most targets
315        let result = build_targets(Some(1_000_000.0), false, Theme::Dark);
316        assert!(!result.is_empty());
317    }
318
319    #[test]
320    fn test_build_profile_targets_custom_targets() {
321        let targets = vec![
322            UsageTarget {
323                name: "Custom Target",
324                required_mbps: 25.0,
325                icon: "🎯",
326            },
327            UsageTarget {
328                name: "Another Target",
329                required_mbps: 50.0,
330                icon: "⭐",
331            },
332        ];
333
334        let result = build_profile_targets(
335            Some(100_000_000.0),
336            false,
337            Theme::Dark,
338            &targets,
339            Some(100.0),
340        );
341
342        assert!(result.contains("Custom Target"));
343        assert!(result.contains("Another Target"));
344    }
345
346    #[test]
347    fn test_build_profile_targets_calculates_ratio() {
348        // Test that ratio is calculated and displayed
349        let targets = vec![UsageTarget {
350            name: "Test",
351            required_mbps: 50.0,
352            icon: "",
353        }];
354
355        let result = build_profile_targets(
356            Some(200_000_000.0),
357            false,
358            Theme::Dark,
359            &targets,
360            Some(200.0),
361        );
362
363        // Should show 4.0x ratio for 200/50 = 4 (format is {:.1}x for <10x)
364        assert!(result.contains("4.0x"));
365    }
366
367    #[test]
368    fn test_build_profile_targets_shortfall() {
369        // When speed is below requirement, show shortfall
370        let targets = vec![UsageTarget {
371            name: "Test",
372            required_mbps: 100.0,
373            icon: "",
374        }];
375
376        let result =
377            build_profile_targets(Some(30_000_000.0), false, Theme::Dark, &targets, Some(30.0));
378
379        // Should show 70 Mb/s short (100 - 30)
380        assert!(result.contains("short"));
381    }
382
383    #[test]
384    fn test_build_profile_targets_no_download() {
385        let targets = vec![UsageTarget {
386            name: "Test",
387            required_mbps: 50.0,
388            icon: "",
389        }];
390
391        let result = build_profile_targets(None, false, Theme::Dark, &targets, None);
392        assert_eq!(result, "");
393    }
394
395    #[test]
396    fn test_build_targets_nc_mode() {
397        // No color mode should still produce output
398        let result = build_targets(Some(100_000_000.0), true, Theme::Dark);
399        assert!(!result.is_empty());
400        // Should have the header
401        assert!(result.contains("USAGE CHECK"));
402    }
403
404    #[test]
405    fn test_build_profile_targets_nc_mode() {
406        let targets = vec![UsageTarget {
407            name: "Test Target",
408            required_mbps: 50.0,
409            icon: "",
410        }];
411
412        let result = build_profile_targets(
413            Some(100_000_000.0),
414            true,
415            Theme::Dark,
416            &targets,
417            Some(100.0),
418        );
419
420        assert!(result.contains("Test Target"));
421    }
422
423    #[test]
424    fn test_build_none_download() {
425        let result = build(None, false, Theme::Dark);
426        assert_eq!(result, "");
427    }
428
429    #[test]
430    fn test_build_with_download() {
431        let result = build(Some(100_000_000.0), false, Theme::Dark);
432        assert!(!result.is_empty());
433        assert!(result.contains("ESTIMATES"));
434    }
435
436    #[test]
437    fn test_build_all_file_types() {
438        let result = build(Some(100_000_000.0), false, Theme::Dark);
439        // All file estimate names should be present
440        assert!(result.contains("Song / Podcast"));
441        assert!(result.contains("Photo"));
442        assert!(result.contains("App install"));
443        assert!(result.contains("HD movie"));
444        assert!(result.contains("4K movie"));
445        assert!(result.contains("Game install"));
446    }
447
448    #[test]
449    fn test_build_gigabit_speed() {
450        // 1 Gbps should show fast download times
451        let result = build(Some(1_000_000_000.0), false, Theme::Dark);
452        assert!(!result.is_empty());
453    }
454
455    #[test]
456    fn test_build_slow_speed() {
457        // 1 Mbps should show slow download times
458        let result = build(Some(1_000_000.0), false, Theme::Dark);
459        assert!(!result.is_empty());
460    }
461
462    #[test]
463    fn test_build_nc_mode() {
464        // No color mode should still produce output
465        let result = build(Some(100_000_000.0), true, Theme::Dark);
466        assert!(!result.is_empty());
467        assert!(result.contains("ESTIMATES"));
468    }
469
470    #[test]
471    fn test_format_targets_function() {
472        // Should not panic and produces output
473        format_targets(Some(100_000_000.0), false, Theme::Dark);
474    }
475
476    #[test]
477    fn test_format_targets_none() {
478        // Should not panic with None
479        format_targets(None, false, Theme::Dark);
480    }
481
482    #[test]
483    fn test_show_function() {
484        // Should not panic
485        show(Some(100_000_000.0), false, Theme::Dark);
486    }
487
488    #[test]
489    fn test_show_none() {
490        // Should not panic with None
491        show(None, false, Theme::Dark);
492    }
493
494    #[test]
495    fn test_build_profile_targets_no_dl_mbps() {
496        // When download is set but dl_mbps is None, should calculate from download
497        let targets = vec![UsageTarget {
498            name: "Test",
499            required_mbps: 50.0,
500            icon: "",
501        }];
502
503        // Pass None for dl_mbps but Some for download_bps - should still work
504        let result = build_profile_targets(
505            Some(100_000_000.0),
506            false,
507            Theme::Dark,
508            &targets,
509            None, // dl_mbps is None
510        );
511
512        assert!(!result.is_empty());
513    }
514
515    #[test]
516    fn test_high_ratio_rounds_correctly() {
517        // Test that 10x+ shows integer only
518        let targets = vec![UsageTarget {
519            name: "Test",
520            required_mbps: 10.0,
521            icon: "",
522        }];
523
524        let result = build_profile_targets(
525            Some(500_000_000.0),
526            false,
527            Theme::Dark,
528            &targets,
529            Some(500.0),
530        );
531
532        // 50x should show "50x" not "50.0x"
533        assert!(result.contains("50x"));
534    }
535}