Skip to main content

opencode_cloud_core/docker/
progress.rs

1//! Progress reporting utilities for Docker operations
2//!
3//! This module provides progress bars and spinners for Docker image
4//! builds and pulls, using indicatif for terminal output.
5
6use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10/// Minimum time between spinner message updates to prevent flickering
11const SPINNER_UPDATE_THROTTLE: Duration = Duration::from_millis(150);
12
13/// Strip ANSI escape codes from a string
14///
15/// Docker build output often contains ANSI color codes that can interfere
16/// with our spinner display. This removes them for clean output.
17fn strip_ansi_codes(s: &str) -> String {
18    let mut result = String::with_capacity(s.len());
19    let mut chars = s.chars().peekable();
20
21    while let Some(c) = chars.next() {
22        if c == '\x1b' {
23            // Check for CSI sequence: ESC [
24            if chars.peek() == Some(&'[') {
25                chars.next(); // consume '['
26                // Skip until we hit a letter (the command character)
27                while let Some(&next) = chars.peek() {
28                    chars.next();
29                    if next.is_ascii_alphabetic() {
30                        break;
31                    }
32                }
33            }
34            // Also handle ESC followed by other sequences (less common)
35        } else {
36            result.push(c);
37        }
38    }
39
40    result
41}
42
43/// Format duration as MM:SS, or HH:MM:SS if over an hour
44fn format_elapsed(duration: Duration) -> String {
45    let total_secs = duration.as_secs();
46    let hours = total_secs / 3600;
47    let minutes = (total_secs % 3600) / 60;
48    let seconds = total_secs % 60;
49
50    if hours > 0 {
51        format!("{hours:02}:{minutes:02}:{seconds:02}")
52    } else {
53        format!("{minutes:02}:{seconds:02}")
54    }
55}
56
57/// Progress reporter for Docker operations
58///
59/// Manages multiple progress bars for concurrent operations like
60/// multi-layer image pulls and build steps.
61pub struct ProgressReporter {
62    multi: MultiProgress,
63    bars: HashMap<String, ProgressBar>,
64    last_update: HashMap<String, Instant>,
65    last_message: HashMap<String, String>,
66    start_time: Instant,
67    /// Optional context prefix shown before step messages (e.g., "Building Docker image")
68    context: Option<String>,
69}
70
71impl Default for ProgressReporter {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl ProgressReporter {
78    /// Create a new progress reporter
79    pub fn new() -> Self {
80        Self {
81            multi: MultiProgress::new(),
82            bars: HashMap::new(),
83            last_update: HashMap::new(),
84            last_message: HashMap::new(),
85            start_time: Instant::now(),
86            context: None,
87        }
88    }
89
90    /// Create a new progress reporter with a context prefix
91    ///
92    /// The context is shown before step messages, e.g., "Building Docker image · Step 1/10"
93    pub fn with_context(context: &str) -> Self {
94        Self {
95            multi: MultiProgress::new(),
96            bars: HashMap::new(),
97            last_update: HashMap::new(),
98            last_message: HashMap::new(),
99            start_time: Instant::now(),
100            context: Some(context.to_string()),
101        }
102    }
103
104    /// Format a message with context prefix if set
105    fn format_message(&self, message: &str) -> String {
106        let elapsed = format_elapsed(self.start_time.elapsed());
107
108        // Strip ANSI escape codes that Docker may include in its output
109        let stripped = strip_ansi_codes(message);
110
111        // Collapse message to single line for spinner display:
112        // - Replace all whitespace sequences (including newlines) with single space
113        // - Trim leading/trailing whitespace
114        let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
115
116        // Format: "[elapsed] Context · message" or "[elapsed] message"
117        // Timer at the beginning for easy scanning
118        match &self.context {
119            Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
120            None => format!("[{elapsed}] {clean_msg}"),
121        }
122    }
123
124    /// Create a spinner for indeterminate progress (e.g., build steps)
125    pub fn add_spinner(&mut self, id: &str, message: &str) -> &ProgressBar {
126        let spinner = self.multi.add(ProgressBar::new_spinner());
127        spinner.set_style(
128            ProgressStyle::default_spinner()
129                .template("{spinner:.green} {msg}")
130                .expect("valid template")
131                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
132        );
133        spinner.set_message(self.format_message(message));
134        spinner.enable_steady_tick(std::time::Duration::from_millis(100));
135        self.bars.insert(id.to_string(), spinner);
136        self.bars.get(id).expect("just inserted")
137    }
138
139    /// Create a progress bar for determinate progress (e.g., layer download)
140    ///
141    /// `total` is in bytes
142    pub fn add_bar(&mut self, id: &str, total: u64) -> &ProgressBar {
143        let bar = self.multi.add(ProgressBar::new(total));
144        bar.set_style(
145            ProgressStyle::default_bar()
146                .template(
147                    "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}",
148                )
149                .expect("valid template")
150                .progress_chars("=>-"),
151        );
152        bar.enable_steady_tick(std::time::Duration::from_millis(100));
153        self.bars.insert(id.to_string(), bar);
154        self.bars.get(id).expect("just inserted")
155    }
156
157    /// Update progress for a layer (used during image pull)
158    ///
159    /// `current` and `total` are in bytes, `status` is the Docker status message
160    pub fn update_layer(&mut self, layer_id: &str, current: u64, total: u64, status: &str) {
161        if let Some(bar) = self.bars.get(layer_id) {
162            // Update total if it changed (Docker sometimes updates this)
163            if bar.length() != Some(total) && total > 0 {
164                bar.set_length(total);
165            }
166            bar.set_position(current);
167            bar.set_message(status.to_string());
168        } else {
169            // Create new bar for this layer
170            let bar = self.add_bar(layer_id, total);
171            bar.set_position(current);
172            bar.set_message(status.to_string());
173        }
174    }
175
176    /// Update spinner message (used during build)
177    ///
178    /// Updates are throttled to prevent flickering from rapid message changes.
179    /// "Step X/Y" messages always update immediately as they indicate significant progress.
180    pub fn update_spinner(&mut self, id: &str, message: &str) {
181        let now = Instant::now();
182        let is_step_message = message.starts_with("Step ");
183
184        // Check if we should throttle this update
185        if !is_step_message {
186            if let Some(last) = self.last_update.get(id) {
187                if now.duration_since(*last) < SPINNER_UPDATE_THROTTLE {
188                    return; // Throttle: too soon since last update
189                }
190            }
191
192            // Skip if message is identical to last one
193            if let Some(last_msg) = self.last_message.get(id) {
194                if last_msg == message {
195                    return;
196                }
197            }
198        }
199
200        // Perform the update with context and elapsed time
201        let formatted = self.format_message(message);
202
203        if let Some(spinner) = self.bars.get(id) {
204            spinner.set_message(formatted);
205        } else {
206            // Create new spinner if doesn't exist
207            self.add_spinner(id, message);
208        }
209
210        // Track update time and message
211        self.last_update.insert(id.to_string(), now);
212        self.last_message
213            .insert(id.to_string(), message.to_string());
214    }
215
216    /// Mark a layer/step as complete
217    pub fn finish(&mut self, id: &str, message: &str) {
218        if let Some(bar) = self.bars.get(id) {
219            bar.finish_with_message(message.to_string());
220        }
221    }
222
223    /// Mark all progress as complete
224    pub fn finish_all(&self, message: &str) {
225        for bar in self.bars.values() {
226            bar.finish_with_message(message.to_string());
227        }
228    }
229
230    /// Mark all progress as failed
231    pub fn abandon_all(&self, message: &str) {
232        for bar in self.bars.values() {
233            bar.abandon_with_message(message.to_string());
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn progress_reporter_creation() {
244        let reporter = ProgressReporter::new();
245        assert!(reporter.bars.is_empty());
246    }
247
248    #[test]
249    fn progress_reporter_default() {
250        let reporter = ProgressReporter::default();
251        assert!(reporter.bars.is_empty());
252    }
253
254    #[test]
255    fn add_spinner_creates_entry() {
256        let mut reporter = ProgressReporter::new();
257        reporter.add_spinner("test", "Testing...");
258        assert!(reporter.bars.contains_key("test"));
259    }
260
261    #[test]
262    fn add_bar_creates_entry() {
263        let mut reporter = ProgressReporter::new();
264        reporter.add_bar("layer1", 1000);
265        assert!(reporter.bars.contains_key("layer1"));
266    }
267
268    #[test]
269    fn update_layer_creates_if_missing() {
270        let mut reporter = ProgressReporter::new();
271        reporter.update_layer("layer1", 500, 1000, "Downloading");
272        assert!(reporter.bars.contains_key("layer1"));
273    }
274
275    #[test]
276    fn update_spinner_creates_if_missing() {
277        let mut reporter = ProgressReporter::new();
278        reporter.update_spinner("step1", "Building...");
279        assert!(reporter.bars.contains_key("step1"));
280    }
281
282    #[test]
283    fn finish_handles_missing_id() {
284        let mut reporter = ProgressReporter::new();
285        // Should not panic on missing id
286        reporter.finish("nonexistent", "Done");
287    }
288
289    #[test]
290    fn finish_all_handles_empty() {
291        let reporter = ProgressReporter::new();
292        // Should not panic on empty
293        reporter.finish_all("Done");
294    }
295
296    #[test]
297    fn abandon_all_handles_empty() {
298        let reporter = ProgressReporter::new();
299        // Should not panic on empty
300        reporter.abandon_all("Failed");
301    }
302
303    #[test]
304    fn format_elapsed_shows_seconds_only() {
305        let duration = Duration::from_secs(45);
306        assert_eq!(format_elapsed(duration), "00:45");
307    }
308
309    #[test]
310    fn format_elapsed_shows_minutes_and_seconds() {
311        let duration = Duration::from_secs(90); // 1m 30s
312        assert_eq!(format_elapsed(duration), "01:30");
313    }
314
315    #[test]
316    fn format_elapsed_shows_hours_when_needed() {
317        let duration = Duration::from_secs(3661); // 1h 1m 1s
318        assert_eq!(format_elapsed(duration), "01:01:01");
319    }
320
321    #[test]
322    fn format_elapsed_zero() {
323        let duration = Duration::from_secs(0);
324        assert_eq!(format_elapsed(duration), "00:00");
325    }
326
327    #[test]
328    fn with_context_sets_context() {
329        let reporter = ProgressReporter::with_context("Building Docker image");
330        assert!(reporter.context.is_some());
331        assert_eq!(reporter.context.unwrap(), "Building Docker image");
332    }
333
334    #[test]
335    fn format_message_includes_context_for_steps() {
336        let reporter = ProgressReporter::with_context("Building Docker image");
337        let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
338        // Format: [elapsed] Context · message
339        assert!(msg.contains("Building Docker image · Step 1/10"));
340        assert!(msg.starts_with("[00:00]"));
341    }
342
343    #[test]
344    fn format_message_includes_context_for_all_messages() {
345        let reporter = ProgressReporter::with_context("Building Docker image");
346        let msg = reporter.format_message("Compiling foo v1.0");
347        // Format: [elapsed] Context · message
348        assert!(msg.contains("Building Docker image · Compiling foo"));
349        assert!(msg.starts_with("[00:00]"));
350    }
351
352    #[test]
353    fn format_message_without_context() {
354        let reporter = ProgressReporter::new();
355        let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
356        // Format: [elapsed] message (no context, no dot)
357        assert!(msg.contains("Step 1/10"));
358        assert!(msg.starts_with("[00:00]"));
359        assert!(!msg.contains("·"));
360    }
361
362    #[test]
363    fn format_message_collapses_whitespace() {
364        let reporter = ProgressReporter::new();
365        // All whitespace (including newlines) collapsed to single spaces for spinner display
366        let msg = reporter.format_message("Compiling foo\n     Compiling bar\n");
367        assert!(!msg.contains('\n'));
368        assert!(msg.contains("Compiling foo Compiling bar"));
369    }
370
371    #[test]
372    fn strip_ansi_codes_removes_color_codes() {
373        // Red text: \x1b[31m ... \x1b[0m
374        let input = "\x1b[31mError:\x1b[0m something failed";
375        let result = strip_ansi_codes(input);
376        assert_eq!(result, "Error: something failed");
377    }
378
379    #[test]
380    fn strip_ansi_codes_handles_plain_text() {
381        let input = "Just plain text";
382        let result = strip_ansi_codes(input);
383        assert_eq!(result, "Just plain text");
384    }
385
386    #[test]
387    fn strip_ansi_codes_handles_multiple_codes() {
388        // Bold green: \x1b[1;32m
389        let input = "\x1b[1;32mSuccess\x1b[0m and \x1b[33mwarning\x1b[0m";
390        let result = strip_ansi_codes(input);
391        assert_eq!(result, "Success and warning");
392    }
393
394    #[test]
395    fn format_message_strips_ansi_codes() {
396        let reporter = ProgressReporter::new();
397        let msg = reporter.format_message("\x1b[31mCompiling\x1b[0m foo");
398        assert!(msg.contains("Compiling foo"));
399        assert!(!msg.contains("\x1b"));
400    }
401}