1use std::io::{self, IsTerminal, Write};
24use std::sync::Mutex;
25
26use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
34#[value(rename_all = "lowercase")]
35pub(crate) enum ProgressMode {
36 #[default]
38 Auto,
39 Always,
41 Never,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum ProgressOutputKind {
47 Bar,
48 Log,
49 Silent,
50}
51
52pub(crate) fn progress_output_kind(
60 mode: ProgressMode,
61 stderr_is_tty: bool,
62 term_supports_progress_bar: bool,
63) -> ProgressOutputKind {
64 let can_show_bar = stderr_is_tty && term_supports_progress_bar;
65 match mode {
66 ProgressMode::Never => ProgressOutputKind::Silent,
67 ProgressMode::Always if can_show_bar => ProgressOutputKind::Bar,
68 ProgressMode::Always => ProgressOutputKind::Log,
69 ProgressMode::Auto if can_show_bar => ProgressOutputKind::Bar,
70 ProgressMode::Auto => ProgressOutputKind::Log,
71 }
72}
73
74fn progress_bar_supported(term: Option<&std::ffi::OsStr>, no_color: bool) -> bool {
75 if no_color {
76 return false;
77 }
78
79 term.map(|term| !term.is_empty() && term != "dumb")
80 .unwrap_or(false)
81}
82
83fn term_supports_progress_bar() -> bool {
84 progress_bar_supported(
85 std::env::var_os("TERM").as_deref(),
86 std::env::var_os("NO_COLOR").is_some(),
87 )
88}
89
90pub(crate) fn build_progress_reporter(mode: ProgressMode) -> Box<dyn ProgressReporter> {
94 match progress_output_kind(
95 mode,
96 std::io::stderr().is_terminal(),
97 term_supports_progress_bar(),
98 ) {
99 ProgressOutputKind::Bar => Box::new(IndicatifProgress::new()),
100 ProgressOutputKind::Log => Box::new(LogProgress::new()),
101 ProgressOutputKind::Silent => Box::new(NoopProgress),
102 }
103}
104
105pub(crate) trait ProgressReporter: Send + Sync {
111 fn start_phase(&self, name: &str, total: Option<u64>);
113
114 fn inc(&self, delta: u64);
116
117 fn finish_phase(&self);
119}
120
121pub(crate) struct NoopProgress;
124
125impl ProgressReporter for NoopProgress {
126 fn start_phase(&self, _name: &str, _total: Option<u64>) {}
127 fn inc(&self, _delta: u64) {}
128 fn finish_phase(&self) {}
129}
130
131pub(crate) struct LogProgress {
135 phase: Mutex<Option<PhaseState>>,
136}
137
138struct PhaseState {
139 name: String,
140 total: Option<u64>,
141 done: u64,
142}
143
144impl LogProgress {
145 pub(crate) fn new() -> Self {
146 Self {
147 phase: Mutex::new(None),
148 }
149 }
150}
151
152fn write_progress_line(line: String) {
153 let _ = writeln!(io::stderr().lock(), "{line}");
154}
155
156impl ProgressReporter for LogProgress {
157 fn start_phase(&self, name: &str, total: Option<u64>) {
158 let Ok(mut phase) = self.phase.lock() else {
159 return;
160 };
161 *phase = Some(PhaseState {
162 name: name.to_string(),
163 total,
164 done: 0,
165 });
166 match total {
167 Some(total) => write_progress_line(format!("Starting phase '{name}' ({total} units)")),
168 None => write_progress_line(format!("Starting phase '{name}'")),
169 }
170 }
171
172 fn inc(&self, delta: u64) {
173 let Ok(mut guard) = self.phase.lock() else {
174 return;
175 };
176 if let Some(phase) = guard.as_mut() {
177 phase.done += delta;
178 match phase.total {
179 Some(total) => {
180 write_progress_line(format!("Phase '{}': {}/{}", phase.name, phase.done, total))
181 }
182 None => write_progress_line(format!("Phase '{}': {}", phase.name, phase.done)),
183 }
184 }
185 }
186
187 fn finish_phase(&self) {
188 let Ok(mut guard) = self.phase.lock() else {
189 return;
190 };
191 if let Some(phase) = guard.take() {
192 write_progress_line(format!(
193 "Finished phase '{}' ({} units)",
194 phase.name, phase.done
195 ));
196 }
197 }
198}
199
200pub(crate) struct IndicatifProgress {
207 bar: Mutex<Option<ProgressBar>>,
208}
209
210impl IndicatifProgress {
211 pub(crate) fn new() -> Self {
212 Self {
213 bar: Mutex::new(None),
214 }
215 }
216}
217
218impl ProgressReporter for IndicatifProgress {
219 fn start_phase(&self, name: &str, total: Option<u64>) {
220 let Ok(mut guard) = self.bar.lock() else {
221 return;
222 };
223
224 if let Some(prev) = guard.take() {
226 prev.finish_and_clear();
227 }
228
229 let bar = ProgressBar::with_draw_target(total, ProgressDrawTarget::stderr());
230 match total {
231 Some(_) => {
232 let style =
233 ProgressStyle::with_template("{msg} [{bar:40}] {pos}/{len} ({percent}%)")
234 .unwrap_or_else(|_| ProgressStyle::default_bar())
235 .progress_chars("=>-");
236 bar.set_style(style);
237 }
238 None => {
239 let style = ProgressStyle::with_template("{spinner} {msg} {pos}")
240 .unwrap_or_else(|_| ProgressStyle::default_spinner());
241 bar.set_style(style);
242 }
243 }
244 bar.set_message(name.to_string());
245 *guard = Some(bar);
246 }
247
248 fn inc(&self, delta: u64) {
249 let Ok(guard) = self.bar.lock() else {
250 return;
251 };
252 if let Some(bar) = guard.as_ref() {
253 bar.inc(delta);
254 }
255 }
256
257 fn finish_phase(&self) {
258 let Ok(mut guard) = self.bar.lock() else {
259 return;
260 };
261 if let Some(bar) = guard.take() {
262 bar.finish_and_clear();
263 }
264 }
265}
266
267#[must_use = "dropping the guard immediately finishes the phase"]
273pub(crate) struct ProgressPhase<'a> {
274 reporter: &'a dyn ProgressReporter,
275 finished: bool,
276}
277
278impl<'a> ProgressPhase<'a> {
279 pub(crate) fn start(
280 reporter: &'a dyn ProgressReporter,
281 name: &str,
282 total: Option<u64>,
283 ) -> Self {
284 reporter.start_phase(name, total);
285 Self {
286 reporter,
287 finished: false,
288 }
289 }
290
291 pub(crate) fn finish(mut self) {
292 self.finish_once();
293 }
294
295 fn finish_once(&mut self) {
296 if !self.finished {
297 self.reporter.finish_phase();
298 self.finished = true;
299 }
300 }
301}
302
303impl Drop for ProgressPhase<'_> {
304 fn drop(&mut self) {
305 self.finish_once();
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_log_progress_is_safe_across_phase_lifecycle() {
315 let progress = LogProgress::new();
318 let reporter: &dyn ProgressReporter = &progress;
319
320 reporter.inc(1); reporter.start_phase("Import data tasks", Some(2));
322 reporter.inc(1);
323 reporter.inc(1);
324 reporter.finish_phase();
325 reporter.finish_phase(); }
327
328 #[test]
329 fn test_progress_output_kind_visibility_matrix() {
330 assert_eq!(
333 ProgressOutputKind::Bar,
334 progress_output_kind(ProgressMode::Auto, true, true)
335 );
336 assert_eq!(
337 ProgressOutputKind::Log,
338 progress_output_kind(ProgressMode::Auto, true, false)
339 );
340 assert_eq!(
341 ProgressOutputKind::Log,
342 progress_output_kind(ProgressMode::Auto, false, true)
343 );
344 assert_eq!(
345 ProgressOutputKind::Log,
346 progress_output_kind(ProgressMode::Always, false, true)
347 );
348 assert_eq!(
349 ProgressOutputKind::Log,
350 progress_output_kind(ProgressMode::Always, true, false)
351 );
352 assert_eq!(
353 ProgressOutputKind::Bar,
354 progress_output_kind(ProgressMode::Always, true, true)
355 );
356 assert_eq!(
357 ProgressOutputKind::Silent,
358 progress_output_kind(ProgressMode::Never, true, true)
359 );
360 assert_eq!(
361 ProgressOutputKind::Silent,
362 progress_output_kind(ProgressMode::Never, false, true)
363 );
364 }
365
366 #[test]
367 fn test_progress_bar_supported_respects_terminal_environment() {
368 assert!(progress_bar_supported(Some("xterm".as_ref()), false));
369 assert!(!progress_bar_supported(Some("dumb".as_ref()), false));
370 assert!(!progress_bar_supported(Some("".as_ref()), false));
371 assert!(!progress_bar_supported(None, false));
372 assert!(!progress_bar_supported(Some("xterm".as_ref()), true));
373 }
374
375 #[test]
376 fn test_indicatif_progress_is_safe_across_phase_lifecycle() {
377 let progress = IndicatifProgress::new();
383 let reporter: &dyn ProgressReporter = &progress;
384
385 reporter.inc(1); reporter.start_phase("Import data tasks", Some(2));
387 reporter.inc(1);
388 reporter.inc(1);
389 reporter.start_phase("Streaming", None); reporter.inc(5);
391 reporter.finish_phase();
392 reporter.finish_phase(); }
394}
395
396#[cfg(test)]
397pub(crate) mod test_util {
398 use std::sync::Mutex;
399
400 use super::ProgressReporter;
401
402 #[derive(Debug, Clone, PartialEq, Eq)]
405 pub(crate) enum ProgressEvent {
406 StartPhase { name: String, total: Option<u64> },
407 Inc { delta: u64 },
408 FinishPhase,
409 }
410
411 #[derive(Default)]
413 pub(crate) struct RecordingProgress {
414 events: Mutex<Vec<ProgressEvent>>,
415 }
416
417 impl RecordingProgress {
418 pub(crate) fn events(&self) -> Vec<ProgressEvent> {
419 self.events.lock().unwrap().clone()
420 }
421
422 pub(crate) fn total_inc(&self) -> u64 {
424 self.events
425 .lock()
426 .unwrap()
427 .iter()
428 .filter_map(|event| match event {
429 ProgressEvent::Inc { delta } => Some(*delta),
430 _ => None,
431 })
432 .sum()
433 }
434
435 fn push(&self, event: ProgressEvent) {
436 self.events.lock().unwrap().push(event);
437 }
438 }
439
440 impl ProgressReporter for RecordingProgress {
441 fn start_phase(&self, name: &str, total: Option<u64>) {
442 self.push(ProgressEvent::StartPhase {
443 name: name.to_string(),
444 total,
445 });
446 }
447
448 fn inc(&self, delta: u64) {
449 self.push(ProgressEvent::Inc { delta });
450 }
451
452 fn finish_phase(&self) {
453 self.push(ProgressEvent::FinishPhase);
454 }
455 }
456}