1#![allow(
10 clippy::cast_precision_loss,
11 clippy::cast_possible_truncation,
12 clippy::cast_sign_loss
13)]
14
15use crate::error::SpeedtestError;
16use crate::progress::no_color;
17use crate::types::{CsvOutput, TestResult};
18use owo_colors::OwoColorize;
19
20pub enum OutputFormat {
23 Json,
24 Csv {
25 delimiter: char,
26 header: bool,
27 },
28 Simple,
29 Detailed {
30 dl_bytes: u64,
31 ul_bytes: u64,
32 dl_duration: f64,
33 ul_duration: f64,
34 },
35}
36
37impl OutputFormat {
38 pub fn format(&self, result: &TestResult, bytes: bool) -> Result<(), SpeedtestError> {
44 match self {
45 OutputFormat::Json => format_json(result),
46 OutputFormat::Csv { delimiter, header } => format_csv(result, *delimiter, *header),
47 OutputFormat::Simple => format_simple(result, bytes),
48 OutputFormat::Detailed {
49 dl_bytes,
50 ul_bytes,
51 dl_duration,
52 ul_duration,
53 } => {
54 format_detailed(
55 result,
56 bytes,
57 *dl_bytes,
58 *ul_bytes,
59 *dl_duration,
60 *ul_duration,
61 )?;
62 format_verbose_sections(result);
63 Ok(())
64 }
65 }
66 }
67}
68
69pub mod estimates;
70pub mod ratings;
71pub mod sections;
72pub mod stability;
73
74pub use estimates::{format_estimates, format_targets};
76pub use ratings::{
77 BufferbloatGrade, bufferbloat_colorized, bufferbloat_grade, colorize_rating, connection_rating,
78 degradation_str, format_duration, format_overall_rating, format_speed_colored,
79 format_speed_plain, ping_rating, speed_rating_mbps,
80};
81pub use sections::{
82 format_connection_info, format_download_section, format_footer, format_latency_section,
83 format_list, format_test_summary, format_upload_section,
84};
85pub use stability::{compute_cv, compute_percentiles, format_stability_line};
86
87pub fn format_simple(result: &TestResult, bytes: bool) -> Result<(), SpeedtestError> {
94 let nc = no_color();
95 let mut parts = Vec::new();
96
97 if let Some(ping) = result.ping {
98 parts.push(if nc {
99 format!("{ping:.1} ms")
100 } else {
101 format!("Latency: {} ms", ping.cyan())
102 });
103 }
104
105 if let Some(dl) = result.download {
106 let speed = if nc {
107 ratings::format_speed_plain(dl, bytes)
108 } else {
109 ratings::format_speed_colored(dl, bytes)
110 };
111 parts.push(format!("Download: {speed}"));
112 }
113
114 if let Some(ul) = result.upload {
115 let speed = if nc {
116 ratings::format_speed_plain(ul, bytes)
117 } else {
118 ratings::format_speed_colored(ul, bytes)
119 };
120 parts.push(format!("Upload: {speed}"));
121 }
122
123 eprintln!("{}", parts.join(" | "));
124 Ok(())
125}
126
127pub fn format_detailed(
134 result: &TestResult,
135 bytes: bool,
136 dl_bytes: u64,
137 ul_bytes: u64,
138 dl_duration: f64,
139 ul_duration: f64,
140) -> Result<(), SpeedtestError> {
141 let nc = no_color();
142
143 if nc {
144 eprintln!("\n TEST RESULTS");
145 } else {
146 eprintln!("\n {}", "TEST RESULTS".bold().underline());
147 }
148 eprintln!("{}", ratings::format_overall_rating(result, nc));
149 eprintln!();
150
151 sections::format_latency_section(result, nc);
152 sections::format_download_section(result, bytes, nc);
153 sections::format_upload_section(result, bytes, nc);
154 sections::format_connection_info(result, nc);
155 sections::format_test_summary(dl_bytes, ul_bytes, dl_duration, ul_duration, nc);
156 sections::format_footer(&result.timestamp, nc);
157
158 Ok(())
159}
160
161pub fn format_json(result: &TestResult) -> Result<(), SpeedtestError> {
167 let is_tty = {
168 use std::io::IsTerminal;
169 std::io::stdout().is_terminal()
170 };
171 let output = if is_tty {
172 serde_json::to_string_pretty(result)?
173 } else {
174 serde_json::to_string(result)?
175 };
176 println!("{output}");
177 Ok(())
178}
179
180pub fn format_csv(
186 result: &TestResult,
187 delimiter: char,
188 print_header: bool,
189) -> Result<(), SpeedtestError> {
190 let stdout = std::io::stdout();
191 let mut wtr = csv::WriterBuilder::new()
192 .delimiter(delimiter as u8)
193 .from_writer(stdout);
194 if print_header {
195 wtr.write_record([
196 "Server ID",
197 "Sponsor",
198 "Server Name",
199 "Timestamp",
200 "Distance",
201 "Ping",
202 "Jitter",
203 "Packet Loss",
204 "Download",
205 "Download Peak",
206 "Upload",
207 "Upload Peak",
208 "IP Address",
209 ])?;
210 }
211 let csv_output = CsvOutput {
212 server_id: result.server.id.clone(),
213 sponsor: result.server.sponsor.clone(),
214 server_name: result.server.name.clone(),
215 timestamp: result.timestamp.clone(),
216 distance: result.server.distance,
217 ping: result.ping.unwrap_or(0.0),
218 jitter: result.jitter.unwrap_or(0.0),
219 packet_loss: result.packet_loss.unwrap_or(0.0),
220 download: result.download.unwrap_or(0.0),
221 download_peak: result.download_peak.unwrap_or(0.0),
222 upload: result.upload.unwrap_or(0.0),
223 upload_peak: result.upload_peak.unwrap_or(0.0),
224 ip_address: result.client_ip.clone().unwrap_or_default(),
225 };
226 wtr.serialize(csv_output)?;
227 wtr.flush()?;
228 Ok(())
229}
230
231pub fn format_verbose_sections(result: &TestResult) {
234 let nc = no_color();
235
236 let targets = estimates::build_targets(result.download, nc);
238 if !targets.is_empty() {
239 eprintln!("{targets}");
240 }
241
242 let estimates = estimates::build_estimates(result.download, nc);
244 if !estimates.is_empty() {
245 eprintln!("{estimates}");
246 }
247
248 if let (Some(dl_s), Some(ul_s)) = (&result.download_samples, &result.upload_samples) {
250 let dl_cv = compute_cv(dl_s);
251 let ul_cv = compute_cv(ul_s);
252 let dl_stability = format_stability_line(dl_cv, nc);
253 let ul_stability = format_stability_line(ul_cv, nc);
254 eprintln!();
255 if nc {
256 eprintln!(" STABILITY");
257 } else {
258 eprintln!("\n {}", "STABILITY".bold().underline());
259 }
260 eprintln!(" {:>14}: {dl_stability}", "Download".dimmed());
261 eprintln!(" {:>14}: {ul_stability}", "Upload".dimmed());
262 }
263
264 if let Some(ref samples) = result.ping_samples {
266 if let Some((p50, p95, p99)) = compute_percentiles(samples) {
267 eprintln!();
268 if nc {
269 eprintln!(" LATENCY PERCENTILES");
270 } else {
271 eprintln!("\n {}", "LATENCY PERCENTILES".bold().underline());
272 }
273 let p50_str = format!("{p50:.1} ms");
274 let p95_str = format!("{p95:.1} ms");
275 let p99_str = format!("{p99:.1} ms");
276 if nc {
277 eprintln!(" P50: {p50_str} P95: {p95_str} P99: {p99_str}");
278 } else {
279 eprintln!(
280 " {}: {} {}: {} {}: {}",
281 "P50".dimmed(),
282 p50_str.cyan(),
283 "P95".dimmed(),
284 p95_str.yellow(),
285 "P99".dimmed(),
286 p99_str.red().bold(),
287 );
288 }
289 }
290 }
291
292 let dl_mbps = result.download.map(|d| d / 1_000_000.0).unwrap_or(0.0);
294 let ul_mbps = result.upload.map(|u| u / 1_000_000.0).unwrap_or(0.0);
295 if let Some(comparison) = crate::history::format_comparison(dl_mbps, ul_mbps, nc) {
296 eprintln!();
297 eprintln!(" {comparison}");
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_format_simple_with_data() {
307 use crate::types::{ServerInfo, TestResult};
308 let result = TestResult {
309 server: ServerInfo {
310 id: "1".to_string(),
311 name: "Test".to_string(),
312 sponsor: "Test".to_string(),
313 country: "US".to_string(),
314 distance: 0.0,
315 },
316 ping: Some(10.0),
317 jitter: None,
318 packet_loss: None,
319 download: Some(100_000_000.0),
320 download_peak: None,
321 upload: Some(50_000_000.0),
322 upload_peak: None,
323 latency_download: None,
324 latency_upload: None,
325 download_samples: None,
326 upload_samples: None,
327 ping_samples: None,
328 timestamp: "2026-01-01T00:00:00Z".to_string(),
329 client_ip: None,
330 };
331
332 let _ = format_simple(&result, false);
334 }
335
336 #[test]
337 fn test_format_data_kb() {
338 assert_eq!(crate::common::format_data_size(5120), "5.0 KB");
339 }
340
341 #[test]
342 fn test_format_data_mb() {
343 assert_eq!(crate::common::format_data_size(5_242_880), "5.0 MB");
344 }
345
346 #[test]
347 fn test_format_data_gb() {
348 assert_eq!(crate::common::format_data_size(1_073_741_824), "1.00 GB");
349 }
350}