1#![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
13struct 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#[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#[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
120pub 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
128struct 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 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 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#[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 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 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 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 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 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 let result = build_targets(None, false, Theme::Dark);
274 assert_eq!(result, "");
275 }
276
277 #[test]
278 fn test_build_targets_with_download() {
279 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 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 let result = build_targets(Some(500_000_000.0), false, Theme::Dark);
300 assert!(!result.is_empty());
302 }
303
304 #[test]
305 fn test_build_targets_poor_speed() {
306 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 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 assert!(result.contains("4.0x"));
357 }
358
359 #[test]
360 fn test_build_profile_targets_shortfall() {
361 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 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 let result = build_targets(Some(100_000_000.0), true, Theme::Dark);
391 assert!(!result.is_empty());
392 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 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 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 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 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 format_targets(Some(100_000_000.0), false, Theme::Dark);
466 }
467
468 #[test]
469 fn test_format_targets_none() {
470 format_targets(None, false, Theme::Dark);
472 }
473
474 #[test]
475 fn test_show_function() {
476 show(Some(100_000_000.0), false, Theme::Dark);
478 }
479
480 #[test]
481 fn test_show_none() {
482 show(None, false, Theme::Dark);
484 }
485
486 #[test]
487 fn test_build_profile_targets_no_dl_mbps() {
488 let targets = vec![UsageTarget {
490 name: "Test",
491 required_mbps: 50.0,
492 icon: "",
493 }];
494
495 let result = build_profile_targets(
497 Some(100_000_000.0),
498 false,
499 Theme::Dark,
500 &targets,
501 None, );
503
504 assert!(!result.is_empty());
505 }
506
507 #[test]
508 fn test_high_ratio_rounds_correctly() {
509 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 assert!(result.contains("50x"));
526 }
527}