1use crate::common;
4use crate::error::Error;
5use crate::grades::{self, LetterGrade};
6use crate::profiles::UserProfile;
7use crate::terminal;
8use crate::theme::{Colors, Theme};
9use crate::types::TestResult;
10use owo_colors::OwoColorize;
11
12pub struct Summary {
13 pub dl_mbps: f64,
14 pub dl_peak_mbps: f64,
15 pub dl_bytes: u64,
16 pub dl_duration: f64,
17 pub ul_mbps: f64,
18 pub ul_peak_mbps: f64,
19 pub ul_bytes: u64,
20 pub ul_duration: f64,
21 pub elapsed: std::time::Duration,
22 pub profile: UserProfile,
23 pub theme: Theme,
24}
25
26fn gauge_scale(peak_mbps: f64) -> f64 {
28 const BREAKPOINTS: &[f64] = &[50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0];
29 BREAKPOINTS
30 .iter()
31 .copied()
32 .find(|&b| b >= peak_mbps * 1.1)
33 .unwrap_or(peak_mbps * 1.1)
34}
35
36fn render_sparkline_from_samples(samples: &[f64], width: usize) -> String {
37 if samples.len() < 2 {
38 return String::new();
39 }
40 let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
41 let len = width.max(2);
42 let min = samples.iter().cloned().reduce(f64::min).unwrap_or(0.0);
43 let max = samples.iter().cloned().reduce(f64::max).unwrap_or(1.0);
44 let range = max - min;
45 if range < 0.001 {
46 return chars[4].to_string().repeat(len);
47 }
48 let step = if samples.len() > len {
49 samples.len() / len
50 } else {
51 1
52 };
53 (0..len)
54 .map(|i| {
55 let idx = ((i * step) + (step / 2)).min(samples.len() - 1);
56 let norm = ((samples[idx] - min) / range).clamp(0.0, 1.0);
57 let ci = (norm * (chars.len() - 1) as f64).round() as usize;
58 chars[ci.clamp(0, chars.len() - 1)]
59 })
60 .collect()
61}
62
63fn speed_trend(samples: &[f64]) -> &'static str {
64 if samples.len() < 6 {
65 return "→";
66 }
67 let n = samples.len();
68 let recent: f64 = samples[n - 2..].iter().copied().sum::<f64>() / 2.0;
69 let older: f64 = samples[n - 5..n - 2].iter().copied().sum::<f64>() / 3.0;
70 let ratio = recent / older.max(0.01);
71 if ratio > 1.05 {
72 "↑"
73 } else if ratio < 0.95 {
74 "↓"
75 } else {
76 "→"
77 }
78}
79
80pub fn boxed_header(grade: &LetterGrade, nc: bool, theme: Theme, term_w: usize) -> String {
82 let box_w = term_w.min(80);
83 let inner_w = box_w.saturating_sub(4);
85 let content_w = inner_w.saturating_sub(4);
87
88 let score = grade.score() as u32;
89 let grade_str = grade.as_str();
90 let desc_str = grade.description();
91 let left_text = format!("◉ NETSPEED · {desc_str}");
92 let right_text = format!("{grade_str} · {score}");
93
94 let left_vis = left_text.chars().count();
96 let right_vis = right_text.chars().count();
97 let padding = content_w.saturating_sub(left_vis + right_vis);
98
99 let top = format!(" ┌{}┐", "─".repeat(inner_w));
100 let bot = format!(" └{}┘", "─".repeat(inner_w));
101
102 if nc {
103 let mid = format!(" │ {left_text}{}{} │", " ".repeat(padding), right_text);
104 format!("{top}\n{mid}\n{bot}")
105 } else {
106 let left_col = format!(
107 "{} · {}",
108 Colors::bold("◉ NETSPEED", theme),
109 Colors::dimmed(desc_str, theme),
110 );
111 let right_col = format!(
112 "{}{}",
113 grade.color_str(nc, theme),
114 Colors::dimmed(&format!(" · {score}"), theme),
115 );
116 let top_col = format!(" ┌{}┐", "─".repeat(inner_w).dimmed());
117 let bot_col = format!(" └{}┘", "─".repeat(inner_w).dimmed());
118 let mid = format!(" │ {left_col}{}{} │", " ".repeat(padding), right_col);
119 format!("{top_col}\n{mid}\n{bot_col}")
120 }
121}
122
123fn speed_block(
125 dir: &str,
126 label: &str,
127 speed_mbps: f64,
128 peak_mbps: f64,
129 max_mbps: f64,
130 theme: Theme,
131 gauge_w: usize,
132 sparkline: &str,
133 trend: &str,
134 nc: bool,
135) -> String {
136 let pct = (speed_mbps / max_mbps).clamp(0.0, 1.0);
137 let filled = (pct * gauge_w as f64).round() as usize;
138 let bar = format!(
139 "{}{}",
140 "█".repeat(filled),
141 "░".repeat(gauge_w.saturating_sub(filled))
142 );
143
144 let speed_str = if speed_mbps < 1000.0 {
145 format!("{speed_mbps:.1} Mb/s")
146 } else {
147 format!("{:.2} Gb/s", speed_mbps / 1000.0)
148 };
149 let peak_str = if peak_mbps < 1000.0 {
150 format!("peak {peak_mbps:.0}")
151 } else {
152 format!("peak {:.1}G", peak_mbps / 1000.0)
153 };
154
155 let indent = " ".repeat(2 + dir.chars().count() + 2 + 10 + 2);
158
159 if nc {
160 let line1 = format!(" {dir} {label:<10} {bar} {speed_str} {peak_str}");
161 if sparkline.is_empty() {
162 line1
163 } else {
164 format!("{line1}\n{indent}{sparkline} {trend}")
165 }
166 } else {
167 let bar_col = if pct >= 0.7 {
168 Colors::good(&bar, theme)
169 } else if pct >= 0.4 {
170 Colors::warn(&bar, theme)
171 } else {
172 Colors::bad(&bar, theme)
173 };
174 let speed_col = if pct >= 0.7 {
175 Colors::good(&speed_str, theme)
176 } else if pct >= 0.4 {
177 Colors::warn(&speed_str, theme)
178 } else {
179 Colors::bad(&speed_str, theme)
180 };
181 let dir_col = Colors::muted(dir, theme);
182 let label_col = Colors::dimmed(&format!("{label:<10}"), theme);
185 let peak_col = Colors::dimmed(&peak_str, theme);
186 let trend_col = Colors::dimmed(trend, theme);
187
188 let line1 = format!(" {dir_col} {label_col} {bar_col} {speed_col} {peak_col}");
189
190 if sparkline.is_empty() {
191 line1
192 } else {
193 let spark_col = if dir == "↓" {
194 Colors::info(sparkline, theme)
195 } else {
196 Colors::good(sparkline, theme)
197 };
198 format!("{line1}\n{indent}{spark_col} {trend_col}")
199 }
200 }
201}
202
203fn latency_row(
205 ping_ms: f64,
206 jitter: Option<f64>,
207 packet_loss: Option<f64>,
208 nc: bool,
209 theme: Theme,
210) -> String {
211 let gauge_w: usize = 12;
212 let max_ping = 100.0;
213 let pct = (ping_ms / max_ping).clamp(0.0, 1.0);
214 let filled = ((1.0 - pct) * gauge_w as f64).round() as usize;
215 let bar = format!(
216 "{}{}",
217 "█".repeat(filled),
218 "░".repeat(gauge_w.saturating_sub(filled))
219 );
220
221 let bar_col = if ping_ms <= 20.0 {
222 Colors::good(&bar, theme)
223 } else if ping_ms <= 50.0 {
224 Colors::warn(&bar, theme)
225 } else {
226 Colors::bad(&bar, theme)
227 };
228
229 let ping_str = format!("{ping_ms:.1} ms");
230 let ping_col = if ping_ms <= 20.0 {
231 Colors::good(&ping_str, theme)
232 } else if ping_ms <= 50.0 {
233 Colors::warn(&ping_str, theme)
234 } else {
235 Colors::bad(&ping_str, theme)
236 };
237
238 let mut parts = if nc {
239 format!(" ◈ Latency {bar} {ping_str}")
240 } else {
241 let lbl = Colors::muted("◈", theme);
242 format!(
243 " {lbl} {} {bar_col} {ping_col}",
244 Colors::dimmed("Latency", theme)
245 )
246 };
247
248 if let Some(j) = jitter {
249 let j_str = format!("{j:.1} ms");
250 if nc {
251 parts.push_str(&format!(" ◈ Jitter {j_str}"));
252 } else {
253 let lbl = Colors::muted("◈", theme);
254 parts.push_str(&format!(
255 " {lbl} {} {}",
256 Colors::dimmed("Jitter", theme),
257 Colors::info(&j_str, theme),
258 ));
259 }
260 }
261
262 if let Some(loss) = packet_loss {
263 let l_str = format!("{loss:.1}%");
264 if nc {
265 parts.push_str(&format!(" ◈ Loss {l_str}"));
266 } else {
267 let lbl = Colors::muted("◈", theme);
268 let loss_col = if loss < 0.5 {
269 Colors::good(&l_str, theme)
270 } else if loss < 2.0 {
271 Colors::warn(&l_str, theme)
272 } else {
273 Colors::bad(&l_str, theme)
274 };
275 parts.push_str(&format!(
276 " {lbl} {} {loss_col}",
277 Colors::dimmed("Loss", theme),
278 ));
279 }
280 }
281
282 parts
283}
284
285fn thin_separator(w: usize, nc: bool, theme: Theme) -> String {
287 let line = "╌".repeat(w.min(78));
288 if nc {
289 format!(" {line}")
290 } else {
291 format!(" {}", Colors::dimmed(&line, theme))
292 }
293}
294
295fn connection_info(result: &TestResult, nc: bool, theme: Theme) -> String {
297 let distance = common::format_distance(result.server.distance);
298 let server_line = if nc {
299 format!(
300 " ◈ Server {} · {} · {}",
301 result.server.sponsor, result.server.country, distance
302 )
303 } else {
304 let lbl = Colors::muted("◈", theme);
305 format!(
306 " {lbl} {} {} {} {} {} {}",
307 Colors::dimmed("Server", theme),
308 result.server.sponsor,
309 Colors::muted("·", theme),
310 Colors::dimmed(&result.server.country, theme),
311 Colors::muted("·", theme),
312 Colors::dimmed(&distance, theme),
313 )
314 };
315
316 let client_line = result.client_ip.as_deref().map(|ip| {
317 if nc {
318 format!("\n ◈ Client {ip}")
319 } else {
320 let lbl = Colors::muted("◈", theme);
321 format!(
322 "\n {lbl} {} {}",
323 Colors::dimmed("Client", theme),
324 Colors::dimmed(ip, theme),
325 )
326 }
327 });
328
329 match client_line {
330 Some(cl) => format!("{server_line}{cl}"),
331 None => server_line,
332 }
333}
334
335fn data_summary(summary: &Summary, nc: bool, theme: Theme) -> String {
337 let elapsed = summary.elapsed.as_secs_f64();
338 let total = summary.dl_bytes + summary.ul_bytes;
339
340 let mut parts: Vec<String> = Vec::new();
341
342 if summary.dl_bytes > 0 {
343 let s = format!(
344 "Downloaded {} in {:.1}s",
345 common::format_data_size(summary.dl_bytes),
346 summary.dl_duration
347 );
348 parts.push(s);
349 }
350 if summary.ul_bytes > 0 {
351 let s = format!(
352 "Uploaded {} in {:.1}s",
353 common::format_data_size(summary.ul_bytes),
354 summary.ul_duration
355 );
356 parts.push(s);
357 }
358 if total > 0 {
359 parts.push(format!("Total {}", common::format_data_size(total)));
360 }
361 parts.push(format!("{elapsed:.1}s"));
362
363 let sep = if nc {
364 " · ".to_string()
365 } else {
366 format!(" {} ", Colors::muted("·", theme))
367 };
368
369 let joined = parts.join(&sep);
370
371 if nc {
372 format!(" {joined}")
373 } else {
374 format!(" {}", Colors::dimmed(&joined, theme))
375 }
376}
377
378pub fn show(result: &TestResult, summary: &Summary) -> Result<(), Error> {
379 let nc = terminal::no_color() || summary.theme == Theme::Monochrome;
380 let theme = summary.theme;
381 let term_w = common::get_terminal_width().unwrap_or(90) as usize;
382 let gauge_w = (term_w.saturating_sub(52)).clamp(15, 35);
383 let gauge_max = gauge_scale(summary.dl_peak_mbps.max(summary.ul_peak_mbps).max(1.0));
384 let spark_w = (term_w.saturating_sub(54)).clamp(8, 30);
385
386 let overall_grade = grades::grade_overall(
387 result.ping,
388 result.jitter,
389 result.download,
390 result.upload,
391 summary.profile,
392 );
393
394 eprintln!();
396 eprintln!("{}", boxed_header(&overall_grade, nc, theme, term_w));
397 eprintln!();
398
399 let dl_spark = result
401 .download_samples
402 .as_deref()
403 .map(|s| render_sparkline_from_samples(s, spark_w))
404 .unwrap_or_default();
405 let dl_trend = result
406 .download_samples
407 .as_deref()
408 .map(speed_trend)
409 .unwrap_or("→");
410
411 eprintln!(
412 "{}",
413 speed_block(
414 "↓",
415 "Download",
416 summary.dl_mbps,
417 summary.dl_peak_mbps,
418 gauge_max,
419 theme,
420 gauge_w,
421 &dl_spark,
422 dl_trend,
423 nc,
424 )
425 );
426 eprintln!();
427
428 let ul_spark = result
430 .upload_samples
431 .as_deref()
432 .map(|s| render_sparkline_from_samples(s, spark_w))
433 .unwrap_or_default();
434 let ul_trend = result
435 .upload_samples
436 .as_deref()
437 .map(speed_trend)
438 .unwrap_or("→");
439
440 eprintln!(
441 "{}",
442 speed_block(
443 "↑",
444 "Upload",
445 summary.ul_mbps,
446 summary.ul_peak_mbps,
447 gauge_max,
448 theme,
449 gauge_w,
450 &ul_spark,
451 ul_trend,
452 nc,
453 )
454 );
455 eprintln!();
456
457 if let Some(ping) = result.ping {
459 eprintln!(
460 "{}",
461 latency_row(ping, result.jitter, result.packet_loss, nc, theme)
462 );
463 eprintln!();
464 }
465
466 eprintln!("{}", thin_separator(term_w, nc, theme));
468 eprintln!();
469 eprintln!("{}", connection_info(result, nc, theme));
470 eprintln!();
471
472 eprintln!("{}", thin_separator(term_w, nc, theme));
474 eprintln!();
475 eprintln!("{}", data_summary(summary, nc, theme));
476 eprintln!();
477
478 Ok(())
479}