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
9use crate::error::SpeedtestError;
10use crate::progress::no_color;
11use crate::types::{CsvOutput, TestResult};
12use owo_colors::OwoColorize;
13
14/// Output format selection — Strategy pattern.
15/// Add new variants here to extend output formats (OCP).
16pub 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    /// Execute the formatting strategy.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if output serialization or writing fails.
49    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
110// Re-export commonly used functions for backward compatibility
111pub 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
123/// Simple mode — single line.
124///
125/// # Errors
126///
127/// This function does not currently return errors, but the signature is
128/// `Result` for future extensibility.
129pub 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/// Detailed mode — clean key/value pairs.
164///
165/// # Errors
166///
167/// This function does not currently return errors, but the signature is
168/// `Result` for future extensibility.
169#[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
200/// Output test results as JSON to stdout.
201///
202/// # Errors
203///
204/// Returns [`SpeedtestError::ParseJson`] if serialization fails.
205pub 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
219/// Output test results as CSV to stdout.
220///
221/// # Errors
222///
223/// Returns [`SpeedtestError::Csv`] if CSV serialization fails.
224pub 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
270/// Format additional verbose output sections: stability, latency percentiles, and historical comparison.
271/// Only used in detailed (verbose) mode.
272pub fn format_verbose_sections(result: &TestResult) {
273    let nc = no_color();
274
275    // Usage check targets
276    let targets = estimates::build_targets(result.download, nc);
277    if !targets.is_empty() {
278        eprintln!("{targets}");
279    }
280
281    // Download time estimates
282    let estimates = estimates::build_estimates(result.download, nc);
283    if !estimates.is_empty() {
284        eprintln!("{estimates}");
285    }
286
287    // Speed stability (DL + UL)
288    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    // Latency percentiles
304    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    // Historical comparison
332    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        // Just verify it doesn't panic
372        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        // Exercise the full integration path: targets, estimates, stability,
418        // latency percentiles, and history comparison
419        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        // Should not panic with all None values
450        format_verbose_sections(&result);
451    }
452}