Skip to main content

rustic_rs/config/
progress_options.rs

1//! Progress Bar Config
2
3use std::{fmt::Write, time::Duration};
4
5use std::io::IsTerminal;
6use std::sync::{Arc, Mutex};
7use std::time::Instant;
8
9use bytesize::ByteSize;
10use indicatif::{HumanDuration, 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
22mod constants {
23    use std::time::Duration;
24
25    pub(super) const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
26    pub(super) const DEFAULT_LOG_INTERVAL: Duration = Duration::from_secs(10);
27}
28
29/// Progress Bar Config
30#[serde_as]
31#[derive(Default, Debug, Parser, Clone, Copy, Deserialize, Serialize, Merge)]
32#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
33pub struct ProgressOptions {
34    /// Don't show any progress bar
35    #[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
36    #[merge(strategy=conflate::bool::overwrite_false)]
37    pub no_progress: bool,
38
39    /// Interval to update progress bars (default: 100ms)
40    #[clap(
41        long,
42        global = true,
43        env = "RUSTIC_PROGRESS_INTERVAL",
44        value_name = "DURATION",
45        conflicts_with = "no_progress"
46    )]
47    #[serde_as(as = "Option<DisplayFromStr>")]
48    #[merge(strategy=conflate::option::overwrite_none)]
49    pub progress_interval: Option<SignedDuration>,
50}
51
52impl ProgressOptions {
53    /// Get interval for interactive progress bars
54    fn interactive_interval(&self) -> Duration {
55        self.progress_interval
56            .map_or(constants::DEFAULT_INTERVAL, |i| {
57                i.try_into().expect("negative durations are not allowed")
58            })
59    }
60
61    /// Get interval for non-interactive logging
62    fn log_interval(&self) -> Duration {
63        self.progress_interval
64            .map_or(constants::DEFAULT_LOG_INTERVAL, |i| {
65                i.try_into().expect("negative durations are not allowed")
66            })
67    }
68
69    /// Factory Pattern: Create progress indicator based on terminal capabilities
70    ///
71    /// * `Hidden`: If --no-progress is set.
72    /// * `Interactive`: If running in a TTY.
73    /// * `NonInteractive`: If running in a pipe/service (logs to stderr).
74    fn create_progress(&self, prefix: &str, kind: ProgressType) -> Progress {
75        if self.no_progress {
76            return Progress::hidden();
77        }
78
79        if std::io::stderr().is_terminal() {
80            Progress::new(InteractiveProgress::new(
81                prefix,
82                kind,
83                self.interactive_interval(),
84            ))
85        } else {
86            let interval = self.log_interval();
87            if interval > Duration::ZERO {
88                Progress::new(NonInteractiveProgress::new(prefix, interval, kind))
89            } else {
90                Progress::hidden()
91            }
92        }
93    }
94}
95
96impl ProgressBars for ProgressOptions {
97    fn progress(&self, progress_kind: ProgressType, prefix: &str) -> Progress {
98        self.create_progress(prefix, progress_kind)
99    }
100}
101
102// ================ Interactive ================
103/// Wrapper around `indicatif::ProgressBar` for interactive terminal usage
104#[derive(Debug, Clone)]
105pub struct InteractiveProgress {
106    bar: ProgressBar,
107    kind: ProgressType,
108}
109
110impl InteractiveProgress {
111    fn new(prefix: &str, kind: ProgressType, tick_interval: Duration) -> Self {
112        let style = Self::initial_style(kind);
113        let bar = ProgressBar::new(0).with_style(style);
114        bar.set_prefix(prefix.to_string());
115        bar.enable_steady_tick(tick_interval);
116        Self { bar, kind }
117    }
118
119    #[allow(clippy::literal_string_with_formatting_args)]
120    fn initial_style(kind: ProgressType) -> ProgressStyle {
121        let template = match kind {
122            ProgressType::Spinner => "[{elapsed_precise}] {prefix:30} {spinner}",
123            ProgressType::Counter => "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}",
124            ProgressType::Bytes => {
125                "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}            {bytes_per_sec:12}"
126            }
127        };
128        ProgressStyle::default_bar().template(template).unwrap()
129    }
130
131    #[allow(clippy::literal_string_with_formatting_args)]
132    fn style_with_length(kind: ProgressType) -> ProgressStyle {
133        match kind {
134            ProgressType::Spinner => Self::initial_style(kind),
135            ProgressType::Counter => ProgressStyle::default_bar()
136                .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}/{len:10}")
137                .unwrap(),
138            ProgressType::Bytes => ProgressStyle::default_bar()
139                .with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| {
140                    let _ = match (s.pos(), s.len()) {
141                        (pos, Some(len)) if pos != 0 && len > pos => {
142                            let eta_secs = s.elapsed().as_secs() * (len - pos) / pos;
143                            write!(w, "{:#}", HumanDuration(Duration::from_secs(eta_secs)))
144                        }
145                        _ => write!(w, "-"),
146                    };
147                })
148                .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})")
149                .unwrap(),
150        }
151    }
152}
153
154impl RusticProgress for InteractiveProgress {
155    fn is_hidden(&self) -> bool {
156        false
157    }
158
159    fn set_length(&self, len: u64) {
160        if matches!(self.kind, ProgressType::Bytes | ProgressType::Counter) {
161            self.bar.set_style(Self::style_with_length(self.kind));
162        }
163        self.bar.set_length(len);
164    }
165
166    fn set_title(&self, title: &str) {
167        self.bar.set_prefix(title.to_string());
168    }
169
170    fn inc(&self, inc: u64) {
171        self.bar.inc(inc);
172    }
173
174    fn finish(&self) {
175        self.bar.finish_with_message("done");
176    }
177}
178
179// ================ Non-Interactive ================
180
181/// Store state for non-interactive progress
182#[derive(Debug)]
183struct NonInteractiveState {
184    prefix: String,
185    position: u64,
186    length: Option<u64>,
187    last_log: Instant,
188}
189
190/// Periodic logger for non-interactive environments (i.e. systemd)
191/// Implemented thread-safe and decouples logging logic from indicatif
192#[derive(Clone, Debug)]
193pub struct NonInteractiveProgress {
194    state: Arc<Mutex<NonInteractiveState>>,
195    start: Instant,
196    interval: Duration,
197    kind: ProgressType,
198}
199
200impl NonInteractiveProgress {
201    fn new(prefix: &str, interval: Duration, kind: ProgressType) -> Self {
202        let now = Instant::now();
203        Self {
204            state: Arc::new(Mutex::new(NonInteractiveState {
205                prefix: prefix.to_string(),
206                position: 0,
207                length: None,
208                last_log: now,
209            })),
210            start: now,
211            interval,
212            kind,
213        }
214    }
215
216    fn format_value(&self, value: u64) -> String {
217        match self.kind {
218            ProgressType::Bytes => ByteSize(value).to_string(), // delegate bytesize handling
219            ProgressType::Counter | ProgressType::Spinner => value.to_string(),
220        }
221    }
222
223    fn log_progress(&self, state: &NonInteractiveState) {
224        let progress = state.length.map_or_else(
225            || self.format_value(state.position),
226            |len| {
227                format!(
228                    "{} / {}",
229                    self.format_value(state.position),
230                    self.format_value(len)
231                )
232            },
233        );
234        info!("{}: {}", state.prefix, progress);
235    }
236}
237
238impl RusticProgress for NonInteractiveProgress {
239    fn is_hidden(&self) -> bool {
240        false
241    }
242
243    fn set_length(&self, len: u64) {
244        if let Ok(mut state) = self.state.lock() {
245            state.length = Some(len);
246        }
247    }
248
249    fn set_title(&self, title: &str) {
250        if let Ok(mut state) = self.state.lock() {
251            state.prefix = title.to_string();
252        }
253    }
254
255    fn inc(&self, inc: u64) {
256        if let Ok(mut state) = self.state.lock() {
257            state.position += inc;
258
259            if state.last_log.elapsed() >= self.interval {
260                self.log_progress(&state);
261                state.last_log = Instant::now();
262            }
263        }
264    }
265
266    fn finish(&self) {
267        let Ok(state) = self.state.lock() else {
268            return;
269        };
270
271        info!(
272            "{}: {} done in {:.2?}",
273            state.prefix,
274            self.format_value(state.position),
275            self.start.elapsed()
276        );
277    }
278}