Skip to main content

tokmd_progress/
lib.rs

1//! Progress spinner utilities for long-running operations.
2
3#[cfg(feature = "ui")]
4use std::io::IsTerminal;
5
6/// Check if we should show interactive output.
7#[cfg(feature = "ui")]
8fn is_interactive() -> bool {
9    // Check if stderr is a TTY (since the spinner writes to stderr).
10    if !std::io::stderr().is_terminal() {
11        return false;
12    }
13
14    // Respect standard and tool-specific controls.
15    if std::env::var("NO_COLOR").is_ok() {
16        return false;
17    }
18    if std::env::var("TOKMD_NO_PROGRESS").is_ok() {
19        return false;
20    }
21
22    true
23}
24
25#[cfg(feature = "ui")]
26mod ui_impl {
27    use super::is_interactive;
28    use indicatif::{ProgressBar, ProgressStyle};
29    use std::time::{Duration, Instant};
30
31    /// A progress indicator that wraps indicatif.
32    pub struct Progress {
33        bar: Option<ProgressBar>,
34    }
35
36    impl Progress {
37        /// Create a new progress indicator.
38        ///
39        /// The spinner is only shown if:
40        /// - `enabled` is true
41        /// - stderr is a TTY
42        /// - NO_COLOR env var is not set
43        /// - TOKMD_NO_PROGRESS env var is not set
44        pub fn new(enabled: bool) -> Self {
45            let should_show = enabled && is_interactive();
46
47            let bar = if should_show {
48                let pb = ProgressBar::new_spinner();
49                pb.set_style(
50                    ProgressStyle::with_template("{spinner:.cyan} {msg}")
51                        .expect("progress template is static and must be valid")
52                        .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", " "]),
53                );
54                pb.enable_steady_tick(Duration::from_millis(80));
55                Some(pb)
56            } else {
57                None
58            };
59
60            Self { bar }
61        }
62
63        /// Set the progress message.
64        pub fn set_message(&self, msg: impl Into<String>) {
65            if let Some(bar) = &self.bar {
66                bar.set_message(msg.into());
67            }
68        }
69
70        /// Finish and clear the spinner.
71        pub fn finish_and_clear(&self) {
72            if let Some(bar) = &self.bar {
73                bar.finish_and_clear();
74            }
75        }
76    }
77
78    impl Drop for Progress {
79        fn drop(&mut self) {
80            if let Some(bar) = &self.bar {
81                bar.finish_and_clear();
82            }
83        }
84    }
85
86    /// A progress bar with ETA support for long-running operations.
87    #[allow(dead_code)]
88    pub struct ProgressBarWithEta {
89        bar: Option<indicatif::ProgressBar>,
90        start_time: Option<Instant>,
91    }
92
93    #[allow(dead_code)]
94    impl ProgressBarWithEta {
95        /// Create a new progress bar with ETA.
96        pub fn new(enabled: bool, total: u64, message: &str) -> Self {
97            let should_show = enabled && is_interactive();
98
99            let (bar, start_time) = if should_show {
100                let pb = indicatif::ProgressBar::new(total);
101                pb.set_style(
102                    ProgressStyle::with_template(
103                        "{spinner:.cyan} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
104                    )
105                    .expect("progress template is static and must be valid"),
106                );
107                pb.set_message(message.to_string());
108                pb.enable_steady_tick(Duration::from_millis(100));
109                (Some(pb), Some(Instant::now()))
110            } else {
111                (None, None)
112            };
113
114            Self { bar, start_time }
115        }
116
117        /// Increment the progress by 1.
118        pub fn inc(&self) {
119            if let Some(bar) = &self.bar {
120                bar.inc(1);
121            }
122        }
123
124        /// Increment the progress by a specific amount.
125        pub fn inc_by(&self, delta: u64) {
126            if let Some(bar) = &self.bar {
127                bar.inc(delta);
128            }
129        }
130
131        /// Set the current progress position.
132        pub fn set_position(&self, pos: u64) {
133            if let Some(bar) = &self.bar {
134                bar.set_position(pos);
135            }
136        }
137
138        /// Set the progress message.
139        pub fn set_message(&self, msg: &str) {
140            if let Some(bar) = &self.bar {
141                bar.set_message(msg.to_string());
142            }
143        }
144
145        /// Update the total length.
146        pub fn set_length(&self, len: u64) {
147            if let Some(bar) = &self.bar {
148                bar.set_length(len);
149            }
150        }
151
152        /// Finish the progress bar with a message.
153        pub fn finish_with_message(&self, msg: &str) {
154            if let Some(bar) = &self.bar {
155                bar.finish_with_message(msg.to_string());
156            }
157        }
158
159        /// Finish and clear the progress bar.
160        pub fn finish_and_clear(&self) {
161            if let Some(bar) = &self.bar {
162                bar.finish_and_clear();
163            }
164        }
165
166        /// Elapsed runtime since this bar was created.
167        #[allow(dead_code)]
168        pub fn elapsed(&self) -> Option<Duration> {
169            self.start_time.map(|t| t.elapsed())
170        }
171    }
172
173    impl Drop for ProgressBarWithEta {
174        fn drop(&mut self) {
175            if let Some(bar) = &self.bar {
176                bar.finish_and_clear();
177            }
178        }
179    }
180}
181
182#[cfg(not(feature = "ui"))]
183mod ui_impl {
184    /// A no-op progress indicator when the `ui` feature is disabled.
185    pub struct Progress;
186
187    impl Progress {
188        /// Create a new progress indicator (no-op without `ui` feature).
189        pub fn new(_enabled: bool) -> Self {
190            Self
191        }
192
193        /// Set the progress message (no-op without `ui` feature).
194        pub fn set_message(&self, _msg: impl Into<String>) {}
195
196        /// Finish and clear the spinner (no-op without `ui` feature).
197        pub fn finish_and_clear(&self) {}
198    }
199
200    /// A no-op progress bar when `ui` feature is disabled.
201    #[allow(dead_code)]
202    pub struct ProgressBarWithEta;
203
204    #[allow(dead_code)]
205    impl ProgressBarWithEta {
206        /// Create a new progress bar (no-op without `ui` feature).
207        pub fn new(_enabled: bool, _total: u64, _message: &str) -> Self {
208            Self
209        }
210
211        /// Increment the progress (no-op without `ui` feature).
212        pub fn inc(&self) {}
213
214        /// Increment the progress by a specific amount (no-op without `ui` feature).
215        pub fn inc_by(&self, _delta: u64) {}
216
217        /// Set the current progress position (no-op without `ui` feature).
218        pub fn set_position(&self, _pos: u64) {}
219
220        /// Set the progress message (no-op without `ui` feature).
221        pub fn set_message(&self, _msg: &str) {}
222
223        /// Update the total length (no-op without `ui` feature).
224        pub fn set_length(&self, _len: u64) {}
225
226        /// Finish the progress bar (no-op without `ui` feature).
227        pub fn finish_with_message(&self, _msg: &str) {}
228
229        /// Finish and clear the progress bar (no-op without `ui` feature).
230        pub fn finish_and_clear(&self) {}
231    }
232}
233
234pub use ui_impl::{Progress, ProgressBarWithEta};
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn progress_methods_do_not_panic_when_disabled() {
242        let progress = Progress::new(false);
243        progress.set_message("test");
244        progress.finish_and_clear();
245    }
246
247    #[test]
248    fn progress_bar_methods_do_not_panic_when_disabled() {
249        let progress = ProgressBarWithEta::new(false, 10, "scan");
250        progress.inc();
251        progress.inc_by(2);
252        progress.set_position(3);
253        progress.set_message("updated");
254        progress.set_length(20);
255        progress.finish_with_message("done");
256        progress.finish_and_clear();
257    }
258}