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/// Progress reporter for Docker operations
44///
45/// Manages multiple progress bars for concurrent operations like
46/// multi-layer image pulls and build steps.
47pub struct ProgressReporter {
48    multi: MultiProgress,
49    bars: HashMap<String, ProgressBar>,
50    last_update_by_id: HashMap<String, Instant>,
51    last_message_by_id: HashMap<String, String>,
52    /// Optional context prefix shown before step messages (e.g., "Building Docker image")
53    context: Option<String>,
54    /// When true, print build output lines directly instead of spinners
55    plain_output: bool,
56}
57
58impl Default for ProgressReporter {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl ProgressReporter {
65    /// Create a new progress reporter
66    pub fn new() -> Self {
67        Self {
68            multi: MultiProgress::new(),
69            bars: HashMap::new(),
70            last_update_by_id: HashMap::new(),
71            last_message_by_id: HashMap::new(),
72            context: None,
73            plain_output: false,
74        }
75    }
76
77    /// Create a new progress reporter with a context prefix
78    ///
79    /// The context is shown before step messages, e.g., "Building Docker image · Step 1/10"
80    pub fn with_context(context: &str) -> Self {
81        Self {
82            multi: MultiProgress::new(),
83            bars: HashMap::new(),
84            last_update_by_id: HashMap::new(),
85            last_message_by_id: HashMap::new(),
86            context: Some(context.to_string()),
87            plain_output: false,
88        }
89    }
90
91    /// Create a progress reporter that prints build output directly
92    pub fn with_context_plain(context: &str) -> Self {
93        Self {
94            multi: MultiProgress::new(),
95            bars: HashMap::new(),
96            last_update_by_id: HashMap::new(),
97            last_message_by_id: HashMap::new(),
98            context: Some(context.to_string()),
99            plain_output: true,
100        }
101    }
102
103    /// Check if plain output mode is enabled
104    pub fn is_plain_output(&self) -> bool {
105        self.plain_output
106    }
107
108    /// Format a message with context prefix if set
109    fn format_message(&self, message: &str) -> String {
110        // Strip ANSI escape codes that Docker may include in its output
111        let stripped = strip_ansi_codes(message);
112
113        // Collapse message to single line for spinner display:
114        // - Replace all whitespace sequences (including newlines) with single space
115        // - Trim leading/trailing whitespace
116        let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
117
118        // Format: "Context · message" or "message"
119        match &self.context {
120            Some(ctx) => format!("{ctx} · {clean_msg}"),
121            None => clean_msg,
122        }
123    }
124
125    /// Create a spinner for indeterminate progress (e.g., build steps)
126    pub fn add_spinner(&mut self, id: &str, message: &str) -> &ProgressBar {
127        if self.plain_output {
128            let spinner = ProgressBar::hidden();
129            self.bars.insert(id.to_string(), spinner);
130            return self.bars.get(id).expect("just inserted");
131        }
132
133        let spinner = self.multi.add(ProgressBar::new_spinner());
134        spinner.set_style(
135            ProgressStyle::default_spinner()
136                .template("{spinner:.green} [{elapsed}] {msg}")
137                .expect("valid template")
138                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
139        );
140        spinner.set_message(self.format_message(message));
141        spinner.enable_steady_tick(std::time::Duration::from_millis(100));
142        self.bars.insert(id.to_string(), spinner);
143        self.bars.get(id).expect("just inserted")
144    }
145
146    /// Create a progress bar for determinate progress (e.g., layer download)
147    ///
148    /// `total` is in bytes
149    pub fn add_bar(&mut self, id: &str, total: u64) -> &ProgressBar {
150        if self.plain_output {
151            let bar = ProgressBar::hidden();
152            self.bars.insert(id.to_string(), bar);
153            return self.bars.get(id).expect("just inserted");
154        }
155
156        let bar = self.multi.add(ProgressBar::new(total));
157        bar.set_style(
158            ProgressStyle::default_bar()
159                .template(
160                    "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}",
161                )
162                .expect("valid template")
163                .progress_chars("=>-"),
164        );
165        bar.enable_steady_tick(std::time::Duration::from_millis(100));
166        self.bars.insert(id.to_string(), bar);
167        self.bars.get(id).expect("just inserted")
168    }
169
170    /// Update progress for a layer (used during image pull)
171    ///
172    /// `current` and `total` are in bytes, `status` is the Docker status message
173    pub fn update_layer(&mut self, layer_id: &str, current: u64, total: u64, status: &str) {
174        if self.plain_output {
175            return;
176        }
177
178        if let Some(bar) = self.bars.get(layer_id) {
179            // Update total if it changed (Docker sometimes updates this)
180            if bar.length() != Some(total) && total > 0 {
181                bar.set_length(total);
182            }
183            bar.set_position(current);
184            bar.set_message(status.to_string());
185        } else {
186            // Create new bar for this layer
187            let bar = self.add_bar(layer_id, total);
188            bar.set_position(current);
189            bar.set_message(status.to_string());
190        }
191    }
192
193    /// Update spinner message (used during build)
194    ///
195    /// Updates are throttled to prevent flickering from rapid message changes.
196    /// "Step X/Y" messages always update immediately as they indicate significant progress.
197    pub fn update_spinner(&mut self, id: &str, message: &str) {
198        if self.plain_output {
199            let clean = strip_ansi_codes(message);
200            let formatted = match &self.context {
201                Some(ctx) => format!("{ctx} · {clean}"),
202                None => clean,
203            };
204            eprintln!("{formatted}");
205            return;
206        }
207
208        let now = Instant::now();
209        let is_step_message = message.starts_with("Step ");
210
211        // Check if we should throttle this update
212        if !is_step_message {
213            if let Some(last) = self.last_update_by_id.get(id) {
214                if now.duration_since(*last) < SPINNER_UPDATE_THROTTLE {
215                    return; // Throttle: too soon since last update
216                }
217            }
218
219            // Skip if message is identical to last one
220            if let Some(last_msg) = self.last_message_by_id.get(id) {
221                if last_msg == message {
222                    return;
223                }
224            }
225        }
226
227        // Perform the update with context and elapsed time
228        let formatted = self.format_message(message);
229
230        if let Some(spinner) = self.bars.get(id) {
231            spinner.set_message(formatted);
232        } else {
233            // Create new spinner if doesn't exist
234            self.add_spinner(id, message);
235        }
236
237        // Track update time and message
238        self.last_update_by_id.insert(id.to_string(), now);
239        self.last_message_by_id
240            .insert(id.to_string(), message.to_string());
241    }
242
243    /// Mark a layer/step as complete
244    pub fn finish(&mut self, id: &str, message: &str) {
245        if let Some(bar) = self.bars.get(id) {
246            bar.finish_with_message(message.to_string());
247        }
248    }
249
250    /// Mark all progress as complete
251    pub fn finish_all(&self, message: &str) {
252        for bar in self.bars.values() {
253            bar.finish_with_message(message.to_string());
254        }
255    }
256
257    /// Mark all progress as failed
258    pub fn abandon_all(&self, message: &str) {
259        for bar in self.bars.values() {
260            bar.abandon_with_message(message.to_string());
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn progress_reporter_creation() {
271        let reporter = ProgressReporter::new();
272        assert!(reporter.bars.is_empty());
273    }
274
275    #[test]
276    fn progress_reporter_default() {
277        let reporter = ProgressReporter::default();
278        assert!(reporter.bars.is_empty());
279    }
280
281    #[test]
282    fn add_spinner_creates_entry() {
283        let mut reporter = ProgressReporter::new();
284        reporter.add_spinner("test", "Testing...");
285        assert!(reporter.bars.contains_key("test"));
286    }
287
288    #[test]
289    fn add_bar_creates_entry() {
290        let mut reporter = ProgressReporter::new();
291        reporter.add_bar("layer1", 1000);
292        assert!(reporter.bars.contains_key("layer1"));
293    }
294
295    #[test]
296    fn update_layer_creates_if_missing() {
297        let mut reporter = ProgressReporter::new();
298        reporter.update_layer("layer1", 500, 1000, "Downloading");
299        assert!(reporter.bars.contains_key("layer1"));
300    }
301
302    #[test]
303    fn update_spinner_creates_if_missing() {
304        let mut reporter = ProgressReporter::new();
305        reporter.update_spinner("step1", "Building...");
306        assert!(reporter.bars.contains_key("step1"));
307    }
308
309    #[test]
310    fn finish_handles_missing_id() {
311        let mut reporter = ProgressReporter::new();
312        // Should not panic on missing id
313        reporter.finish("nonexistent", "Done");
314    }
315
316    #[test]
317    fn finish_all_handles_empty() {
318        let reporter = ProgressReporter::new();
319        // Should not panic on empty
320        reporter.finish_all("Done");
321    }
322
323    #[test]
324    fn abandon_all_handles_empty() {
325        let reporter = ProgressReporter::new();
326        // Should not panic on empty
327        reporter.abandon_all("Failed");
328    }
329
330    #[test]
331    fn with_context_sets_context() {
332        let reporter = ProgressReporter::with_context("Building Docker image");
333        assert!(reporter.context.is_some());
334        assert_eq!(reporter.context.unwrap(), "Building Docker image");
335    }
336
337    #[test]
338    fn format_message_includes_context_for_steps() {
339        let reporter = ProgressReporter::with_context("Building Docker image");
340        let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
341        // Format: Context · message
342        assert!(msg.starts_with("Building Docker image · Step 1/10"));
343    }
344
345    #[test]
346    fn format_message_includes_context_for_all_messages() {
347        let reporter = ProgressReporter::with_context("Building Docker image");
348        let msg = reporter.format_message("Compiling foo v1.0");
349        // Format: Context · message
350        assert!(msg.starts_with("Building Docker image · Compiling foo"));
351    }
352
353    #[test]
354    fn format_message_without_context() {
355        let reporter = ProgressReporter::new();
356        let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
357        // Format: message (no context, no dot)
358        assert!(msg.starts_with("Step 1/10"));
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}