mithril_client_cli/utils/
progress_reporter.rs

1use chrono::Utc;
2use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
3use mithril_client::MithrilResult;
4use slog::{Logger, warn};
5use std::{
6    fmt::Write,
7    ops::Deref,
8    sync::{Arc, RwLock},
9    time::{Duration, Instant},
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13/// Output type of a [ProgressPrinter] or a [DownloadProgressReporter]
14pub enum ProgressOutputType {
15    /// Output to json
16    JsonReporter,
17    /// Output to tty
18    Tty,
19    /// No output
20    Hidden,
21}
22
23impl From<ProgressOutputType> for ProgressDrawTarget {
24    fn from(value: ProgressOutputType) -> Self {
25        match value {
26            ProgressOutputType::JsonReporter => ProgressDrawTarget::hidden(),
27            ProgressOutputType::Tty => ProgressDrawTarget::stderr(),
28            ProgressOutputType::Hidden => ProgressDrawTarget::hidden(),
29        }
30    }
31}
32
33/// Kind of progress bar
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ProgressBarKind {
36    Bytes,
37    Files,
38}
39
40/// Wrapper of a indicatif [MultiProgress] to allow reporting to json.
41pub struct ProgressPrinter {
42    multi_progress: MultiProgress,
43    output_type: ProgressOutputType,
44    number_of_steps: u16,
45}
46
47impl ProgressPrinter {
48    /// Instantiate a new progress printer
49    pub fn new(output_type: ProgressOutputType, number_of_steps: u16) -> Self {
50        Self {
51            multi_progress: MultiProgress::with_draw_target(output_type.into()),
52            output_type,
53            number_of_steps,
54        }
55    }
56
57    /// Return the output type of the progress printer
58    pub fn output_type(&self) -> ProgressOutputType {
59        self.output_type
60    }
61
62    /// Report the current step
63    pub fn report_step(&self, step_number: u16, text: &str) -> MithrilResult<()> {
64        match self.output_type {
65            ProgressOutputType::JsonReporter => eprintln!(
66                r#"{{"timestamp": "{timestamp}", "step_num": {step_number}, "total_steps": {number_of_steps}, "message": "{text}"}}"#,
67                timestamp = Utc::now().to_rfc3339(),
68                number_of_steps = self.number_of_steps,
69            ),
70            ProgressOutputType::Tty => self
71                .multi_progress
72                .println(format!("{step_number}/{} - {text}", self.number_of_steps))?,
73            ProgressOutputType::Hidden => (),
74        };
75
76        Ok(())
77    }
78
79    /// Print a message to the output
80    pub fn print_message(&self, text: &str) -> MithrilResult<()> {
81        match self.output_type {
82            ProgressOutputType::JsonReporter => eprintln!(
83                r#"{{"timestamp": "{timestamp}", "message": "{text}"}}"#,
84                timestamp = Utc::now().to_rfc3339(),
85            ),
86            ProgressOutputType::Tty => self.multi_progress.println(text)?,
87            ProgressOutputType::Hidden => (),
88        };
89
90        Ok(())
91    }
92}
93
94impl Deref for ProgressPrinter {
95    type Target = MultiProgress;
96
97    fn deref(&self) -> &Self::Target {
98        &self.multi_progress
99    }
100}
101
102/// Utility to format a [ProgressBar] status as json
103#[derive(Clone)]
104pub struct ProgressBarJsonFormatter {
105    label: String,
106    kind: ProgressBarKind,
107}
108
109impl ProgressBarJsonFormatter {
110    /// Instantiate a `ProgressBarJsonFormatter`
111    pub fn new<T: Into<String>>(label: T, kind: ProgressBarKind) -> Self {
112        Self {
113            label: label.into(),
114            kind,
115        }
116    }
117
118    /// Get a json formatted string given the progress bar status
119    pub fn format(&self, progress_bar: &ProgressBar) -> String {
120        ProgressBarJsonFormatter::format_values(
121            &self.label,
122            self.kind,
123            Utc::now().to_rfc3339(),
124            progress_bar.position(),
125            progress_bar.length().unwrap_or(0),
126            progress_bar.eta(),
127            progress_bar.elapsed(),
128        )
129    }
130
131    fn format_values(
132        label: &str,
133        kind: ProgressBarKind,
134        timestamp: String,
135        amount_downloaded: u64,
136        amount_total: u64,
137        duration_left: Duration,
138        duration_elapsed: Duration,
139    ) -> String {
140        let amount_prefix = match kind {
141            ProgressBarKind::Bytes => "bytes",
142            ProgressBarKind::Files => "files",
143        };
144
145        format!(
146            r#"{{"label": "{}", "timestamp": "{}", "{}_downloaded": {}, "{}_total": {}, "seconds_left": {}.{:0>3}, "seconds_elapsed": {}.{:0>3}}}"#,
147            label,
148            timestamp,
149            amount_prefix,
150            amount_downloaded,
151            amount_prefix,
152            amount_total,
153            duration_left.as_secs(),
154            duration_left.subsec_millis(),
155            duration_elapsed.as_secs(),
156            duration_elapsed.subsec_millis(),
157        )
158    }
159}
160
161/// Wrapper of a indicatif [ProgressBar] to allow reporting to json.
162#[derive(Clone)]
163pub struct DownloadProgressReporter {
164    progress_bar: ProgressBar,
165    output_type: ProgressOutputType,
166    json_reporter: ProgressBarJsonFormatter,
167    last_json_report_instant: Arc<RwLock<Option<Instant>>>,
168    logger: Logger,
169}
170
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct DownloadProgressReporterParams {
173    pub label: String,
174    pub output_type: ProgressOutputType,
175    pub progress_bar_kind: ProgressBarKind,
176    pub include_label_in_tty: bool,
177}
178
179impl DownloadProgressReporterParams {
180    pub fn style(&self) -> ProgressStyle {
181        ProgressStyle::with_template(&self.style_template())
182            .unwrap()
183            .with_key(
184                "eta",
185                |state: &indicatif::ProgressState, w: &mut dyn Write| {
186                    write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
187                },
188            )
189            .progress_chars("#>-")
190    }
191
192    fn style_template(&self) -> String {
193        let label = if self.include_label_in_tty {
194            &self.label
195        } else {
196            ""
197        };
198
199        match self.progress_bar_kind {
200            ProgressBarKind::Bytes => {
201                format!(
202                    "{{spinner:.green}} {label} [{{elapsed_precise}}] [{{wide_bar:.cyan/blue}}] {{bytes}}/{{total_bytes}} ({{eta}})"
203                )
204            }
205            ProgressBarKind::Files => {
206                format!(
207                    "{{spinner:.green}} {label} [{{elapsed_precise}}] [{{wide_bar:.cyan/blue}}] Files: {{human_pos}}/{{human_len}} ({{eta}})"
208                )
209            }
210        }
211    }
212}
213
214impl DownloadProgressReporter {
215    /// Instantiate a new progress reporter
216    pub fn new(
217        progress_bar: ProgressBar,
218        params: DownloadProgressReporterParams,
219        logger: Logger,
220    ) -> Self {
221        progress_bar.set_style(params.style());
222
223        Self {
224            progress_bar,
225            output_type: params.output_type,
226            json_reporter: ProgressBarJsonFormatter::new(&params.label, params.progress_bar_kind),
227            last_json_report_instant: Arc::new(RwLock::new(None)),
228            logger,
229        }
230    }
231
232    #[cfg(test)]
233    /// Get the kind of the download progress bar
234    pub fn kind(&self) -> ProgressBarKind {
235        self.json_reporter.kind
236    }
237
238    /// Report the current progress, setting the actual position to the given value
239    pub fn report(&self, actual_position: u64) {
240        self.progress_bar.set_position(actual_position);
241        self.report_json_progress();
242    }
243
244    /// Report the current progress, incrementing the actual position by the given delta
245    pub fn inc(&self, delta: u64) {
246        self.progress_bar.inc(delta);
247        self.report_json_progress();
248    }
249
250    /// Report that the current download is finished and print the given message.
251    pub fn finish(&self, message: &str) {
252        self.progress_bar.finish_with_message(message.to_string());
253    }
254
255    /// Finish the progress bar and clear the line
256    pub fn finish_and_clear(&self) {
257        self.progress_bar.finish_and_clear();
258    }
259
260    fn get_remaining_time_since_last_json_report(&self) -> Option<Duration> {
261        match self.last_json_report_instant.read() {
262            Ok(instant) => (*instant).map(|instant| instant.elapsed()),
263            Err(_) => None,
264        }
265    }
266
267    fn report_json_progress(&self) {
268        if let ProgressOutputType::JsonReporter = self.output_type {
269            let should_report = match self.get_remaining_time_since_last_json_report() {
270                Some(remaining_time) => remaining_time > Duration::from_millis(333),
271                None => true,
272            };
273
274            if should_report {
275                eprintln!("{}", self.json_reporter.format(&self.progress_bar));
276
277                match self.last_json_report_instant.write() {
278                    Ok(mut instant) => *instant = Some(Instant::now()),
279                    Err(error) => {
280                        warn!(self.logger, "failed to update last json report instant"; "error" => ?error)
281                    }
282                };
283            }
284        };
285    }
286
287    pub(crate) fn inner_progress_bar(&self) -> &ProgressBar {
288        &self.progress_bar
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::thread::sleep;
295
296    use super::*;
297    use indicatif::ProgressBar;
298    use serde_json::Value;
299
300    #[test]
301    fn json_reporter_change_downloaded_and_total_key_prefix_based_on_progress_bar_kind() {
302        fn run(kind: ProgressBarKind, expected_prefix: &str) {
303            let json_string = ProgressBarJsonFormatter::format_values(
304                "label",
305                kind,
306                "timestamp".to_string(),
307                0,
308                0,
309                Duration::from_millis(1000),
310                Duration::from_millis(2500),
311            );
312
313            assert!(
314                json_string.contains(&format!(r#""{expected_prefix}_downloaded":"#)),
315                "'{expected_prefix}_downloaded' key not found in json output: {json_string}",
316            );
317            assert!(
318                json_string.contains(&format!(r#""{expected_prefix}_total":"#)),
319                "'{expected_prefix}_total' key not found in json output: {json_string}",
320            );
321        }
322
323        run(ProgressBarKind::Bytes, "bytes");
324        run(ProgressBarKind::Files, "files");
325    }
326
327    #[test]
328    fn json_report_include_label() {
329        let json_string = ProgressBarJsonFormatter::format_values(
330            "unique_label",
331            ProgressBarKind::Bytes,
332            "timestamp".to_string(),
333            0,
334            0,
335            Duration::from_millis(7569),
336            Duration::from_millis(5124),
337        );
338
339        assert!(
340            json_string.contains(r#""label": "unique_label""#),
341            "Label key and/or value not found in json output: {json_string}",
342        );
343    }
344
345    #[test]
346    fn check_seconds_formatting_in_json_report_with_more_than_100_milliseconds() {
347        let json_string = ProgressBarJsonFormatter::format_values(
348            "label",
349            ProgressBarKind::Bytes,
350            "timestamp".to_string(),
351            0,
352            0,
353            Duration::from_millis(7569),
354            Duration::from_millis(5124),
355        );
356
357        assert!(
358            json_string.contains(r#""seconds_left": 7.569"#),
359            "Not expected value in json output: {json_string}",
360        );
361        assert!(
362            json_string.contains(r#""seconds_elapsed": 5.124"#),
363            "Not expected value in json output: {json_string}",
364        );
365    }
366
367    #[test]
368    fn check_seconds_formatting_in_json_report_with_less_than_100_milliseconds() {
369        let json_string = ProgressBarJsonFormatter::format_values(
370            "label",
371            ProgressBarKind::Bytes,
372            "timestamp".to_string(),
373            0,
374            0,
375            Duration::from_millis(7006),
376            Duration::from_millis(5004),
377        );
378
379        assert!(
380            json_string.contains(r#""seconds_left": 7.006"#),
381            "Not expected value in json output: {json_string}"
382        );
383        assert!(
384            json_string.contains(r#""seconds_elapsed": 5.004"#),
385            "Not expected value in json output: {json_string}"
386        );
387    }
388
389    #[test]
390    fn check_seconds_formatting_in_json_report_with_milliseconds_ending_by_zeros() {
391        let json_string = ProgressBarJsonFormatter::format_values(
392            "label",
393            ProgressBarKind::Bytes,
394            "timestamp".to_string(),
395            0,
396            0,
397            Duration::from_millis(7200),
398            Duration::from_millis(5100),
399        );
400
401        assert!(
402            json_string.contains(r#""seconds_left": 7.200"#),
403            "Not expected value in json output: {json_string}"
404        );
405        assert!(
406            json_string.contains(r#""seconds_elapsed": 5.100"#),
407            "Not expected value in json output: {json_string}"
408        );
409    }
410
411    #[test]
412    fn check_seconds_left_and_elapsed_time_are_used_by_the_formatter() {
413        fn format_duration(duration: &Duration) -> String {
414            format!("{}.{}", duration.as_secs(), duration.subsec_nanos())
415        }
416        fn round_at_ms(duration: Duration) -> Duration {
417            Duration::from_millis(duration.as_millis() as u64)
418        }
419
420        // 4 steps
421        let progress_bar = ProgressBar::new(4);
422        // 1 step done in 15 ms, left 45ms to finish the 4th steps
423        sleep(Duration::from_millis(15));
424        progress_bar.set_position(1);
425
426        let duration_left_before = round_at_ms(progress_bar.eta());
427        let duration_elapsed_before = round_at_ms(progress_bar.elapsed());
428
429        let json_string =
430            ProgressBarJsonFormatter::new("label", ProgressBarKind::Bytes).format(&progress_bar);
431
432        let duration_left_after = round_at_ms(progress_bar.eta());
433        let duration_elapsed_after = round_at_ms(progress_bar.elapsed());
434
435        // Milliseconds in json may not be exactly the same as the one we get because of the test duration.
436        let delta = 0.1;
437
438        let json_value: Value = serde_json::from_str(&json_string).unwrap();
439        let seconds_left = json_value["seconds_left"].as_f64().unwrap();
440        let seconds_elapsed = json_value["seconds_elapsed"].as_f64().unwrap();
441
442        // We check that we pass the right values to format checking that time left is 3 times the time elapsed
443        assert!(
444            seconds_elapsed * 3.0 - delta < seconds_left
445                && seconds_left < seconds_elapsed * 3.0 + delta,
446            "seconds_left should be close to 3*{} but it's {}.",
447            &seconds_elapsed,
448            &seconds_left
449        );
450
451        let duration_left = Duration::from_secs_f64(seconds_left);
452        assert!(
453            duration_left_before <= duration_left && duration_left <= duration_left_after,
454            "Duration left: {} should be between {} and {}",
455            format_duration(&duration_left),
456            format_duration(&duration_left_before),
457            format_duration(&duration_left_after),
458        );
459
460        let duration_elapsed = Duration::from_secs_f64(seconds_elapsed);
461        assert!(
462            duration_elapsed_before <= duration_elapsed
463                && duration_elapsed <= duration_elapsed_after,
464            "Duration elapsed: {} should be between {} and {}",
465            format_duration(&duration_elapsed),
466            format_duration(&duration_elapsed_before),
467            format_duration(&duration_elapsed_after),
468        );
469    }
470
471    #[test]
472    fn style_of_download_progress_reporter_when_include_label_in_tty_is_false() {
473        let params = DownloadProgressReporterParams {
474            label: "label".to_string(),
475            output_type: ProgressOutputType::Tty,
476            progress_bar_kind: ProgressBarKind::Bytes,
477            include_label_in_tty: false,
478        };
479
480        let style_template = params.style_template();
481        assert!(
482            !style_template.contains("label"),
483            "Label should not be included in the style template, got: '{style_template}'"
484        );
485    }
486
487    #[test]
488    fn style_of_download_progress_reporter_when_include_label_in_tty_is_true() {
489        let params = DownloadProgressReporterParams {
490            label: "label".to_string(),
491            output_type: ProgressOutputType::Tty,
492            progress_bar_kind: ProgressBarKind::Bytes,
493            include_label_in_tty: true,
494        };
495
496        let style_template = params.style_template();
497        assert!(
498            style_template.contains("label"),
499            "Label should be included in the style template, got: '{style_template}'"
500        );
501    }
502}