Skip to main content

rustic_rs/config/
progress_options.rs

1//! Progress Bar Config
2
3use std::{fmt::Write, io::Write as _, time::Duration};
4
5use std::io::IsTerminal;
6use std::sync::{Arc, Mutex, OnceLock};
7use std::time::Instant;
8
9use bytesize::ByteSize;
10use indicatif::{HumanDuration, MultiProgress, ProgressBar, ProgressState, ProgressStyle};
11
12use clap::Parser;
13use conflate::Merge;
14use jiff::SignedDuration;
15use log::info;
16
17use serde::{Deserialize, Serialize};
18use serde_with::{DisplayFromStr, serde_as};
19
20use rustic_core::{Progress, ProgressBars, ProgressType, RusticProgress};
21
22/// Returns the global `MultiProgress` instance used by all interactive progress bars.
23///
24/// Must be shared with `indicatif_log_bridge::LogWrapper` so that log output
25/// suspends progress bars before printing.
26pub fn multi_progress() -> &'static MultiProgress {
27    static MP: OnceLock<MultiProgress> = OnceLock::new();
28    MP.get_or_init(|| {
29        let mp = MultiProgress::new();
30        mp.set_move_cursor(true);
31        mp
32    })
33}
34
35mod constants {
36    use std::time::Duration;
37
38    pub(super) const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
39    pub(super) const DEFAULT_LOG_INTERVAL: Duration = Duration::from_secs(10);
40}
41
42/// Progress Bar Config
43#[serde_as]
44#[derive(Default, Debug, Parser, Clone, Copy, Deserialize, Serialize, Merge)]
45#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
46pub struct ProgressOptions {
47    /// Don't show any progress bar
48    #[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
49    #[merge(strategy=conflate::bool::overwrite_false)]
50    pub no_progress: bool,
51
52    /// Write progress as newline-delimited JSON
53    #[clap(
54        long,
55        global = true,
56        env = "RUSTIC_JSON_PROGRESS",
57        conflicts_with = "no_progress"
58    )]
59    #[merge(strategy=conflate::bool::overwrite_false)]
60    pub json_progress: bool,
61
62    /// Interval to update progress bars (default: 100ms)
63    #[clap(
64        long,
65        global = true,
66        env = "RUSTIC_PROGRESS_INTERVAL",
67        value_name = "DURATION",
68        conflicts_with = "no_progress"
69    )]
70    #[serde_as(as = "Option<DisplayFromStr>")]
71    #[merge(strategy=conflate::option::overwrite_none)]
72    pub progress_interval: Option<SignedDuration>,
73}
74
75impl ProgressOptions {
76    /// Get interval for interactive progress bars
77    fn interactive_interval(&self) -> Duration {
78        self.progress_interval
79            .map_or(constants::DEFAULT_INTERVAL, |i| {
80                i.try_into().expect("negative durations are not allowed")
81            })
82    }
83
84    /// Get interval for non-interactive logging
85    fn log_interval(&self) -> Duration {
86        self.progress_interval
87            .map_or(constants::DEFAULT_LOG_INTERVAL, |i| {
88                i.try_into().expect("negative durations are not allowed")
89            })
90    }
91
92    /// Factory Pattern: Create progress indicator based on terminal capabilities
93    ///
94    /// * `Hidden`: If --no-progress is set.
95    /// * `Interactive`: If running in a TTY.
96    /// * `NonInteractive`: If running in a pipe/service (logs to stderr).
97    fn create_progress(&self, prefix: &str, kind: ProgressType) -> Progress {
98        if self.no_progress {
99            return Progress::hidden();
100        }
101
102        let interval = self.log_interval();
103        if self.json_progress {
104            return if interval > Duration::ZERO && matches!(kind, ProgressType::Bytes) {
105                Progress::new(JsonProgress::new(prefix, interval, kind))
106            } else {
107                Progress::hidden()
108            };
109        }
110
111        if std::io::stderr().is_terminal() {
112            Progress::new(InteractiveProgress::new(
113                prefix,
114                kind,
115                self.interactive_interval(),
116            ))
117        } else {
118            if interval > Duration::ZERO {
119                Progress::new(NonInteractiveProgress::new(prefix, interval, kind))
120            } else {
121                Progress::hidden()
122            }
123        }
124    }
125}
126
127impl ProgressBars for ProgressOptions {
128    fn progress(&self, progress_kind: ProgressType, prefix: &str) -> Progress {
129        self.create_progress(prefix, progress_kind)
130    }
131}
132
133// ================ Interactive ================
134/// Wrapper around `indicatif::ProgressBar` for interactive terminal usage
135#[derive(Debug, Clone)]
136pub struct InteractiveProgress {
137    bar: ProgressBar,
138    kind: ProgressType,
139}
140
141impl InteractiveProgress {
142    fn new(prefix: &str, kind: ProgressType, tick_interval: Duration) -> Self {
143        let style = Self::initial_style(kind);
144        let bar = multi_progress().add(ProgressBar::new(0).with_style(style));
145        bar.set_prefix(prefix.to_string());
146        bar.enable_steady_tick(tick_interval);
147        Self { bar, kind }
148    }
149
150    #[allow(clippy::literal_string_with_formatting_args)]
151    fn initial_style(kind: ProgressType) -> ProgressStyle {
152        let template = match kind {
153            ProgressType::Spinner => "[{elapsed_precise}] {prefix:30} {spinner}",
154            ProgressType::Counter => "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}",
155            ProgressType::Bytes => {
156                "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}            {bytes_per_sec:12}"
157            }
158        };
159        ProgressStyle::default_bar().template(template).unwrap()
160    }
161
162    #[allow(clippy::literal_string_with_formatting_args)]
163    fn style_with_length(kind: ProgressType) -> ProgressStyle {
164        match kind {
165            ProgressType::Spinner => Self::initial_style(kind),
166            ProgressType::Counter => ProgressStyle::default_bar()
167                .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}/{len:10}")
168                .unwrap(),
169            ProgressType::Bytes => ProgressStyle::default_bar()
170                .with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| {
171                    let _ = match (s.pos(), s.len()) {
172                        (pos, Some(len)) if pos != 0 && len > pos => {
173                            let eta_secs = s.elapsed().as_secs() * (len - pos) / pos;
174                            write!(w, "{:#}", HumanDuration(Duration::from_secs(eta_secs)))
175                        }
176                        _ => write!(w, "-"),
177                    };
178                })
179                .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})")
180                .unwrap(),
181        }
182    }
183}
184
185impl RusticProgress for InteractiveProgress {
186    fn is_hidden(&self) -> bool {
187        false
188    }
189
190    fn set_length(&self, len: u64) {
191        if matches!(self.kind, ProgressType::Bytes | ProgressType::Counter) {
192            self.bar.set_style(Self::style_with_length(self.kind));
193        }
194        self.bar.set_length(len);
195    }
196
197    fn set_title(&self, title: &str) {
198        self.bar.set_prefix(title.to_string());
199    }
200
201    fn inc(&self, inc: u64) {
202        self.bar.inc(inc);
203    }
204
205    fn finish(&self) {
206        self.bar.finish_with_message("done");
207    }
208}
209
210// ================ Non-Interactive ================
211
212/// Store state for non-interactive progress
213#[derive(Debug)]
214struct NonInteractiveState {
215    prefix: String,
216    position: u64,
217    length: Option<u64>,
218    last_log: Instant,
219}
220
221impl NonInteractiveState {
222    fn progress_text(&self, kind: ProgressType) -> String {
223        let format_value = |value| match kind {
224            ProgressType::Bytes => ByteSize(value).to_string(),
225            ProgressType::Counter | ProgressType::Spinner => value.to_string(),
226        };
227
228        self.length.map_or_else(
229            || format_value(self.position),
230            |len| format!("{} / {}", format_value(self.position), format_value(len)),
231        )
232    }
233
234    fn should_log(&self, interval: Duration) -> bool {
235        self.last_log.elapsed() >= interval
236    }
237
238    fn mark_logged(&mut self) {
239        self.last_log = Instant::now();
240    }
241}
242
243/// Periodic logger for non-interactive environments (i.e. systemd)
244/// Implemented thread-safe and decouples logging logic from indicatif
245#[derive(Clone, Debug)]
246pub struct NonInteractiveProgress {
247    state: Arc<Mutex<NonInteractiveState>>,
248    start: Instant,
249    interval: Duration,
250    kind: ProgressType,
251}
252
253impl NonInteractiveProgress {
254    fn new(prefix: &str, interval: Duration, kind: ProgressType) -> Self {
255        let now = Instant::now();
256        Self {
257            state: Arc::new(Mutex::new(NonInteractiveState {
258                prefix: prefix.to_string(),
259                position: 0,
260                length: None,
261                last_log: now,
262            })),
263            start: now,
264            interval,
265            kind,
266        }
267    }
268
269    fn format_value(&self, value: u64) -> String {
270        match self.kind {
271            ProgressType::Bytes => ByteSize(value).to_string(), // delegate bytesize handling
272            ProgressType::Counter | ProgressType::Spinner => value.to_string(),
273        }
274    }
275
276    fn log_progress(&self, state: &NonInteractiveState) {
277        info!("{}: {}", state.prefix, state.progress_text(self.kind));
278    }
279}
280
281impl RusticProgress for NonInteractiveProgress {
282    fn is_hidden(&self) -> bool {
283        false
284    }
285
286    fn set_length(&self, len: u64) {
287        if let Ok(mut state) = self.state.lock() {
288            state.length = Some(len);
289        }
290    }
291
292    fn set_title(&self, title: &str) {
293        if let Ok(mut state) = self.state.lock() {
294            state.prefix = title.to_string();
295        }
296    }
297
298    fn inc(&self, inc: u64) {
299        if let Ok(mut state) = self.state.lock() {
300            state.position += inc;
301
302            if state.should_log(self.interval) {
303                self.log_progress(&state);
304                state.mark_logged();
305            }
306        }
307    }
308
309    fn finish(&self) {
310        let Ok(state) = self.state.lock() else {
311            return;
312        };
313
314        info!(
315            "{}: {} done in {:.2?}",
316            state.prefix,
317            self.format_value(state.position),
318            self.start.elapsed()
319        );
320    }
321}
322
323// ================ JSON ================
324
325/// Periodic JSON lines progress for machine-readable consumers
326#[derive(Clone, Debug)]
327pub struct JsonProgress {
328    state: Arc<Mutex<NonInteractiveState>>,
329    start: Instant,
330    interval: Duration,
331    kind: ProgressType,
332}
333
334#[derive(Serialize)]
335struct JsonProgressStatus {
336    message_type: &'static str,
337    seconds_elapsed: u64,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    seconds_remaining: Option<u64>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    percent_done: Option<f64>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    total_bytes: Option<u64>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    bytes_done: Option<u64>,
346}
347
348impl JsonProgress {
349    fn new(prefix: &str, interval: Duration, kind: ProgressType) -> Self {
350        let now = Instant::now();
351        Self {
352            state: Arc::new(Mutex::new(NonInteractiveState {
353                prefix: prefix.to_string(),
354                position: 0,
355                length: None,
356                last_log: now,
357            })),
358            start: now,
359            interval,
360            kind,
361        }
362    }
363
364    fn log_progress(&self, state: &NonInteractiveState) {
365        let is_bytes = matches!(self.kind, ProgressType::Bytes);
366        let elapsed = self.start.elapsed().as_secs();
367        let percent_done = state
368            .length
369            .filter(|len| *len > 0)
370            .map(|len| (state.position as f64 / len as f64).min(1.0));
371        let seconds_remaining = match (state.position, state.length) {
372            (position, Some(len)) if position > 0 && len > position => {
373                Some(elapsed.saturating_mul(len - position) / position)
374            }
375            _ => None,
376        };
377
378        let status = JsonProgressStatus {
379            message_type: "status",
380            seconds_elapsed: elapsed,
381            seconds_remaining,
382            percent_done,
383            total_bytes: is_bytes.then_some(state.length).flatten(),
384            bytes_done: is_bytes.then_some(state.position),
385        };
386
387        let mut stdout = std::io::stdout().lock();
388        _ = serde_json::to_writer(&mut stdout, &status);
389        _ = writeln!(stdout);
390    }
391}
392
393impl RusticProgress for JsonProgress {
394    fn is_hidden(&self) -> bool {
395        false
396    }
397
398    fn set_length(&self, len: u64) {
399        if let Ok(mut state) = self.state.lock() {
400            state.length = Some(len);
401            self.log_progress(&state);
402            state.mark_logged();
403        }
404    }
405
406    fn set_title(&self, title: &str) {
407        if let Ok(mut state) = self.state.lock() {
408            state.prefix = title.to_string();
409        }
410    }
411
412    fn inc(&self, inc: u64) {
413        if let Ok(mut state) = self.state.lock() {
414            state.position += inc;
415
416            if state.should_log(self.interval) {
417                self.log_progress(&state);
418                state.mark_logged();
419            }
420        }
421    }
422
423    fn finish(&self) {
424        let Ok(state) = self.state.lock() else {
425            return;
426        };
427
428        self.log_progress(&state);
429    }
430}