Skip to main content

raps_kernel/
progress.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Progress bar and spinner utilities
5//!
6//! Provides centralized progress bar creation with automatic handling of
7//! non-interactive mode. Progress bars are hidden when running in CI/CD
8//! or when output is piped.
9
10use indicatif::{ProgressBar, ProgressStyle};
11
12use crate::interactive;
13
14/// Standard progress bar style for file operations (upload/download)
15const FILE_PROGRESS_TEMPLATE: &str =
16    "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({percent}%)";
17
18/// Standard spinner style for async operations
19const SPINNER_TEMPLATE: &str = "{spinner:.cyan} {msg}";
20
21/// Progress bar characters
22const PROGRESS_CHARS: &str = "█▓░";
23
24/// Create a progress bar for file operations (upload/download)
25///
26/// Automatically hides the progress bar in non-interactive mode.
27pub fn file_progress(size: u64, message: &str) -> ProgressBar {
28    let pb = if interactive::is_non_interactive() {
29        ProgressBar::hidden()
30    } else {
31        ProgressBar::new(size)
32    };
33
34    pb.set_style(
35        ProgressStyle::default_bar()
36            .template(FILE_PROGRESS_TEMPLATE)
37            .expect("hardcoded progress template is valid")
38            .progress_chars(PROGRESS_CHARS),
39    );
40    pb.set_message(message.to_string());
41    pb
42}
43
44/// Create a spinner for async/waiting operations
45///
46/// Automatically hides the spinner in non-interactive mode.
47pub fn spinner(message: &str) -> ProgressBar {
48    let pb = if interactive::is_non_interactive() {
49        ProgressBar::hidden()
50    } else {
51        ProgressBar::new_spinner()
52    };
53
54    pb.set_style(
55        ProgressStyle::default_spinner()
56            .template(SPINNER_TEMPLATE)
57            .expect("hardcoded progress template is valid"),
58    );
59    pb.set_message(message.to_string());
60
61    if !interactive::is_non_interactive() {
62        pb.enable_steady_tick(std::time::Duration::from_millis(100));
63    }
64
65    pb
66}
67
68/// Create a progress bar for counting items
69///
70/// Automatically hides the progress bar in non-interactive mode.
71pub fn item_progress(count: u64, message: &str) -> ProgressBar {
72    let pb = if interactive::is_non_interactive() {
73        ProgressBar::hidden()
74    } else {
75        ProgressBar::new(count)
76    };
77
78    pb.set_style(
79        ProgressStyle::default_bar()
80            .template("{msg} [{bar:40.cyan/blue}] {pos}/{len}")
81            .expect("hardcoded progress template is valid")
82            .progress_chars(PROGRESS_CHARS),
83    );
84    pb.set_message(message.to_string());
85    pb
86}
87
88/// Progress bar guard that ensures proper cleanup on drop
89///
90/// Use this when there's a risk of early return or panic during progress operations.
91pub struct ProgressGuard {
92    pb: ProgressBar,
93    abandon_on_drop: bool,
94}
95
96impl ProgressGuard {
97    /// Create a new progress guard wrapping a progress bar
98    pub fn new(pb: ProgressBar) -> Self {
99        Self {
100            pb,
101            abandon_on_drop: true,
102        }
103    }
104
105    /// Mark the progress as successfully completed
106    ///
107    /// Call this when the operation completes successfully to prevent
108    /// the guard from abandoning the progress bar on drop.
109    pub fn finish(mut self, message: &str) {
110        self.abandon_on_drop = false;
111        self.pb.finish_with_message(message.to_string());
112    }
113
114    /// Get a reference to the underlying progress bar
115    pub fn progress(&self) -> &ProgressBar {
116        &self.pb
117    }
118}
119
120impl Drop for ProgressGuard {
121    fn drop(&mut self) {
122        if self.abandon_on_drop {
123            self.pb.abandon();
124        }
125    }
126}
127
128impl std::ops::Deref for ProgressGuard {
129    type Target = ProgressBar;
130
131    fn deref(&self) -> &Self::Target {
132        &self.pb
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn reset_interactive_state() {
141        interactive::init(false, false);
142        interactive::MOCK_IS_TERMINAL.store(true, std::sync::atomic::Ordering::Relaxed);
143    }
144
145    fn set_non_interactive() {
146        interactive::init(true, false);
147    }
148
149    // ==================== File Progress Tests ====================
150
151    #[test]
152    fn test_file_progress_creation() {
153        reset_interactive_state();
154        let pb = file_progress(1000, "Uploading");
155        assert_eq!(pb.length(), Some(1000));
156    }
157
158    #[test]
159    fn test_file_progress_zero_size() {
160        reset_interactive_state();
161        let pb = file_progress(0, "Uploading empty file");
162        assert_eq!(pb.length(), Some(0));
163    }
164
165    #[test]
166    fn test_file_progress_large_size() {
167        reset_interactive_state();
168        let pb = file_progress(u64::MAX, "Uploading huge file");
169        assert_eq!(pb.length(), Some(u64::MAX));
170    }
171
172    #[test]
173    fn test_file_progress_non_interactive() {
174        set_non_interactive();
175        let pb = file_progress(1000, "Uploading");
176        // In non-interactive mode, progress bar is hidden
177        // Hidden progress bars return None for length
178        assert!(pb.length().is_none());
179        reset_interactive_state();
180    }
181
182    // ==================== Spinner Tests ====================
183
184    #[test]
185    fn test_spinner_creation() {
186        reset_interactive_state();
187        let pb = spinner("Processing...");
188        assert!(pb.length().is_none()); // Spinners have no length
189    }
190
191    #[test]
192    fn test_spinner_non_interactive() {
193        set_non_interactive();
194        let pb = spinner("Processing...");
195        assert!(pb.length().is_none());
196        reset_interactive_state();
197    }
198
199    #[test]
200    fn test_spinner_empty_message() {
201        reset_interactive_state();
202        let pb = spinner("");
203        assert!(pb.length().is_none());
204    }
205
206    // ==================== Item Progress Tests ====================
207
208    #[test]
209    fn test_item_progress_creation() {
210        reset_interactive_state();
211        let pb = item_progress(10, "Processing items");
212        assert_eq!(pb.length(), Some(10));
213    }
214
215    #[test]
216    fn test_item_progress_single_item() {
217        reset_interactive_state();
218        let pb = item_progress(1, "Processing item");
219        assert_eq!(pb.length(), Some(1));
220    }
221
222    #[test]
223    fn test_item_progress_zero_items() {
224        reset_interactive_state();
225        let pb = item_progress(0, "No items");
226        assert_eq!(pb.length(), Some(0));
227    }
228
229    #[test]
230    fn test_item_progress_non_interactive() {
231        set_non_interactive();
232        let pb = item_progress(10, "Processing items");
233        // Hidden progress bars return None for length
234        assert!(pb.length().is_none());
235        reset_interactive_state();
236    }
237
238    // ==================== Progress Guard Tests ====================
239
240    #[test]
241    fn test_progress_guard_finish() {
242        reset_interactive_state();
243        let pb = file_progress(100, "Test");
244        let guard = ProgressGuard::new(pb);
245        guard.finish("Done");
246        // No panic on drop
247    }
248
249    #[test]
250    fn test_progress_guard_abandon_on_drop() {
251        reset_interactive_state();
252        let pb = file_progress(100, "Test");
253        let _guard = ProgressGuard::new(pb);
254        // Guard will abandon on drop - this shouldn't panic
255    }
256
257    #[test]
258    fn test_progress_guard_deref() {
259        reset_interactive_state();
260        let pb = file_progress(100, "Test");
261        let guard = ProgressGuard::new(pb);
262        // Can use deref to access progress bar methods
263        assert_eq!(guard.length(), Some(100));
264    }
265
266    #[test]
267    fn test_progress_guard_progress_method() {
268        reset_interactive_state();
269        let pb = file_progress(100, "Test");
270        let guard = ProgressGuard::new(pb);
271        // Can access underlying progress bar
272        assert_eq!(guard.progress().length(), Some(100));
273    }
274
275    #[test]
276    fn test_progress_guard_increment() {
277        reset_interactive_state();
278        let pb = file_progress(100, "Test");
279        let guard = ProgressGuard::new(pb);
280        guard.inc(50);
281        assert_eq!(guard.position(), 50);
282    }
283
284    #[test]
285    fn test_progress_guard_set_position() {
286        reset_interactive_state();
287        let pb = file_progress(100, "Test");
288        let guard = ProgressGuard::new(pb);
289        guard.set_position(75);
290        assert_eq!(guard.position(), 75);
291    }
292
293    // ==================== Constants Tests ====================
294
295    #[test]
296    fn test_progress_chars_length() {
297        // "█▓░" - 3 UTF-8 characters, 9 bytes total (3 bytes each)
298        assert_eq!(PROGRESS_CHARS.len(), 9);
299        assert_eq!(PROGRESS_CHARS.chars().count(), 3);
300    }
301
302    #[test]
303    fn test_file_template_contains_bar() {
304        assert!(FILE_PROGRESS_TEMPLATE.contains("{bar:"));
305    }
306
307    #[test]
308    fn test_spinner_template_contains_spinner() {
309        assert!(SPINNER_TEMPLATE.contains("{spinner"));
310    }
311}