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
43pub 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 context: Option<String>,
54 plain_output: bool,
56}
57
58impl Default for ProgressReporter {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl ProgressReporter {
65 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 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 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 pub fn is_plain_output(&self) -> bool {
105 self.plain_output
106 }
107
108 fn format_message(&self, message: &str) -> String {
110 let stripped = strip_ansi_codes(message);
112
113 let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
117
118 match &self.context {
120 Some(ctx) => format!("{ctx} · {clean_msg}"),
121 None => clean_msg,
122 }
123 }
124
125 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 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 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 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 let bar = self.add_bar(layer_id, total);
188 bar.set_position(current);
189 bar.set_message(status.to_string());
190 }
191 }
192
193 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 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; }
217 }
218
219 if let Some(last_msg) = self.last_message_by_id.get(id) {
221 if last_msg == message {
222 return;
223 }
224 }
225 }
226
227 let formatted = self.format_message(message);
229
230 if let Some(spinner) = self.bars.get(id) {
231 spinner.set_message(formatted);
232 } else {
233 self.add_spinner(id, message);
235 }
236
237 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 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 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 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 reporter.finish("nonexistent", "Done");
314 }
315
316 #[test]
317 fn finish_all_handles_empty() {
318 let reporter = ProgressReporter::new();
319 reporter.finish_all("Done");
321 }
322
323 #[test]
324 fn abandon_all_handles_empty() {
325 let reporter = ProgressReporter::new();
326 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 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 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 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 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}