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)]
13pub enum ProgressOutputType {
15 JsonReporter,
17 Tty,
19 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ProgressBarKind {
36 Bytes,
37 Files,
38}
39
40pub struct ProgressPrinter {
42 multi_progress: MultiProgress,
43 output_type: ProgressOutputType,
44 number_of_steps: u16,
45}
46
47impl ProgressPrinter {
48 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 pub fn output_type(&self) -> ProgressOutputType {
59 self.output_type
60 }
61
62 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 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#[derive(Clone)]
104pub struct ProgressBarJsonFormatter {
105 label: String,
106 kind: ProgressBarKind,
107}
108
109impl ProgressBarJsonFormatter {
110 pub fn new<T: Into<String>>(label: T, kind: ProgressBarKind) -> Self {
112 Self {
113 label: label.into(),
114 kind,
115 }
116 }
117
118 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#[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 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(¶ms.label, params.progress_bar_kind),
227 last_json_report_instant: Arc::new(RwLock::new(None)),
228 logger,
229 }
230 }
231
232 #[cfg(test)]
233 pub fn kind(&self) -> ProgressBarKind {
235 self.json_reporter.kind
236 }
237
238 pub fn report(&self, actual_position: u64) {
240 self.progress_bar.set_position(actual_position);
241 self.report_json_progress();
242 }
243
244 pub fn inc(&self, delta: u64) {
246 self.progress_bar.inc(delta);
247 self.report_json_progress();
248 }
249
250 pub fn finish(&self, message: &str) {
252 self.progress_bar.finish_with_message(message.to_string());
253 }
254
255 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 let progress_bar = ProgressBar::new(4);
422 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 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 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}