opencode_cloud_core/docker/
progress.rs1use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10const SPINNER_UPDATE_THROTTLE: Duration = Duration::from_millis(150);
12
13fn 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 if chars.peek() == Some(&'[') {
25 chars.next(); while let Some(&next) = chars.peek() {
28 chars.next();
29 if next.is_ascii_alphabetic() {
30 break;
31 }
32 }
33 }
34 } else {
36 result.push(c);
37 }
38 }
39
40 result
41}
42
43fn 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
57pub 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 context: Option<String>,
69}
70
71impl Default for ProgressReporter {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77impl ProgressReporter {
78 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 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 fn format_message(&self, message: &str) -> String {
106 let elapsed = format_elapsed(self.start_time.elapsed());
107
108 let stripped = strip_ansi_codes(message);
110
111 let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
115
116 match &self.context {
119 Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
120 None => format!("[{elapsed}] {clean_msg}"),
121 }
122 }
123
124 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 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 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 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 let bar = self.add_bar(layer_id, total);
171 bar.set_position(current);
172 bar.set_message(status.to_string());
173 }
174 }
175
176 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 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; }
190 }
191
192 if let Some(last_msg) = self.last_message.get(id) {
194 if last_msg == message {
195 return;
196 }
197 }
198 }
199
200 let formatted = self.format_message(message);
202
203 if let Some(spinner) = self.bars.get(id) {
204 spinner.set_message(formatted);
205 } else {
206 self.add_spinner(id, message);
208 }
209
210 self.last_update.insert(id.to_string(), now);
212 self.last_message
213 .insert(id.to_string(), message.to_string());
214 }
215
216 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 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 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 reporter.finish("nonexistent", "Done");
287 }
288
289 #[test]
290 fn finish_all_handles_empty() {
291 let reporter = ProgressReporter::new();
292 reporter.finish_all("Done");
294 }
295
296 #[test]
297 fn abandon_all_handles_empty() {
298 let reporter = ProgressReporter::new();
299 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); 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); 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 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 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 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 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 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 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}