1use 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
22pub 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#[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 #[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
49 #[merge(strategy=conflate::bool::overwrite_false)]
50 pub no_progress: bool,
51
52 #[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 #[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 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 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 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#[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#[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#[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(), 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#[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}