Skip to main content

netspeed_cli/formatter/
ratings.rs

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