Skip to main content

cli/data/
progress.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Minimal internal progress abstraction for Export/Import V2.
16//!
17//! This is intentionally small and log/internal oriented. It does not touch
18//! stdout and is safe for non-interactive runs. [`LogProgress`] backs the
19//! import-v2 `--progress` flag for non-interactive runs by routing events to
20//! stderr, while [`IndicatifProgress`] renders an interactive bar on a TTY.
21//! Both implement [`ProgressReporter`], so call sites stay agnostic.
22
23use std::io::{self, IsTerminal, Write};
24use std::sync::Mutex;
25
26use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
27
28/// Controls progress reporting for Export/Import V2.
29///
30/// `auto` shows an interactive bar only on a TTY and falls back to lightweight
31/// log progress otherwise; `always` always emits progress, using the bar on a
32/// TTY and lightweight logs otherwise; `never` is silent.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
34#[value(rename_all = "lowercase")]
35pub(crate) enum ProgressMode {
36    /// Show an interactive bar on a TTY, otherwise log progress.
37    #[default]
38    Auto,
39    /// Always emit progress: a bar on TTY, lightweight logs otherwise.
40    Always,
41    /// Never emit progress.
42    Never,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum ProgressOutputKind {
47    Bar,
48    Log,
49    Silent,
50}
51
52/// Selects the progress output style for `mode` given whether stderr is a TTY
53/// and whether the terminal environment is suitable for progress-bar control
54/// sequences.
55///
56/// The interactive `indicatif` stderr target hides itself when redirected or
57/// when the terminal is not user-attended, so forced progress falls back to
58/// [`LogProgress`] instead of a hidden bar in those cases.
59pub(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
90/// Builds the progress reporter for `mode`. `never` is silent; otherwise an
91/// interactive bar is used on TTY stderr, falling back to lightweight log
92/// progress when stderr is redirected.
93pub(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
105/// Receives progress events from long-running Export/Import V2 work.
106///
107/// The trait is object-safe so callers can take `&dyn ProgressReporter` and stay
108/// agnostic about the concrete implementation (no-op in production today, a
109/// recording fake in tests).
110pub(crate) trait ProgressReporter: Send + Sync {
111    /// Begins a phase with an optional known total number of units.
112    fn start_phase(&self, name: &str, total: Option<u64>);
113
114    /// Advances the current phase by `delta` units.
115    fn inc(&self, delta: u64);
116
117    /// Marks the current phase as finished.
118    fn finish_phase(&self);
119}
120
121/// A reporter that discards every event. Used as the production default and in
122/// tests that do not care about progress.
123pub(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
131/// A lightweight reporter that logs phase lifecycle and progress through the
132/// stderr. It never touches stdout, so it is safe for non-interactive runs and
133/// keeps dry-run output clean.
134pub(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
200/// A reporter that renders an interactive progress bar via `indicatif`.
201///
202/// It draws to stderr through an explicit [`ProgressDrawTarget::stderr`] so it
203/// never collides with stdout (e.g. dry-run SQL). Phases with a known total get
204/// a determinate bar; unknown totals fall back to an animated spinner. Each
205/// phase clears itself on finish via [`ProgressBar::finish_and_clear`].
206pub(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        // Replacing any prior phase: clear it before starting the next.
225        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/// RAII guard for a started progress phase.
268///
269/// This keeps future stateful reporters safe on every early-return path after a
270/// phase starts. Call [`Self::finish`] to end the phase at a deliberate point;
271/// otherwise the guard finishes it when dropped.
272#[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        // LogProgress takes only `&self`, so it must drive a full phase
316        // lifecycle (including an out-of-phase `inc`) without panicking.
317        let progress = LogProgress::new();
318        let reporter: &dyn ProgressReporter = &progress;
319
320        reporter.inc(1); // No active phase yet: must be a no-op, not a panic.
321        reporter.start_phase("Import data tasks", Some(2));
322        reporter.inc(1);
323        reporter.inc(1);
324        reporter.finish_phase();
325        reporter.finish_phase(); // Idempotent: finishing twice is harmless.
326    }
327
328    #[test]
329    fn test_progress_output_kind_visibility_matrix() {
330        // auto follows the TTY for the bar and falls back to log progress;
331        // always emits progress even when stderr is redirected; never is silent.
332        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        // IndicatifProgress takes only `&self`, so it must survive a full
378        // lifecycle (including determinate and spinner phases, an out-of-phase
379        // `inc`, and double finish) without panicking. The draw target is
380        // stderr, which is non-interactive under the test harness, so nothing
381        // actually renders.
382        let progress = IndicatifProgress::new();
383        let reporter: &dyn ProgressReporter = &progress;
384
385        reporter.inc(1); // No active phase yet: must be a no-op, not a panic.
386        reporter.start_phase("Import data tasks", Some(2));
387        reporter.inc(1);
388        reporter.inc(1);
389        reporter.start_phase("Streaming", None); // Spinner phase replaces the bar.
390        reporter.inc(5);
391        reporter.finish_phase();
392        reporter.finish_phase(); // Idempotent: finishing twice is harmless.
393    }
394}
395
396#[cfg(test)]
397pub(crate) mod test_util {
398    use std::sync::Mutex;
399
400    use super::ProgressReporter;
401
402    /// A single recorded progress event, used to assert progress behavior in
403    /// unit tests.
404    #[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    /// A reporter that records every event in order for later assertions.
412    #[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        /// Sum of all `inc` deltas recorded so far.
423        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}