1use crate::error::SpeedtestError;
10use crate::progress::no_color;
11use crate::types::{CsvOutput, TestResult};
12use owo_colors::OwoColorize;
13
14pub enum OutputFormat {
17 Json,
18 Csv {
19 delimiter: char,
20 header: bool,
21 },
22 Simple,
23 Detailed {
24 dl_bytes: u64,
25 ul_bytes: u64,
26 dl_duration: f64,
27 ul_duration: f64,
28 dl_skipped: bool,
29 ul_skipped: bool,
30 },
31 Dashboard {
32 dl_mbps: f64,
33 dl_peak_mbps: f64,
34 dl_bytes: u64,
35 dl_duration: f64,
36 ul_mbps: f64,
37 ul_peak_mbps: f64,
38 ul_bytes: u64,
39 ul_duration: f64,
40 },
41}
42
43impl OutputFormat {
44 pub fn format(&self, result: &TestResult, bytes: bool) -> Result<(), SpeedtestError> {
50 match self {
51 OutputFormat::Json => format_json(result),
52 OutputFormat::Csv { delimiter, header } => format_csv(result, *delimiter, *header),
53 OutputFormat::Simple => format_simple(result, bytes),
54 OutputFormat::Detailed {
55 dl_bytes,
56 ul_bytes,
57 dl_duration,
58 ul_duration,
59 dl_skipped,
60 ul_skipped,
61 } => {
62 format_detailed(
63 result,
64 bytes,
65 *dl_bytes,
66 *ul_bytes,
67 *dl_duration,
68 *ul_duration,
69 *dl_skipped,
70 *ul_skipped,
71 )?;
72 format_verbose_sections(result);
73 Ok(())
74 }
75 OutputFormat::Dashboard {
76 dl_mbps,
77 dl_peak_mbps,
78 dl_bytes,
79 dl_duration,
80 ul_mbps,
81 ul_peak_mbps,
82 ul_bytes,
83 ul_duration,
84 } => {
85 dashboard::format_dashboard(
86 result,
87 &dashboard::DashboardSummary {
88 dl_mbps: *dl_mbps,
89 dl_peak_mbps: *dl_peak_mbps,
90 dl_bytes: *dl_bytes,
91 dl_duration: *dl_duration,
92 ul_mbps: *ul_mbps,
93 ul_peak_mbps: *ul_peak_mbps,
94 ul_bytes: *ul_bytes,
95 ul_duration: *ul_duration,
96 },
97 )?;
98 Ok(())
99 }
100 }
101 }
102}
103
104pub mod dashboard;
105pub mod estimates;
106pub mod ratings;
107pub mod sections;
108pub mod stability;
109
110pub use estimates::{format_estimates, format_targets};
112pub use ratings::{
113 BufferbloatGrade, bufferbloat_colorized, bufferbloat_grade, colorize_rating, connection_rating,
114 degradation_str, format_duration, format_overall_rating, format_speed_colored,
115 format_speed_plain, ping_rating, speed_rating_mbps,
116};
117pub use sections::{
118 format_connection_info, format_download_section, format_footer, format_latency_section,
119 format_list, format_test_summary, format_upload_section,
120};
121pub use stability::{compute_cv, compute_percentiles, format_stability_line};
122
123pub fn format_simple(result: &TestResult, bytes: bool) -> Result<(), SpeedtestError> {
130 let nc = no_color();
131 let mut parts = Vec::new();
132
133 if let Some(ping) = result.ping {
134 parts.push(if nc {
135 format!("Latency: {ping:.1} ms")
136 } else {
137 format!("Latency: {} ms", ping.cyan())
138 });
139 }
140
141 if let Some(dl) = result.download {
142 let speed = if nc {
143 ratings::format_speed_plain(dl, bytes)
144 } else {
145 ratings::format_speed_colored(dl, bytes)
146 };
147 parts.push(format!("Download: {speed}"));
148 }
149
150 if let Some(ul) = result.upload {
151 let speed = if nc {
152 ratings::format_speed_plain(ul, bytes)
153 } else {
154 ratings::format_speed_colored(ul, bytes)
155 };
156 parts.push(format!("Upload: {speed}"));
157 }
158
159 eprintln!("{}", parts.join(" | "));
160 Ok(())
161}
162
163#[allow(clippy::too_many_arguments)]
170pub fn format_detailed(
171 result: &TestResult,
172 bytes: bool,
173 dl_bytes: u64,
174 ul_bytes: u64,
175 dl_duration: f64,
176 ul_duration: f64,
177 dl_skipped: bool,
178 ul_skipped: bool,
179) -> Result<(), SpeedtestError> {
180 let nc = no_color();
181
182 if nc {
183 eprintln!("\n TEST RESULTS");
184 } else {
185 eprintln!("\n {}", "TEST RESULTS".bold().underline());
186 }
187 eprintln!("{}", ratings::format_overall_rating(result, nc));
188 eprintln!();
189
190 sections::format_latency_section(result, nc);
191 sections::format_download_section(result, bytes, nc, dl_skipped);
192 sections::format_upload_section(result, bytes, nc, ul_skipped);
193 sections::format_connection_info(result, nc);
194 sections::format_test_summary(dl_bytes, ul_bytes, dl_duration, ul_duration, nc);
195 sections::format_footer(&result.timestamp, nc);
196
197 Ok(())
198}
199
200pub fn format_json(result: &TestResult) -> Result<(), SpeedtestError> {
206 let is_tty = {
207 use std::io::IsTerminal;
208 std::io::stdout().is_terminal()
209 };
210 let output = if is_tty {
211 serde_json::to_string_pretty(result)?
212 } else {
213 serde_json::to_string(result)?
214 };
215 println!("{output}");
216 Ok(())
217}
218
219pub fn format_csv(
225 result: &TestResult,
226 delimiter: char,
227 print_header: bool,
228) -> Result<(), SpeedtestError> {
229 let stdout = std::io::stdout();
230 let mut wtr = csv::WriterBuilder::new()
231 .delimiter(delimiter as u8)
232 .from_writer(stdout);
233 if print_header {
234 wtr.write_record([
235 "Server ID",
236 "Sponsor",
237 "Server Name",
238 "Timestamp",
239 "Distance",
240 "Ping",
241 "Jitter",
242 "Packet Loss",
243 "Download",
244 "Download Peak",
245 "Upload",
246 "Upload Peak",
247 "IP Address",
248 ])?;
249 }
250 let csv_output = CsvOutput {
251 server_id: result.server.id.clone(),
252 sponsor: result.server.sponsor.clone(),
253 server_name: result.server.name.clone(),
254 timestamp: result.timestamp.clone(),
255 distance: result.server.distance,
256 ping: result.ping.unwrap_or(0.0),
257 jitter: result.jitter.unwrap_or(0.0),
258 packet_loss: result.packet_loss.unwrap_or(0.0),
259 download: result.download.unwrap_or(0.0),
260 download_peak: result.download_peak.unwrap_or(0.0),
261 upload: result.upload.unwrap_or(0.0),
262 upload_peak: result.upload_peak.unwrap_or(0.0),
263 ip_address: result.client_ip.clone().unwrap_or_default(),
264 };
265 wtr.serialize(csv_output)?;
266 wtr.flush()?;
267 Ok(())
268}
269
270pub fn format_verbose_sections(result: &TestResult) {
273 let nc = no_color();
274
275 let targets = estimates::build_targets(result.download, nc);
277 if !targets.is_empty() {
278 eprintln!("{targets}");
279 }
280
281 let estimates = estimates::build_estimates(result.download, nc);
283 if !estimates.is_empty() {
284 eprintln!("{estimates}");
285 }
286
287 if let (Some(dl_s), Some(ul_s)) = (&result.download_samples, &result.upload_samples) {
289 let dl_cv = compute_cv(dl_s);
290 let ul_cv = compute_cv(ul_s);
291 let dl_stability = format_stability_line(dl_cv, nc);
292 let ul_stability = format_stability_line(ul_cv, nc);
293 eprintln!();
294 if nc {
295 eprintln!(" STABILITY");
296 } else {
297 eprintln!("\n {}", "STABILITY".bold().underline());
298 }
299 eprintln!(" {:>14}: {dl_stability}", "Download".dimmed());
300 eprintln!(" {:>14}: {ul_stability}", "Upload".dimmed());
301 }
302
303 if let Some(ref samples) = result.ping_samples {
305 if let Some((p50, p95, p99)) = compute_percentiles(samples) {
306 eprintln!();
307 if nc {
308 eprintln!(" LATENCY PERCENTILES");
309 } else {
310 eprintln!("\n {}", "LATENCY PERCENTILES".bold().underline());
311 }
312 let p50_str = format!("{p50:.1} ms");
313 let p95_str = format!("{p95:.1} ms");
314 let p99_str = format!("{p99:.1} ms");
315 if nc {
316 eprintln!(" P50: {p50_str} P95: {p95_str} P99: {p99_str}");
317 } else {
318 eprintln!(
319 " {}: {} {}: {} {}: {}",
320 "P50".dimmed(),
321 p50_str.cyan(),
322 "P95".dimmed(),
323 p95_str.yellow(),
324 "P99".dimmed(),
325 p99_str.red().bold(),
326 );
327 }
328 }
329 }
330
331 let dl_mbps = result.download.map(|d| d / 1_000_000.0).unwrap_or(0.0);
333 let ul_mbps = result.upload.map(|u| u / 1_000_000.0).unwrap_or(0.0);
334 if let Some(comparison) = crate::history::format_comparison(dl_mbps, ul_mbps, nc) {
335 eprintln!();
336 eprintln!(" {comparison}");
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_format_simple_with_data() {
346 use crate::types::{ServerInfo, TestResult};
347 let result = TestResult {
348 server: ServerInfo {
349 id: "1".to_string(),
350 name: "Test".to_string(),
351 sponsor: "Test".to_string(),
352 country: "US".to_string(),
353 distance: 0.0,
354 },
355 ping: Some(10.0),
356 jitter: None,
357 packet_loss: None,
358 download: Some(100_000_000.0),
359 download_peak: None,
360 upload: Some(50_000_000.0),
361 upload_peak: None,
362 latency_download: None,
363 latency_upload: None,
364 download_samples: None,
365 upload_samples: None,
366 ping_samples: None,
367 timestamp: "2026-01-01T00:00:00Z".to_string(),
368 client_ip: None,
369 };
370
371 let _ = format_simple(&result, false);
373 }
374
375 #[test]
376 fn test_format_data_kb() {
377 assert_eq!(crate::common::format_data_size(5120), "5.0 KB");
378 }
379
380 #[test]
381 fn test_format_data_mb() {
382 assert_eq!(crate::common::format_data_size(5_242_880), "5.0 MB");
383 }
384
385 #[test]
386 fn test_format_data_gb() {
387 assert_eq!(crate::common::format_data_size(1_073_741_824), "1.00 GB");
388 }
389
390 #[test]
391 fn test_format_verbose_sections_integration() {
392 use crate::types::{ServerInfo, TestResult};
393 let result = TestResult {
394 server: ServerInfo {
395 id: "1".to_string(),
396 name: "Test".to_string(),
397 sponsor: "Test ISP".to_string(),
398 country: "US".to_string(),
399 distance: 10.0,
400 },
401 ping: Some(10.0),
402 jitter: Some(1.5),
403 packet_loss: Some(0.0),
404 download: Some(100_000_000.0),
405 download_peak: Some(120_000_000.0),
406 upload: Some(50_000_000.0),
407 upload_peak: Some(60_000_000.0),
408 latency_download: Some(15.0),
409 latency_upload: Some(12.0),
410 download_samples: Some(vec![95_000_000.0, 100_000_000.0, 105_000_000.0]),
411 upload_samples: Some(vec![48_000_000.0, 50_000_000.0, 52_000_000.0]),
412 ping_samples: Some(vec![9.5, 10.0, 10.5]),
413 timestamp: "2026-01-01T00:00:00Z".to_string(),
414 client_ip: Some("192.168.1.1".to_string()),
415 };
416
417 format_verbose_sections(&result);
420 }
421
422 #[test]
423 fn test_format_verbose_sections_empty() {
424 use crate::types::{ServerInfo, TestResult};
425 let result = TestResult {
426 server: ServerInfo {
427 id: "1".to_string(),
428 name: "Test".to_string(),
429 sponsor: "Test".to_string(),
430 country: "US".to_string(),
431 distance: 0.0,
432 },
433 ping: None,
434 jitter: None,
435 packet_loss: None,
436 download: None,
437 download_peak: None,
438 upload: None,
439 upload_peak: None,
440 latency_download: None,
441 latency_upload: None,
442 download_samples: None,
443 upload_samples: None,
444 ping_samples: None,
445 timestamp: "2026-01-01T00:00:00Z".to_string(),
446 client_ip: None,
447 };
448
449 format_verbose_sections(&result);
451 }
452}