Skip to main content

netspeed_cli/
progress.rs

1//! Terminal progress bars and spinners for test feedback.
2//!
3//! This module provides user interface components for test progress:
4//! - [`SpeedProgress`] — Progress bar with real-time speed display
5//! - Spinners for individual test phases (server discovery, ping, etc.)
6//! - `NO_COLOR` environment variable support for disabling colored output
7//! - Colorized finish messages with test results
8
9#![allow(
10    clippy::cast_precision_loss,
11    clippy::cast_possible_truncation,
12    clippy::cast_sign_loss
13)]
14
15use crate::common;
16use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
17use owo_colors::OwoColorize;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, Ordering};
20
21/// Detect if `NO_COLOR` environment variable is set
22#[must_use]
23pub fn no_color() -> bool {
24    std::env::var("NO_COLOR").is_ok()
25}
26
27/// A progress tracker for download/upload tests.
28/// Updates a single shared progress bar with live speed.
29pub struct SpeedProgress {
30    bar: ProgressBar,
31    done: Arc<AtomicBool>,
32}
33
34impl SpeedProgress {
35    /// Create a new progress tracker for a test phase.
36    /// `label` is something like "Download" or "Upload".
37    #[must_use]
38    pub fn new(label: &str) -> Self {
39        Self::with_target(label, ProgressDrawTarget::stderr_with_hz(10))
40    }
41
42    /// Create with a custom draw target (use `ProgressDrawTarget::hidden()` for silent mode).
43    #[must_use]
44    pub fn with_target(label: &str, target: ProgressDrawTarget) -> Self {
45        let done = Arc::new(AtomicBool::new(false));
46        let bar = ProgressBar::with_draw_target(Some(100), target);
47
48        let style = ProgressStyle::with_template(
49            "  {prefix} {bar:40.cyan/blue} {percent:>3}%  {elapsed_precise} | {msg}",
50        )
51        .unwrap()
52        .progress_chars("━╾─");
53
54        bar.set_style(style);
55        bar.set_prefix(if no_color() {
56            format!("{:<10}", format!("{}:", label))
57        } else {
58            format!("{:<10}", format!("{label}:").dimmed())
59        });
60        bar.set_message("starting...");
61        bar.set_position(0);
62
63        Self { bar, done }
64    }
65
66    /// Update the live speed and data display.
67    /// `speed_mbps` is the current speed in Mb/s (or MB/s if bytes mode).
68    /// `progress` is 0.0 to 1.0.
69    /// `bytes` is total bytes transferred so far.
70    pub fn update(&self, speed_mbps: f64, progress: f64, bytes: u64) {
71        let speed_str = if speed_mbps < 1000.0 {
72            format!("{speed_mbps:.1} Mb/s")
73        } else {
74            format!("{:.2} Gb/s", speed_mbps / 1000.0)
75        };
76
77        let data_str = common::format_data_size(bytes);
78
79        let msg = if no_color() {
80            format!("{data_str} @ {speed_str}")
81        } else {
82            format!("{} @ {}", data_str.white(), speed_str.cyan())
83        };
84
85        self.bar.set_message(msg);
86        let pct = (progress * 100.0) as u64;
87        self.bar.set_position(pct.min(100));
88    }
89
90    /// Mark the test as complete and display final speed.
91    pub fn finish(&self, final_speed_mbps: f64, total_bytes: u64) {
92        let speed_str = if final_speed_mbps < 1000.0 {
93            format!("{final_speed_mbps:.2} Mb/s")
94        } else {
95            format!("{:.2} Gb/s", final_speed_mbps / 1000.0)
96        };
97
98        let data_str = common::format_data_size(total_bytes);
99
100        self.bar.set_position(100);
101        let msg = if no_color() {
102            format!("DONE ({data_str} total @ {speed_str})")
103        } else {
104            format!(
105                "{} ({} total @ {})",
106                "DONE".green().bold(),
107                data_str.dimmed(),
108                speed_str.green()
109            )
110        };
111        self.bar.finish_with_message(msg);
112        self.done.store(true, Ordering::Relaxed);
113    }
114}
115
116/// Simple spinner for non-speed phases (server fetch, ping).
117#[must_use]
118pub fn create_spinner(message: &str) -> ProgressBar {
119    let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(10));
120    pb.set_style(
121        ProgressStyle::with_template("  {spinner} {msg}")
122            .unwrap()
123            .tick_strings(&["·", "o", "O", "o"]),
124    );
125    pb.set_message(message.to_string());
126    pb.enable_steady_tick(std::time::Duration::from_millis(120));
127    pb
128}
129
130/// Finish a simple spinner with a checkmark.
131pub fn finish_ok(pb: &ProgressBar, message: &str) {
132    if no_color() {
133        pb.finish_with_message(format!("  {message}"));
134    } else {
135        pb.finish_with_message(format!("  {} {}", "✓".green(), message));
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use serial_test::serial;
143
144    /// Safely set NO_COLOR env var
145    fn set_no_color() {
146        // SAFETY: Tests using this function are marked with #[serial] to prevent concurrent env access
147        unsafe { std::env::set_var("NO_COLOR", "1") }
148    }
149
150    /// Safely remove NO_COLOR env var
151    fn unset_no_color() {
152        // SAFETY: Tests using this function are marked with #[serial] to prevent concurrent env access
153        unsafe { std::env::remove_var("NO_COLOR") }
154    }
155
156    #[test]
157    fn test_no_color_default() {
158        // Note: This may return true if NO_COLOR is set by another test.
159        // We just verify the function doesn't panic.
160        let _ = no_color();
161    }
162
163    #[test]
164    fn test_create_spinner() {
165        let pb = create_spinner("Testing...");
166        assert!(!pb.is_finished());
167        pb.finish_and_clear();
168    }
169
170    #[test]
171    fn test_finish_ok() {
172        let pb = create_spinner("Testing...");
173        finish_ok(&pb, "Done");
174        assert!(pb.is_finished());
175    }
176
177    #[test]
178    fn test_speed_progress_new() {
179        let sp = SpeedProgress::new("Download");
180        assert!(!sp.done.load(Ordering::Relaxed));
181        sp.bar.finish_and_clear();
182    }
183
184    #[test]
185    fn test_speed_progress_update() {
186        let sp = SpeedProgress::new("Download");
187        sp.update(125.4, 0.5, 5_000_000);
188        sp.finish(125.40, 10_000_000);
189        assert!(sp.done.load(Ordering::Relaxed));
190    }
191
192    #[test]
193    #[serial]
194    fn test_no_color_env_set() {
195        set_no_color();
196        assert!(no_color());
197        unset_no_color();
198    }
199
200    #[test]
201    #[serial]
202    fn test_create_spinner_nc() {
203        set_no_color();
204        let pb = create_spinner("Testing...");
205        assert!(!pb.is_finished());
206        pb.finish_and_clear();
207        unset_no_color();
208    }
209
210    #[test]
211    #[serial]
212    fn test_finish_ok_nc() {
213        set_no_color();
214        let pb = create_spinner("Testing...");
215        finish_ok(&pb, "Done");
216        assert!(pb.is_finished());
217        unset_no_color();
218    }
219
220    #[test]
221    #[serial]
222    fn test_speed_progress_nc() {
223        set_no_color();
224        let sp = SpeedProgress::new("Download");
225        sp.update(125.4, 0.5, 5_000_000);
226        sp.finish(125.40, 10_000_000);
227        assert!(sp.done.load(Ordering::Relaxed));
228        unset_no_color();
229    }
230}