1use crate::cli::output::Output;
28use crate::note;
29use indicatif::{ProgressBar, ProgressStyle, WeakProgressBar};
30use scope::display::theme::color_enabled_stderr;
31use std::sync::Mutex;
32use std::time::Duration;
33
34static DRAWING: Mutex<Vec<WeakProgressBar>> = Mutex::new(Vec::new());
36
37fn register(bar: &ProgressBar) {
39 let mut bars = DRAWING.lock().unwrap_or_else(|e| e.into_inner());
40 bars.retain(|weak| weak.upgrade().is_some_and(|b| !b.is_finished()));
41 bars.push(bar.downgrade());
42}
43
44pub(crate) fn paused<R>(f: impl FnOnce() -> R) -> R {
48 let bars: Vec<ProgressBar> = DRAWING
50 .lock()
51 .unwrap_or_else(|e| e.into_inner())
52 .iter()
53 .filter_map(WeakProgressBar::upgrade)
54 .filter(|b| !b.is_finished())
55 .collect();
56 paused_all(&bars, f)
57}
58
59fn paused_all<R>(bars: &[ProgressBar], f: impl FnOnce() -> R) -> R {
60 match bars.split_first() {
61 None => f(),
62 Some((bar, rest)) => bar.suspend(|| paused_all(rest, f)),
63 }
64}
65
66pub struct Spinner {
72 bar: ProgressBar,
73 output: Output,
74}
75
76impl Spinner {
77 pub fn new(message: &str, output: &Output) -> Self {
80 let bar = if output.progress_enabled() && atty_stderr() {
81 let pb = ProgressBar::new_spinner();
82 pb.set_style(
83 ProgressStyle::with_template(if color_enabled_stderr() {
84 "{spinner:.cyan} {msg}"
85 } else {
86 "{spinner} {msg}"
87 })
88 .unwrap()
89 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
90 );
91 pb.set_message(message.to_string());
92 pb.enable_steady_tick(Duration::from_millis(80));
93 register(&pb);
94 pb
95 } else {
96 note!(output, "{}", message);
98 ProgressBar::hidden()
99 };
100 Self {
101 bar,
102 output: output.clone(),
103 }
104 }
105
106 pub fn set_message(&self, message: impl Into<String>) {
108 self.bar.set_message(message.into());
109 }
110
111 pub fn finish(&self, message: &str) {
113 if !self.bar.is_hidden() {
114 self.bar.finish_with_message(format!("✓ {}", message));
115 }
116 }
117
118 pub fn finish_warn(&self, message: &str) {
120 if !self.bar.is_hidden() {
121 self.bar.finish_with_message(format!("⚠ {}", message));
122 }
123 }
124
125 pub fn finish_and_clear(&self) {
127 self.bar.finish_and_clear();
128 }
129
130 pub fn println(&self, message: &str) {
134 self.bar.suspend(|| note!(self.output, "{}", message));
135 }
136}
137
138pub(crate) fn status(spinner: Option<&Spinner>, out: &Output, message: &str) {
141 match spinner {
142 Some(sp) => sp.set_message(message.to_string()),
143 None => note!(out, "{}", message),
144 }
145}
146
147pub(crate) fn notice(spinner: Option<&Spinner>, out: &Output, message: &str) {
150 match spinner {
151 Some(sp) => sp.println(message),
152 None => note!(out, "{}", message),
153 }
154}
155
156pub struct StepProgress {
158 bar: ProgressBar,
159}
160
161impl StepProgress {
162 pub fn new(total: u64, prefix: &str, output: &Output) -> Self {
165 let bar = if output.progress_enabled() && atty_stderr() {
166 let pb = ProgressBar::new(total);
167 pb.set_style(
168 ProgressStyle::with_template(if color_enabled_stderr() {
169 "{prefix} [{bar:30.cyan/dim}] {pos}/{len} {msg}"
170 } else {
171 "{prefix} [{bar:30}] {pos}/{len} {msg}"
172 })
173 .unwrap()
174 .progress_chars("━━╸"),
175 );
176 pb.set_prefix(prefix.to_string());
177 register(&pb);
178 pb
179 } else {
180 note!(output, "{} (0/{})", prefix, total);
181 ProgressBar::hidden()
182 };
183 Self { bar }
184 }
185
186 pub fn inc(&self, message: &str) {
188 self.bar.set_message(message.to_string());
189 self.bar.inc(1);
190 }
191
192 pub fn finish(&self, message: &str) {
194 if !self.bar.is_hidden() {
195 self.bar.finish_with_message(format!("✓ {}", message));
196 }
197 }
198}
199
200fn progress_bar_ok(env: &dyn Fn(&str) -> Option<String>, stderr_is_tty: bool) -> bool {
206 stderr_is_tty && env("TERM").as_deref() != Some("dumb")
207}
208
209fn atty_stderr() -> bool {
211 use std::io::IsTerminal;
212 progress_bar_ok(&|k| std::env::var(k).ok(), std::io::stderr().is_terminal())
213}
214
215#[cfg(test)]
222pub(crate) mod testing {
223 use super::*;
224
225 #[derive(Clone, Default, Debug)]
227 pub(crate) struct Screen(std::sync::Arc<Mutex<Vec<String>>>);
228
229 impl Screen {
230 pub(crate) fn ops(&self) -> Vec<String> {
231 self.0.lock().unwrap().clone()
232 }
233 fn push(&self, op: String) -> std::io::Result<()> {
234 self.0.lock().unwrap().push(op);
235 Ok(())
236 }
237 }
238
239 impl indicatif::TermLike for Screen {
240 fn width(&self) -> u16 {
241 80
242 }
243 fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
244 self.push(format!("up {n}"))
245 }
246 fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
247 self.push(format!("down {n}"))
248 }
249 fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
250 self.push(format!("right {n}"))
251 }
252 fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
253 self.push(format!("left {n}"))
254 }
255 fn write_line(&self, s: &str) -> std::io::Result<()> {
256 self.push(format!("line {s}"))
257 }
258 fn write_str(&self, s: &str) -> std::io::Result<()> {
259 self.push(format!("str {s}"))
260 }
261 fn clear_line(&self) -> std::io::Result<()> {
262 self.push("clear".into())
263 }
264 fn flush(&self) -> std::io::Result<()> {
265 Ok(())
266 }
267 }
268
269 pub(crate) fn drawing_spinner(message: &str, output: &Output, screen: &Screen) -> Spinner {
271 let bar = ProgressBar::with_draw_target(
272 None,
273 indicatif::ProgressDrawTarget::term_like(Box::new(screen.clone())),
274 );
275 bar.set_style(ProgressStyle::with_template("{spinner} {msg}").unwrap());
276 bar.set_message(message.to_string());
277 bar.enable_steady_tick(Duration::from_millis(5));
278 register(&bar);
279 Spinner {
280 bar,
281 output: output.clone(),
282 }
283 }
284
285 pub(crate) static REGISTRY_LOCK: Mutex<()> = Mutex::new(());
287}
288
289#[cfg(test)]
290mod tests {
291 use super::testing::*;
292 use super::*;
293
294 #[test]
295 fn test_progress_bar_rules() {
296 let env = |m: &'static [(&'static str, &'static str)]| {
297 move |k: &str| m.iter().find(|(n, _)| *n == k).map(|(_, v)| v.to_string())
298 };
299 assert!(progress_bar_ok(&env(&[]), true));
300 assert!(progress_bar_ok(&env(&[("NO_COLOR", "1")]), true));
302 assert!(!progress_bar_ok(&env(&[("TERM", "dumb")]), true));
303 assert!(!progress_bar_ok(&env(&[]), false));
304 }
305
306 fn quiet() -> Output {
307 Output::capture().0
308 }
309
310 #[test]
311 fn test_hidden_spinner_writes_status_to_err_channel() {
312 let (o, cap) = Output::capture();
315 let sp = Spinner::new("Fetching...", &o);
316 sp.println("note");
317 sp.finish("done");
318 assert_eq!(cap.err(), "Fetching...\nnote\n");
319 assert_eq!(cap.out(), "");
320 }
321
322 #[test]
323 fn test_spinner_create_and_finish() {
324 let sp = Spinner::new("Testing...", &quiet());
326 sp.set_message("Updated");
327 sp.finish("Done");
328 }
329
330 #[test]
331 fn test_spinner_finish_and_clear() {
332 let sp = Spinner::new("Testing...", &quiet());
333 sp.finish_and_clear();
334 }
335
336 #[test]
337 fn test_spinner_finish_warn() {
338 let sp = Spinner::new("Testing...", &quiet());
339 sp.finish_warn("Warning");
340 }
341
342 #[test]
343 fn test_step_progress_create_and_finish() {
344 let prog = StepProgress::new(3, "Processing", &quiet());
345 prog.inc("Step 1");
346 prog.inc("Step 2");
347 prog.inc("Step 3");
348 prog.finish("Done");
349 }
350
351 #[test]
352 fn test_spinner_multiple_set_message() {
353 let sp = Spinner::new("Initial", &quiet());
354 sp.set_message("First update");
355 sp.set_message("Second update");
356 sp.set_message("Third update");
357 sp.finish("Complete");
358 }
359
360 #[test]
361 fn test_step_progress_multiple_inc() {
362 let prog = StepProgress::new(5, "Processing", &quiet());
363 prog.inc("Step 1");
364 prog.inc("Step 2");
365 prog.inc("Step 3");
366 prog.inc("Step 4");
367 prog.inc("Step 5");
368 prog.finish("Done");
369 }
370
371 #[test]
372 fn test_step_progress_single_step() {
373 let prog = StepProgress::new(1, "Single", &quiet());
374 prog.inc("Only step");
375 prog.finish("Complete");
376 }
377
378 #[test]
379 fn test_step_progress_large_total() {
380 let prog = StepProgress::new(100, "Large", &quiet());
381 for i in 1..=100 {
382 prog.inc(&format!("Step {}", i));
383 }
384 prog.finish("Complete");
385 }
386
387 #[test]
388 fn test_step_progress_zero_total() {
389 let prog = StepProgress::new(0, "Empty", &quiet());
390 prog.finish("Complete");
391 }
392
393 #[test]
394 fn test_spinner_finish_warn_with_message() {
395 let sp = Spinner::new("Warning test", &quiet());
396 sp.finish_warn("Something went wrong");
397 }
398
399 #[test]
400 fn test_spinner_finish_warn_multiple_calls() {
401 let sp = Spinner::new("Test", &quiet());
402 sp.finish_warn("First warning");
403 sp.finish_warn("Second warning");
405 }
406
407 #[test]
408 fn test_spinner_println() {
409 let sp = Spinner::new("Working...", &quiet());
410 sp.println("A message above the spinner");
411 sp.finish("Done");
412 }
413
414 #[test]
415 fn test_paused_stops_every_drawing_bar_while_it_runs() {
416 let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
419 let screen = Screen::default();
420 let sp = drawing_spinner("Searching...", &quiet(), &screen);
421 std::thread::sleep(Duration::from_millis(30));
422 let drawn_during = paused(|| {
423 let at_start = screen.ops();
424 let last = |op: &str| at_start.iter().rposition(|o| o.starts_with(op));
425 assert!(last("clear") > last("str"), "the line is cleared");
426 std::thread::sleep(Duration::from_millis(40));
427 screen.ops().len() - at_start.len()
428 });
429 assert_eq!(drawn_during, 0, "the spinner drew while paused");
430 std::thread::sleep(Duration::from_millis(30));
431 let after = screen.ops();
432 assert!(
433 after[after.len() - 4..]
434 .iter()
435 .any(|op| op.contains("Searching...")),
436 "it draws again after: {after:?}"
437 );
438 sp.finish_and_clear();
439 }
440
441 #[test]
442 fn test_paused_skips_ended_bars_and_runs_without_any() {
443 let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
444 let screen = Screen::default();
445 let sp = drawing_spinner("x", &quiet(), &screen);
446 sp.finish("done");
447 let before = screen.ops().len();
448 assert_eq!(paused(|| 7), 7);
449 assert_eq!(screen.ops().len(), before, "an ended bar keeps its line");
450 drop(sp);
451 assert_eq!(paused(|| 8), 8);
452 }
453
454 #[test]
455 fn test_println_beside_a_drawing_spinner_goes_through_the_output() {
456 let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
459 let screen = Screen::default();
460 let (o, cap) = Output::capture();
461 let sp = drawing_spinner("Searching...", &o, &screen);
462 sp.println("Not found on DexScreener; found X on Y (CEX)");
463 assert_eq!(cap.err(), "Not found on DexScreener; found X on Y (CEX)\n");
464 assert!(
465 !screen.ops().iter().any(|op| op.contains("DexScreener")),
466 "not drawn by the bar"
467 );
468 sp.finish_and_clear();
469 }
470}