1use crate::common;
8use crate::formatter::ratings;
9use crate::history;
10use crate::progress::no_color;
11use crate::types::TestResult;
12use owo_colors::OwoColorize;
13
14const BOX_WIDTH: usize = 60;
15const BAR_WIDTH: usize = 28;
16
17fn metric_bar(value: f64, max: f64, width: usize, nc: bool) -> String {
19 let bar = common::bar_chart(value, max, width);
20 if nc {
21 bar
22 } else {
23 let fill_pct = (value / max).clamp(0.0, 1.0) * 100.0;
24 if fill_pct >= 70.0 {
25 bar.green().to_string()
26 } else if fill_pct >= 40.0 {
27 bar.yellow().to_string()
28 } else {
29 bar.red().to_string()
30 }
31 }
32}
33
34pub struct DashboardSummary {
36 pub dl_mbps: f64,
37 pub dl_peak_mbps: f64,
38 pub dl_bytes: u64,
39 pub dl_duration: f64,
40 pub ul_mbps: f64,
41 pub ul_peak_mbps: f64,
42 pub ul_bytes: u64,
43 pub ul_duration: f64,
44}
45
46fn section_divider(title: &str, nc: bool) -> String {
48 let title_with_spaces = format!(" {title} ");
49 let dash_count = BOX_WIDTH.saturating_sub(title_with_spaces.len() + 4);
50 let dashes = "─".repeat(dash_count);
51 if nc {
52 format!(" {title_with_spaces}{dashes}")
53 } else {
54 format!(" {}", title_with_spaces.dimmed()) + &dashes.dimmed().to_string()
55 }
56}
57
58fn build_header(result: &TestResult, nc: bool) -> String {
60 let version = env!("CARGO_PKG_VERSION");
61 let title = format!(" netspeed-cli v{version} ");
62 let half_pad = (BOX_WIDTH.saturating_sub(title.len())) / 2;
63 let left_pad = "═".repeat(half_pad);
64 let right_pad = "═".repeat(BOX_WIDTH.saturating_sub(half_pad + title.len()));
65 let title_line = format!("{left_pad}{title}{right_pad}");
66
67 let server_line = format!(
68 " Server: {} ({}) · {} · {}",
69 result.server.sponsor,
70 result.server.name,
71 result.server.country,
72 common::format_distance(result.server.distance)
73 );
74
75 let ip_line = result
76 .client_ip
77 .as_ref()
78 .map(|ip| format!(" Client IP: {ip}"));
79
80 let mut lines = Vec::new();
81
82 if nc {
84 lines.push(format!("╔{title_line}╗"));
85 } else {
86 lines.push(format!("╔{title_line}╗").dimmed().to_string());
87 }
88
89 let padded_server = format!("{server_line:<BOX_WIDTH$}");
91 if nc {
92 lines.push(format!("║{padded_server}║"));
93 } else {
94 lines.push(format!("║{padded_server}║").dimmed().to_string());
95 }
96
97 if let Some(ip) = ip_line {
99 let padded_ip = format!("{ip:<BOX_WIDTH$}");
100 if nc {
101 lines.push(format!("║{padded_ip}║"));
102 } else {
103 lines.push(format!("║{padded_ip}║").dimmed().to_string());
104 }
105 }
106
107 if nc {
109 lines.push(format!("╚{:═<BOX_WIDTH$}╝", ""));
110 } else {
111 lines.push(format!("╚{:═<BOX_WIDTH$}╝", "").dimmed().to_string());
112 }
113
114 lines.join("\n")
115}
116
117fn build_overall_rating(result: &TestResult, nc: bool) -> String {
119 let rating = ratings::connection_rating(result);
120 if nc {
121 format!(" Overall: {rating}")
122 } else {
123 let rating_colored = ratings::colorize_rating(rating, nc);
124 format!(" {} {rating_colored}", "Overall:".dimmed())
125 }
126}
127
128fn build_metric_bars(result: &TestResult, nc: bool) -> String {
130 let mut lines = Vec::new();
131
132 if let Some(ping) = result.ping {
134 let rating = ratings::ping_rating(ping);
135 let bar = metric_bar(ping, 100.0, BAR_WIDTH, nc);
137 if nc {
138 lines.push(format!(
139 " {:<14} {} {:>8.1} ms ({rating})",
140 "Latency", bar, ping
141 ));
142 } else {
143 let ping_str = format!("{ping:.1} ms");
144 let rating_str = ratings::colorize_rating(rating, nc);
145 lines.push(format!(
146 " {:<14} {} {} {}",
147 "Latency".dimmed(),
148 bar,
149 ping_str.cyan().bold(),
150 rating_str,
151 ));
152 }
153 }
154
155 if let Some(dl) = result.download {
157 let dl_mbps = dl / 1_000_000.0;
158 let rating = ratings::speed_rating_mbps(dl_mbps);
159 let bar = metric_bar(dl_mbps, 1000.0, BAR_WIDTH, nc);
160 if nc {
161 lines.push(format!(
162 " {:<14} {} {:>8.2} Mb/s ({rating})",
163 "Download", bar, dl_mbps
164 ));
165 } else {
166 let speed_str = format!("{dl_mbps:.2} Mb/s");
167 let colored_speed = if dl_mbps >= 200.0 {
168 speed_str.green().bold().to_string()
169 } else if dl_mbps >= 50.0 {
170 speed_str.bright_green().to_string()
171 } else if dl_mbps >= 25.0 {
172 speed_str.yellow().to_string()
173 } else {
174 speed_str.red().to_string()
175 };
176 lines.push(format!(
177 " {:<14} {} {} {}",
178 "Download".dimmed(),
179 bar,
180 colored_speed,
181 ratings::colorize_rating(rating, nc),
182 ));
183 }
184 }
185
186 if let Some(ul) = result.upload {
188 let ul_mbps = ul / 1_000_000.0;
189 let rating = ratings::speed_rating_mbps(ul_mbps);
190 let bar = metric_bar(ul_mbps, 1000.0, BAR_WIDTH, nc);
191 if nc {
192 lines.push(format!(
193 " {:<14} {} {:>8.2} Mb/s ({rating})",
194 "Upload", bar, ul_mbps
195 ));
196 } else {
197 let speed_str = format!("{ul_mbps:.2} Mb/s");
198 let colored_speed = if ul_mbps >= 200.0 {
199 speed_str.green().bold().to_string()
200 } else if ul_mbps >= 50.0 {
201 speed_str.bright_green().to_string()
202 } else if ul_mbps >= 25.0 {
203 speed_str.yellow().to_string()
204 } else {
205 speed_str.red().to_string()
206 };
207 lines.push(format!(
208 " {:<14} {} {} {}",
209 "Upload".dimmed(),
210 bar,
211 colored_speed,
212 ratings::colorize_rating(rating, nc),
213 ));
214 }
215 }
216
217 lines.join("\n")
218}
219
220fn build_download_summary(summary: &DashboardSummary, nc: bool) -> String {
222 if summary.dl_duration <= 0.0 {
223 return String::new();
224 }
225
226 let mut lines = Vec::new();
227 lines.push(section_divider("Download Summary", nc));
228
229 let bar = metric_bar(summary.dl_mbps, 1000.0, BAR_WIDTH, nc);
230 if nc {
231 lines.push(format!(
232 " {:<14} {:>8.2} Mb/s {bar}",
233 "Speed:", summary.dl_mbps
234 ));
235 } else {
236 lines.push(format!(
237 " {:<14} {} {}",
238 "Speed:".dimmed(),
239 format!("{:.2} Mb/s", summary.dl_mbps).cyan().bold(),
240 bar,
241 ));
242 }
243
244 if summary.dl_peak_mbps > 0.0 {
245 if nc {
246 lines.push(format!(
247 " {:<14} {:.2} Mb/s",
248 "Peak:", summary.dl_peak_mbps
249 ));
250 } else {
251 lines.push(format!(
252 " {:<14} {}",
253 "Peak:".dimmed(),
254 format!("{:.2} Mb/s", summary.dl_peak_mbps).bright_cyan(),
255 ));
256 }
257 }
258
259 if nc {
260 lines.push(format!(" {:<14} {:.1}s", "Duration:", summary.dl_duration));
261 } else {
262 lines.push(format!(
263 " {:<14} {}",
264 "Duration:".dimmed(),
265 format!("{:.1}s", summary.dl_duration).white(),
266 ));
267 }
268
269 if nc {
270 lines.push(format!(
271 " {:<14} {}",
272 "Transferred:",
273 common::format_data_size(summary.dl_bytes)
274 ));
275 } else {
276 lines.push(format!(
277 " {:<14} {}",
278 "Transferred:".dimmed(),
279 common::format_data_size(summary.dl_bytes).white(),
280 ));
281 }
282
283 lines.join("\n")
284}
285
286fn build_upload_summary(summary: &DashboardSummary, nc: bool) -> String {
288 if summary.ul_duration <= 0.0 {
289 return String::new();
290 }
291
292 let mut lines = Vec::new();
293 lines.push(section_divider("Upload Summary", nc));
294
295 let bar = metric_bar(summary.ul_mbps, 1000.0, BAR_WIDTH, nc);
296 if nc {
297 lines.push(format!(
298 " {:<14} {:>8.2} Mb/s {bar}",
299 "Speed:", summary.ul_mbps
300 ));
301 } else {
302 lines.push(format!(
303 " {:<14} {} {}",
304 "Speed:".dimmed(),
305 format!("{:.2} Mb/s", summary.ul_mbps).yellow().bold(),
306 bar,
307 ));
308 }
309
310 if summary.ul_peak_mbps > 0.0 {
311 if nc {
312 lines.push(format!(
313 " {:<14} {:.2} Mb/s",
314 "Peak:", summary.ul_peak_mbps
315 ));
316 } else {
317 lines.push(format!(
318 " {:<14} {}",
319 "Peak:".dimmed(),
320 format!("{:.2} Mb/s", summary.ul_peak_mbps).bright_yellow(),
321 ));
322 }
323 }
324
325 if nc {
326 lines.push(format!(" {:<14} {:.1}s", "Duration:", summary.ul_duration));
327 } else {
328 lines.push(format!(
329 " {:<14} {}",
330 "Duration:".dimmed(),
331 format!("{:.1}s", summary.ul_duration).white(),
332 ));
333 }
334
335 if nc {
336 lines.push(format!(
337 " {:<14} {}",
338 "Transferred:",
339 common::format_data_size(summary.ul_bytes)
340 ));
341 } else {
342 lines.push(format!(
343 " {:<14} {}",
344 "Transferred:".dimmed(),
345 common::format_data_size(summary.ul_bytes).white(),
346 ));
347 }
348
349 lines.join("\n")
350}
351
352fn build_history(nc: bool) -> String {
354 let recent = history::get_recent_sparkline();
355 if recent.is_empty() {
356 if nc {
357 return String::from(" History: No history available");
358 }
359 return format!(
360 " {} {}",
361 "History:".dimmed(),
362 "No history available".bright_black()
363 );
364 }
365
366 let mut lines = Vec::new();
367 lines.push(section_divider("History", nc));
368
369 let dl_values: Vec<f64> = recent.iter().map(|(_, dl, _)| *dl).collect();
371 let ul_values: Vec<f64> = recent.iter().map(|(_, _, ul)| *ul).collect();
372
373 let dl_spark = history::sparkline(&dl_values);
374 let ul_spark = history::sparkline(&ul_values);
375
376 if nc {
377 lines.push(format!(" DL sparkline: {dl_spark}"));
378 lines.push(format!(" UL sparkline: {ul_spark}"));
379 } else {
380 lines.push(format!(" {} {}", "DL:".dimmed(), dl_spark.green()));
381 lines.push(format!(" {} {}", "UL:".dimmed(), ul_spark.yellow()));
382 }
383
384 for (date, dl, ul) in recent.iter().rev().take(3) {
386 let indicator = if *dl >= 200.0 {
387 "⚡"
388 } else if *dl >= 50.0 {
389 "●"
390 } else if *dl >= 25.0 {
391 "◐"
392 } else {
393 "○"
394 };
395 if nc {
396 lines.push(format!(" {date} {dl:>7.1}↓ / {ul:>6.1}↑ Mb/s"));
397 } else {
398 let indicator_colored = if *dl >= 200.0 {
399 indicator.green().to_string()
400 } else if *dl >= 50.0 {
401 indicator.bright_green().to_string()
402 } else if *dl >= 25.0 {
403 indicator.yellow().to_string()
404 } else {
405 indicator.red().to_string()
406 };
407 lines.push(format!(
408 " {date} {indicator_colored} {}↓ / {}↑ Mb/s",
409 format!("{dl:.1}").green(),
410 format!("{ul:.1}").yellow(),
411 ));
412 }
413 }
414
415 lines.join("\n")
416}
417
418fn build_footer() -> String {
420 format!(
421 " {}",
422 "Tip: Use --list to see servers, --history for full history".bright_black()
423 )
424}
425
426pub fn format_dashboard(
433 result: &TestResult,
434 summary: &DashboardSummary,
435) -> Result<(), crate::error::SpeedtestError> {
436 let nc = no_color();
437
438 eprintln!();
439 eprintln!("{}", build_header(result, nc));
440 eprintln!();
441 eprintln!("{}", build_overall_rating(result, nc));
442 eprintln!();
443 eprintln!("{}", build_metric_bars(result, nc));
444 eprintln!();
445 let dl_summary = build_download_summary(summary, nc);
446 if !dl_summary.is_empty() {
447 eprintln!("{dl_summary}");
448 eprintln!();
449 }
450 let ul_summary = build_upload_summary(summary, nc);
451 if !ul_summary.is_empty() {
452 eprintln!("{ul_summary}");
453 eprintln!();
454 }
455 eprintln!("{}", build_history(nc));
456 eprintln!();
457 eprintln!("{}", build_footer());
458 eprintln!();
459
460 Ok(())
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::types::ServerInfo;
467
468 fn make_result() -> TestResult {
469 TestResult {
470 server: ServerInfo {
471 id: "1".to_string(),
472 name: "TestServer".to_string(),
473 sponsor: "TestISP".to_string(),
474 country: "US".to_string(),
475 distance: 15.0,
476 },
477 ping: Some(12.0),
478 jitter: Some(1.5),
479 packet_loss: Some(0.0),
480 download: Some(150_000_000.0),
481 download_peak: Some(180_000_000.0),
482 upload: Some(50_000_000.0),
483 upload_peak: Some(60_000_000.0),
484 latency_download: Some(18.0),
485 latency_upload: Some(15.0),
486 download_samples: Some(vec![140_000_000.0, 150_000_000.0, 160_000_000.0]),
487 upload_samples: Some(vec![48_000_000.0, 50_000_000.0, 52_000_000.0]),
488 ping_samples: Some(vec![11.0, 12.0, 13.0]),
489 timestamp: "2026-04-06T12:00:00Z".to_string(),
490 client_ip: Some("192.168.1.100".to_string()),
491 }
492 }
493
494 #[test]
495 fn test_metric_bar_half() {
496 let bar = metric_bar(500.0, 1000.0, 20, true);
497 assert_eq!(bar.chars().count(), 20);
498 assert_eq!(bar, "██████████░░░░░░░░░░");
500 }
501
502 #[test]
503 fn test_metric_bar_full() {
504 let bar = metric_bar(1000.0, 1000.0, 10, true);
505 assert_eq!(bar, "██████████");
506 }
507
508 #[test]
509 fn test_build_header() {
510 let result = make_result();
511 let header = build_header(&result, true);
512 assert!(header.contains("netspeed-cli"));
513 assert!(header.contains("TestISP"));
514 assert!(header.contains("192.168.1.100"));
515 assert!(header.starts_with("╔"));
517 assert!(header.contains("╚"));
518 }
519
520 #[test]
521 fn test_build_metric_bars() {
522 let result = make_result();
523 let bars = build_metric_bars(&result, true);
524 assert!(bars.contains("Latency"));
525 assert!(bars.contains("Download"));
526 assert!(bars.contains("Upload"));
527 assert!(bars.contains("█"));
528 }
529
530 #[test]
531 fn test_build_overall_rating() {
532 let result = make_result();
533 let rating = build_overall_rating(&result, true);
534 assert!(rating.contains("Overall"));
535 }
536
537 #[test]
538 fn test_build_download_summary() {
539 let summary = DashboardSummary {
540 dl_mbps: 150.0,
541 dl_peak_mbps: 180.0,
542 dl_bytes: 15_000_000,
543 dl_duration: 3.2,
544 ul_mbps: 50.0,
545 ul_peak_mbps: 60.0,
546 ul_bytes: 5_000_000,
547 ul_duration: 2.1,
548 };
549 let result = build_download_summary(&summary, true);
550 assert!(result.contains("Download Summary"));
551 assert!(result.contains("Speed"));
552 assert!(result.contains("Peak"));
553 assert!(result.contains("150.00"));
554 }
555
556 #[test]
557 fn test_build_upload_summary() {
558 let summary = DashboardSummary {
559 dl_mbps: 150.0,
560 dl_peak_mbps: 180.0,
561 dl_bytes: 15_000_000,
562 dl_duration: 3.2,
563 ul_mbps: 50.0,
564 ul_peak_mbps: 60.0,
565 ul_bytes: 5_000_000,
566 ul_duration: 2.1,
567 };
568 let result = build_upload_summary(&summary, true);
569 assert!(result.contains("Upload Summary"));
570 assert!(result.contains("Speed"));
571 assert!(result.contains("50.00"));
572 }
573
574 #[test]
575 fn test_build_history_no_data() {
576 let section = build_history(true);
578 assert!(section.contains("History"));
580 }
581
582 #[test]
583 fn test_build_footer() {
584 let footer = build_footer();
585 assert!(footer.contains("--list"));
586 assert!(footer.contains("--history"));
587 }
588
589 #[test]
590 fn test_format_dashboard_integration() {
591 let result = make_result();
592 let summary = DashboardSummary {
593 dl_mbps: 150.0,
594 dl_peak_mbps: 180.0,
595 dl_bytes: 15_000_000,
596 dl_duration: 3.2,
597 ul_mbps: 50.0,
598 ul_peak_mbps: 60.0,
599 ul_bytes: 5_000_000,
600 ul_duration: 2.1,
601 };
602 format_dashboard(&result, &summary).unwrap();
604 }
605
606 #[test]
607 fn test_format_dashboard_no_color() {
608 let result = make_result();
609 let summary = DashboardSummary {
610 dl_mbps: 150.0,
611 dl_peak_mbps: 180.0,
612 dl_bytes: 15_000_000,
613 dl_duration: 3.2,
614 ul_mbps: 50.0,
615 ul_peak_mbps: 60.0,
616 ul_bytes: 5_000_000,
617 ul_duration: 2.1,
618 };
619 #[allow(unsafe_code)]
621 unsafe {
622 std::env::set_var("NO_COLOR", "1");
623 }
624 format_dashboard(&result, &summary).unwrap();
625 #[allow(unsafe_code)]
627 unsafe {
628 std::env::remove_var("NO_COLOR");
629 }
630 }
631
632 #[test]
633 fn test_section_divider() {
634 let div = section_divider("Speed", true);
635 assert!(div.contains("Speed"));
636 assert!(div.contains("─"));
637 }
638}