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!(
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
124pub 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
132struct 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 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 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#[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 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 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 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 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 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 let result = build_targets(None, false, Theme::Dark);
282 assert_eq!(result, "");
283 }
284
285 #[test]
286 fn test_build_targets_with_download() {
287 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 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 let result = build_targets(Some(500_000_000.0), false, Theme::Dark);
308 assert!(!result.is_empty());
310 }
311
312 #[test]
313 fn test_build_targets_poor_speed() {
314 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 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 assert!(result.contains("4.0x"));
365 }
366
367 #[test]
368 fn test_build_profile_targets_shortfall() {
369 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 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 let result = build_targets(Some(100_000_000.0), true, Theme::Dark);
399 assert!(!result.is_empty());
400 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 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 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 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 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 format_targets(Some(100_000_000.0), false, Theme::Dark);
474 }
475
476 #[test]
477 fn test_format_targets_none() {
478 format_targets(None, false, Theme::Dark);
480 }
481
482 #[test]
483 fn test_show_function() {
484 show(Some(100_000_000.0), false, Theme::Dark);
486 }
487
488 #[test]
489 fn test_show_none() {
490 show(None, false, Theme::Dark);
492 }
493
494 #[test]
495 fn test_build_profile_targets_no_dl_mbps() {
496 let targets = vec![UsageTarget {
498 name: "Test",
499 required_mbps: 50.0,
500 icon: "",
501 }];
502
503 let result = build_profile_targets(
505 Some(100_000_000.0),
506 false,
507 Theme::Dark,
508 &targets,
509 None, );
511
512 assert!(!result.is_empty());
513 }
514
515 #[test]
516 fn test_high_ratio_rounds_correctly() {
517 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 assert!(result.contains("50x"));
534 }
535}