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