Skip to main content

netspeed_cli/formatter/
mod.rs

1//! Output formatting for speed test results.
2//!
3//! This module is organized into submodules:
4//! - [`ratings`] — Rating helper functions (ping, speed, connection, bufferbloat)
5//! - [`sections`] — Output section formatters (latency, download, upload, etc.)
6//! - [`stability`] — Speed stability analysis and latency percentiles
7//! - [`estimates`] — Usage check targets and download time estimates
8
9#![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
20/// Output format selection — Strategy pattern.
21/// Add new variants here to extend output formats (OCP).
22pub 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    /// Execute the formatting strategy.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if output serialization or writing fails.
43    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
74// Re-export commonly used functions for backward compatibility
75pub 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
87/// Simple mode — single line.
88///
89/// # Errors
90///
91/// This function does not currently return errors, but the signature is
92/// `Result` for future extensibility.
93pub 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
127/// Detailed mode — clean key/value pairs.
128///
129/// # Errors
130///
131/// This function does not currently return errors, but the signature is
132/// `Result` for future extensibility.
133pub 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
161/// Output test results as JSON to stdout.
162///
163/// # Errors
164///
165/// Returns [`SpeedtestError::ParseJson`] if serialization fails.
166pub 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
180/// Output test results as CSV to stdout.
181///
182/// # Errors
183///
184/// Returns [`SpeedtestError::Csv`] if CSV serialization fails.
185pub 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
231/// Format additional verbose output sections: stability, latency percentiles, and historical comparison.
232/// Only used in detailed (verbose) mode.
233pub fn format_verbose_sections(result: &TestResult) {
234    let nc = no_color();
235
236    // Usage check targets
237    let targets = estimates::build_targets(result.download, nc);
238    if !targets.is_empty() {
239        eprintln!("{targets}");
240    }
241
242    // Download time estimates
243    let estimates = estimates::build_estimates(result.download, nc);
244    if !estimates.is_empty() {
245        eprintln!("{estimates}");
246    }
247
248    // Speed stability (DL + UL)
249    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    // Latency percentiles
265    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    // Historical comparison
293    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        // Just verify it doesn't panic
333        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}