Skip to main content

netspeed_cli/formatter/
ratings.rs

1//! Rating helper functions for speed test results.
2
3use crate::types::TestResult;
4use owo_colors::OwoColorize;
5
6#[must_use]
7pub fn ping_rating(ping_ms: f64) -> &'static str {
8    if ping_ms < 10.0 {
9        "Excellent"
10    } else if ping_ms < 30.0 {
11        "Good"
12    } else if ping_ms < 60.0 {
13        "Fair"
14    } else if ping_ms < 100.0 {
15        "Poor"
16    } else {
17        "Bad"
18    }
19}
20
21#[must_use]
22pub fn speed_rating_mbps(mbps: f64) -> &'static str {
23    if mbps >= 500.0 {
24        "Excellent"
25    } else if mbps >= 200.0 {
26        "Great"
27    } else if mbps >= 100.0 {
28        "Good"
29    } else if mbps >= 50.0 {
30        "Fair"
31    } else if mbps >= 25.0 {
32        "Moderate"
33    } else if mbps >= 10.0 {
34        "Slow"
35    } else {
36        "Very Slow"
37    }
38}
39
40pub fn colorize_rating(rating: &str, nc: bool) -> String {
41    if nc {
42        rating.to_string()
43    } else {
44        match rating {
45            "Excellent" => format!("{}{}", "⚡ ".green().bold(), rating.green().bold()),
46            "Great" => format!("{}{}", "🔵  ".blue(), rating.blue()),
47            "Good" => format!("{}{}", "🟢  ".bright_green(), rating.bright_green()),
48            "Fair" => format!("{}{}", "🟡  ".yellow(), rating.yellow()),
49            "Moderate" => format!("{}{}", "🟠  ".bright_yellow(), rating.bright_yellow()),
50            "Poor" => format!("{}{}", "🔴  ".red(), rating.red()),
51            "Slow" => format!("{}{}", "🟤  ".bright_red(), rating.bright_red()),
52            "Very Slow" => format!("{}{}", "⚠️  ".red().bold(), rating.red().bold()),
53            _ => rating.to_string(),
54        }
55    }
56}
57
58/// Helper struct to hold speed formatting components.
59struct SpeedComponents {
60    value: f64,
61    unit: &'static str,
62}
63
64/// Extract speed components for formatting.
65fn speed_components(bps: f64, bytes: bool) -> SpeedComponents {
66    let divider = if bytes { 8.0 } else { 1.0 };
67    let unit = if bytes { "MB/s" } else { "Mb/s" };
68    let value = bps / divider / 1_000_000.0;
69    SpeedComponents { value, unit }
70}
71
72pub fn format_speed_colored(bps: f64, bytes: bool) -> String {
73    let SpeedComponents { value, unit } = speed_components(bps, bytes);
74    let mbps = bps / 1_000_000.0;
75    let rating = speed_rating_mbps(mbps);
76    match rating {
77        "Excellent" | "Great" => format!("{value:.2} {unit}").green().bold().to_string(),
78        "Good" => format!("{value:.2} {unit}").bright_green().to_string(),
79        "Fair" | "Moderate" => format!("{value:.2} {unit}").yellow().to_string(),
80        "Poor" | "Slow" | "Very Slow" => format!("{value:.2} {unit}").red().to_string(),
81        _ => format!("{value:.2} {unit}"),
82    }
83}
84
85pub fn format_speed_plain(bps: f64, bytes: bool) -> String {
86    let SpeedComponents { value, unit } = speed_components(bps, bytes);
87    format!("{value:.2} {unit}")
88}
89
90pub fn format_duration(secs: f64) -> String {
91    if secs < 60.0 {
92        format!("{secs:.1}s")
93    } else {
94        let mins = secs as u64 / 60;
95        let secs = secs % 60.0;
96        format!("{mins}m {secs:.0}s")
97    }
98}
99
100#[must_use]
101pub fn connection_rating(result: &TestResult) -> &'static str {
102    /// Score a "lower is better" metric (ping, jitter) on a 0–100 scale.
103    fn score_lower(value: f64, thresholds: [f64; 5]) -> f64 {
104        if value < thresholds[0] {
105            100.0
106        } else if value < thresholds[1] {
107            80.0
108        } else if value < thresholds[2] {
109            60.0
110        } else if value < thresholds[3] {
111            40.0
112        } else {
113            20.0
114        }
115    }
116
117    /// Score a "higher is better" metric (download, upload) on a 0–100 scale.
118    fn score_higher(mbps: f64, thresholds: [f64; 6]) -> f64 {
119        if mbps >= thresholds[0] {
120            100.0
121        } else if mbps >= thresholds[1] {
122            85.0
123        } else if mbps >= thresholds[2] {
124            70.0
125        } else if mbps >= thresholds[3] {
126            55.0
127        } else if mbps >= thresholds[4] {
128            40.0
129        } else if mbps >= thresholds[5] {
130            25.0
131        } else {
132            10.0
133        }
134    }
135
136    let mut score = 0.0;
137    let mut factors = 0.0;
138
139    // Ping (lower is better)
140    if let Some(ping) = result.ping {
141        score += score_lower(ping, [10.0, 30.0, 60.0, 100.0, f64::MAX]);
142        factors += 1.0;
143    }
144
145    // Jitter (lower is better)
146    if let Some(jitter) = result.jitter {
147        score += score_lower(jitter, [2.0, 5.0, 10.0, 20.0, f64::MAX]);
148        factors += 1.0;
149    }
150
151    // Download speed (higher is better)
152    if let Some(dl) = result.download {
153        score += score_higher(dl / 1_000_000.0, [500.0, 200.0, 100.0, 50.0, 25.0, 10.0]);
154        factors += 1.0;
155    }
156
157    // Upload speed (higher is better)
158    if let Some(ul) = result.upload {
159        score += score_higher(ul / 1_000_000.0, [500.0, 200.0, 100.0, 50.0, 25.0, 10.0]);
160        factors += 1.0;
161    }
162
163    if factors == 0.0 {
164        return "Unknown";
165    }
166
167    let avg = score / factors;
168    if avg >= 90.0 {
169        "Excellent"
170    } else if avg >= 75.0 {
171        "Great"
172    } else if avg >= 55.0 {
173        "Good"
174    } else if avg >= 40.0 {
175        "Fair"
176    } else if avg >= 25.0 {
177        "Moderate"
178    } else {
179        "Poor"
180    }
181}
182
183/// Bufferbloat grade based on added latency under load.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum BufferbloatGrade {
186    A,
187    B,
188    C,
189    D,
190    F,
191}
192
193impl BufferbloatGrade {
194    #[must_use]
195    pub fn as_str(&self) -> &'static str {
196        match self {
197            Self::A => "A",
198            Self::B => "B",
199            Self::C => "C",
200            Self::D => "D",
201            Self::F => "F",
202        }
203    }
204}
205
206/// Compute bufferbloat grade from latency degradation under load.
207#[must_use]
208pub fn bufferbloat_grade(load_latency: f64, idle_latency: f64) -> (BufferbloatGrade, f64) {
209    let added = if idle_latency > 0.0 {
210        load_latency - idle_latency
211    } else {
212        load_latency
213    };
214    let grade = if added < 5.0 {
215        BufferbloatGrade::A
216    } else if added < 20.0 {
217        BufferbloatGrade::B
218    } else if added < 50.0 {
219        BufferbloatGrade::C
220    } else if added < 100.0 {
221        BufferbloatGrade::D
222    } else {
223        BufferbloatGrade::F
224    };
225    (grade, added.max(0.0))
226}
227
228pub fn bufferbloat_colorized(grade: BufferbloatGrade, added_ms: f64, nc: bool) -> String {
229    if nc {
230        format!("{} ({added_ms:.0}ms)", grade.as_str())
231    } else {
232        let (color, bold) = match grade {
233            BufferbloatGrade::A => ("green", true),
234            BufferbloatGrade::B => ("bright_green", false),
235            BufferbloatGrade::C => ("yellow", false),
236            BufferbloatGrade::D => ("bright_yellow", false),
237            BufferbloatGrade::F => ("red", true),
238        };
239        let text = format!("{} ({added_ms:.0}ms added)", grade.as_str());
240        match (color, bold) {
241            ("green", true) => text.green().bold().to_string(),
242            ("bright_green", _) => text.bright_green().to_string(),
243            ("yellow", _) => text.yellow().to_string(),
244            ("bright_yellow", _) => text.bright_yellow().to_string(),
245            ("red", true) => text.red().bold().to_string(),
246            _ => text.dimmed().to_string(),
247        }
248    }
249}
250
251pub fn format_overall_rating(result: &TestResult, nc: bool) -> String {
252    let rating = connection_rating(result);
253    if nc {
254        format!("  Overall: {rating}")
255    } else {
256        let (icon, color) = match rating {
257            "Excellent" => ("⚡ ", "green"),
258            "Great" => ("🔵  ", "blue"),
259            "Good" => ("🟢  ", "bright_green"),
260            "Fair" => ("🟡  ", "yellow"),
261            "Moderate" => ("🟠  ", "bright_yellow"),
262            "Poor" => ("🔴  ", "red"),
263            _ => ("", ""),
264        };
265        let text = format!("{icon}{rating}");
266        let colored = match color {
267            "green" => text.green().bold().to_string(),
268            "blue" => text.blue().to_string(),
269            "bright_green" => text.bright_green().to_string(),
270            "yellow" => text.yellow().to_string(),
271            "bright_yellow" => text.bright_yellow().to_string(),
272            "red" => text.red().to_string(),
273            _ => text.dimmed().to_string(),
274        };
275        format!("  {} {colored}", "Overall:".dimmed())
276    }
277}
278
279pub fn degradation_str(lat_load: f64, idle_ping: Option<f64>, nc: bool) -> String {
280    let Some(idle) = idle_ping else {
281        return String::new();
282    };
283    if idle <= 0.0 {
284        return String::new();
285    }
286    let pct = ((lat_load / idle) - 1.0) * 100.0;
287    let (label, color) = if pct < 25.0 {
288        ("minimal", "green")
289    } else if pct < 50.0 {
290        ("moderate", "yellow")
291    } else {
292        ("significant", "red")
293    };
294    let text = format!("+{pct:.0}% ({label})");
295    if nc {
296        format!("  [{text:>8}]")
297    } else {
298        let colored = match color {
299            "green" => text.green().to_string(),
300            "yellow" => text.yellow().to_string(),
301            "red" => text.red().to_string(),
302            _ => text.dimmed().to_string(),
303        };
304        format!("  {colored}")
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_ping_rating() {
314        assert_eq!(ping_rating(5.0), "Excellent");
315        assert_eq!(ping_rating(20.0), "Good");
316        assert_eq!(ping_rating(50.0), "Fair");
317        assert_eq!(ping_rating(80.0), "Poor");
318        assert_eq!(ping_rating(150.0), "Bad");
319    }
320
321    #[test]
322    fn test_speed_rating() {
323        assert_eq!(speed_rating_mbps(600.0), "Excellent");
324        assert_eq!(speed_rating_mbps(150.0), "Good");
325        assert_eq!(speed_rating_mbps(5.0), "Very Slow");
326    }
327
328    #[test]
329    fn test_format_duration() {
330        assert_eq!(format_duration(30.0), "30.0s");
331        assert_eq!(format_duration(90.0), "1m 30s");
332    }
333}