Skip to main content

harmont_cli/output/
progress.rs

1//! Progress-bar [`OutputRenderer`] — bridges [`BuildEvent`]s into
2//! `tracing` spans that `tracing-indicatif` renders as live progress
3//! bars.
4//!
5//! Each pipeline step gets its own child span (and therefore its own
6//! progress bar). Completed steps stay visible with a ✓/✗ indicator;
7//! only actively running steps show a spinner. Logs are buffered
8//! silently and only replayed to the writer on failure.
9
10use std::collections::HashMap;
11use std::fmt;
12use std::io::Write;
13
14use hm_plugin_protocol::BuildEvent;
15use indicatif::ProgressStyle;
16use owo_colors::{OwoColorize, Style};
17use tracing::{Span, info_span};
18use tracing_indicatif::span_ext::IndicatifSpanExt;
19use uuid::Uuid;
20
21use crate::runner::OutputRenderer;
22
23fn styled(text: &str, style: Style, color: bool) -> String {
24    if color {
25        format!("{}", text.style(style))
26    } else {
27        text.to_string()
28    }
29}
30
31#[allow(clippy::literal_string_with_formatting_args)]
32fn active_style(color: bool) -> ProgressStyle {
33    let tpl = if color {
34        "{span_child_prefix}{spinner:.cyan} {wide_msg}  ({elapsed})"
35    } else {
36        "{span_child_prefix}{spinner} {wide_msg}  ({elapsed})"
37    };
38    ProgressStyle::with_template(tpl).unwrap_or_else(|_| ProgressStyle::default_spinner())
39}
40
41#[allow(clippy::literal_string_with_formatting_args)]
42fn completed_style(color: bool) -> ProgressStyle {
43    let check = if color {
44        format!("{}", "✓".green())
45    } else {
46        "✓".to_string()
47    };
48    let tpl = format!("{{span_child_prefix}}{check} {{wide_msg}}");
49    ProgressStyle::with_template(&tpl).unwrap_or_else(|_| ProgressStyle::default_spinner())
50}
51
52#[allow(clippy::literal_string_with_formatting_args)]
53fn failed_style(color: bool) -> ProgressStyle {
54    let cross = if color {
55        format!("{}", "✗".red())
56    } else {
57        "✗".to_string()
58    };
59    let tpl = format!("{{span_child_prefix}}{cross} {{wide_msg}}");
60    ProgressStyle::with_template(&tpl).unwrap_or_else(|_| ProgressStyle::default_spinner())
61}
62
63fn format_duration(ms: u64) -> String {
64    if ms < 1000 {
65        format!("{ms}ms")
66    } else if ms < 60_000 {
67        let secs = ms / 1000;
68        let tenths = (ms % 1000) / 100;
69        format!("{secs}.{tenths}s")
70    } else {
71        let mins = ms / 60_000;
72        let secs = (ms % 60_000) / 1000;
73        format!("{mins}m{secs}s")
74    }
75}
76
77/// Progress-bar renderer.
78///
79/// Generic over `W: Write` so tests can capture text output into a
80/// `Vec<u8>` while production code writes to `std::io::Stderr`.
81#[derive(Debug)]
82pub(crate) enum StepOutcome {
83    Succeeded { duration_ms: u64 },
84    Failed { duration_ms: u64, exit_code: i32 },
85    Cancelled { duration_ms: u64 },
86    Cached,
87}
88
89pub struct ProgressRenderer<W> {
90    out: W,
91    pub(crate) color: bool,
92    root_span: Option<Span>,
93    step_spans: HashMap<Uuid, Span>,
94    step_keys: HashMap<Uuid, String>,
95    step_names: HashMap<Uuid, String>,
96    log_buffer: HashMap<Uuid, Vec<String>>,
97    failed_steps: Vec<(Uuid, i32)>,
98    step_order: Vec<Uuid>,
99    pub(crate) step_outcomes: HashMap<Uuid, StepOutcome>,
100}
101
102impl<W> fmt::Debug for ProgressRenderer<W> {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.debug_struct("ProgressRenderer")
105            .field("steps_tracked", &self.step_spans.len())
106            .finish_non_exhaustive()
107    }
108}
109
110impl<W> ProgressRenderer<W> {
111    #[must_use]
112    pub fn new(out: W, color: bool) -> Self {
113        Self {
114            out,
115            color,
116            root_span: None,
117            step_spans: HashMap::new(),
118            step_keys: HashMap::new(),
119            step_names: HashMap::new(),
120            log_buffer: HashMap::new(),
121            failed_steps: Vec::new(),
122            step_order: Vec::new(),
123            step_outcomes: HashMap::new(),
124        }
125    }
126}
127
128impl<W: Write> ProgressRenderer<W> {
129    fn print_failure_report(&mut self) {
130        for (step_id, exit_code) in &self.failed_steps {
131            let name = self.step_names.get(step_id).map_or("?", String::as_str);
132            let header = format!("--- {name} failed (exit {exit_code}) ---");
133            let _ = writeln!(
134                self.out,
135                "\n{}",
136                styled(&header, Style::new().red(), self.color)
137            );
138            if let Some(lines) = self.log_buffer.get(step_id) {
139                for line in lines {
140                    let _ = writeln!(self.out, "{line}");
141                }
142            }
143        }
144    }
145
146    fn print_step_summary(&mut self) {
147        let max_name_len = self
148            .step_order
149            .iter()
150            .filter_map(|id| self.step_names.get(id))
151            .map(String::len)
152            .max()
153            .unwrap_or(0);
154
155        let _ = writeln!(self.out);
156        for step_id in &self.step_order {
157            let name = self.step_names.get(step_id).map_or("?", String::as_str);
158            let (indicator, timing) = match self.step_outcomes.get(step_id) {
159                Some(StepOutcome::Succeeded { duration_ms }) => (
160                    styled("✓", Style::new().green(), self.color),
161                    styled(
162                        &format_duration(*duration_ms),
163                        Style::new().dimmed(),
164                        self.color,
165                    ),
166                ),
167                Some(StepOutcome::Failed {
168                    duration_ms,
169                    exit_code,
170                }) => (
171                    styled("✗", Style::new().red(), self.color),
172                    styled(
173                        &format!("{}  exit {exit_code}", format_duration(*duration_ms)),
174                        Style::new().red(),
175                        self.color,
176                    ),
177                ),
178                Some(StepOutcome::Cancelled { duration_ms }) => (
179                    styled("-", Style::new().dimmed(), self.color),
180                    styled(
181                        &format!("{}  cancelled", format_duration(*duration_ms)),
182                        Style::new().dimmed(),
183                        self.color,
184                    ),
185                ),
186                Some(StepOutcome::Cached) => (
187                    styled("✓", Style::new().green(), self.color),
188                    styled("cached", Style::new().dimmed(), self.color),
189                ),
190                None => (
191                    styled("-", Style::new().dimmed(), self.color),
192                    styled("—", Style::new().dimmed(), self.color),
193                ),
194            };
195            let _ = writeln!(self.out, "  {indicator} {name:<max_name_len$}  {timing}");
196        }
197    }
198}
199
200impl<W> OutputRenderer for ProgressRenderer<W>
201where
202    W: Write + Send + fmt::Debug,
203{
204    #[allow(clippy::too_many_lines, clippy::literal_string_with_formatting_args)]
205    fn on_event(&mut self, event: &BuildEvent) {
206        match event {
207            BuildEvent::BuildStart { plan, .. } => {
208                let root = info_span!("pipeline");
209
210                let tpl = if self.color {
211                    "{spinner:.green} {span_name}  {wide_bar:.green/white} {pos}/{len} steps  ({elapsed})"
212                } else {
213                    "{spinner} {span_name}  {wide_bar} {pos}/{len} steps  ({elapsed})"
214                };
215                root.pb_set_style(
216                    &ProgressStyle::with_template(tpl)
217                        .unwrap_or_else(|_| ProgressStyle::default_bar()),
218                );
219                root.pb_set_length(plan.step_count as u64);
220                root.pb_start();
221
222                self.root_span = Some(root);
223            }
224
225            BuildEvent::StepQueued {
226                step_id,
227                key,
228                parent_key,
229                display_name,
230                ..
231            } => {
232                self.step_keys.insert(*step_id, key.clone());
233                self.step_names.insert(*step_id, display_name.clone());
234                self.step_order.push(*step_id);
235
236                let parent_span = parent_key
237                    .as_ref()
238                    .and_then(|pk| {
239                        self.step_keys
240                            .iter()
241                            .find(|(_, k)| *k == pk)
242                            .and_then(|(id, _)| self.step_spans.get(id))
243                    })
244                    .or(self.root_span.as_ref());
245
246                let span = parent_span
247                    .map_or_else(|| info_span!("step"), |p| info_span!(parent: p, "step"));
248
249                span.pb_set_style(&active_style(self.color));
250                span.pb_set_message(display_name);
251                span.pb_start();
252
253                self.step_spans.insert(*step_id, span);
254            }
255
256            BuildEvent::StepStart { step_id, .. } => {
257                if let Some(span) = self.step_spans.get(step_id) {
258                    let name = self.step_names.get(step_id).map_or("?", String::as_str);
259                    span.pb_set_message(name);
260                }
261            }
262
263            BuildEvent::StepLog { step_id, line, .. } => {
264                self.log_buffer
265                    .entry(*step_id)
266                    .or_default()
267                    .push(line.clone());
268            }
269
270            BuildEvent::StepCacheHit { step_id, .. } => {
271                if let Some(span) = self.step_spans.get(step_id) {
272                    let name = self.step_names.get(step_id).map_or("?", String::as_str);
273                    span.pb_set_style(&completed_style(self.color));
274                    span.pb_set_message(&format!("{name}  (cached)"));
275                }
276                self.step_outcomes.insert(*step_id, StepOutcome::Cached);
277                if let Some(root) = &self.root_span {
278                    root.pb_inc(1);
279                }
280            }
281
282            BuildEvent::StepEnd {
283                step_id,
284                exit_code,
285                duration_ms,
286                ..
287            } => {
288                let cancelled = *exit_code == 130;
289                if *exit_code != 0 && !cancelled {
290                    self.failed_steps.push((*step_id, *exit_code));
291                    if let Some(span) = self.step_spans.get(step_id) {
292                        let name = self.step_names.get(step_id).map_or("?", String::as_str);
293                        span.pb_set_style(&failed_style(self.color));
294                        span.pb_set_message(&format!("{name}  FAILED (exit {exit_code})"));
295                    }
296                } else if cancelled {
297                    if let Some(span) = self.step_spans.get(step_id) {
298                        let name = self.step_names.get(step_id).map_or("?", String::as_str);
299                        span.pb_set_style(&completed_style(self.color));
300                        span.pb_set_message(&format!("{name}  (cancelled)"));
301                    }
302                } else if let Some(span) = self.step_spans.get(step_id) {
303                    let name = self.step_names.get(step_id).map_or("?", String::as_str);
304                    let dur = format_duration(*duration_ms);
305                    span.pb_set_style(&completed_style(self.color));
306                    span.pb_set_message(&format!("{name}  ({dur})"));
307                }
308
309                let outcome = if *exit_code == 0 {
310                    StepOutcome::Succeeded {
311                        duration_ms: *duration_ms,
312                    }
313                } else if cancelled {
314                    StepOutcome::Cancelled {
315                        duration_ms: *duration_ms,
316                    }
317                } else {
318                    StepOutcome::Failed {
319                        duration_ms: *duration_ms,
320                        exit_code: *exit_code,
321                    }
322                };
323                self.step_outcomes.insert(*step_id, outcome);
324
325                if let Some(root) = &self.root_span {
326                    root.pb_inc(1);
327                }
328            }
329
330            BuildEvent::ChainFailed { .. } => {}
331
332            BuildEvent::BuildEnd {
333                exit_code,
334                duration_ms,
335            } => {
336                self.step_spans.clear();
337                self.root_span.take();
338
339                self.print_step_summary();
340
341                if *exit_code != 0 {
342                    self.print_failure_report();
343                    let dur = format_duration(*duration_ms);
344                    let msg = format!("✗ Build failed in {dur}");
345                    let _ = writeln!(
346                        self.out,
347                        "\n{}",
348                        styled(&msg, Style::new().red().bold(), self.color)
349                    );
350                } else {
351                    let dur = format_duration(*duration_ms);
352                    let msg = format!("✓ Build succeeded in {dur}");
353                    let _ = writeln!(
354                        self.out,
355                        "\n{}",
356                        styled(&msg, Style::new().green().bold(), self.color)
357                    );
358                }
359            }
360        }
361    }
362}
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
366mod tests {
367    use super::*;
368    use hm_plugin_protocol::{PlanSummary, StdStream};
369
370    fn renderer() -> ProgressRenderer<Vec<u8>> {
371        ProgressRenderer::new(Vec::new(), false)
372    }
373
374    fn output(r: &ProgressRenderer<Vec<u8>>) -> String {
375        String::from_utf8(r.out.clone()).unwrap()
376    }
377
378    #[test]
379    fn buffers_logs_silently() {
380        let mut r = renderer();
381        let step_id = Uuid::new_v4();
382
383        r.on_event(&BuildEvent::StepQueued {
384            step_id,
385            key: "compile".into(),
386            chain_idx: 0,
387            parent_key: None,
388            display_name: "compile".into(),
389        });
390
391        r.on_event(&BuildEvent::StepLog {
392            step_id,
393            stream: StdStream::Stdout,
394            line: "compiling main.rs".into(),
395            ts: chrono::Utc::now(),
396        });
397
398        assert!(output(&r).is_empty(), "expected no text output");
399
400        let buf = r.log_buffer.get(&step_id).expect("log_buffer entry");
401        assert_eq!(buf.len(), 1);
402        assert_eq!(buf[0], "compiling main.rs");
403    }
404
405    #[test]
406    fn replays_logs_on_failure() {
407        let mut r = renderer();
408        let step_id = Uuid::new_v4();
409
410        r.on_event(&BuildEvent::BuildStart {
411            run_id: Uuid::nil(),
412            plan: PlanSummary {
413                step_count: 1,
414                chain_count: 1,
415                default_runner: "docker".into(),
416            },
417            started_at: chrono::Utc::now(),
418        });
419
420        r.on_event(&BuildEvent::StepQueued {
421            step_id,
422            key: "test".into(),
423            chain_idx: 0,
424            parent_key: None,
425            display_name: "test".into(),
426        });
427
428        r.on_event(&BuildEvent::StepLog {
429            step_id,
430            stream: StdStream::Stderr,
431            line: "assertion failed at line 42".into(),
432            ts: chrono::Utc::now(),
433        });
434
435        r.on_event(&BuildEvent::StepEnd {
436            step_id,
437            exit_code: 1,
438            duration_ms: 500,
439            snapshot: None,
440        });
441
442        r.on_event(&BuildEvent::BuildEnd {
443            exit_code: 1,
444            duration_ms: 600,
445        });
446
447        let s = output(&r);
448        assert!(s.contains("test"), "expected step key in output: {s}");
449        assert!(s.contains("exit 1"), "expected exit code in output: {s}");
450        assert!(
451            s.contains("assertion failed at line 42"),
452            "expected log line in output: {s}"
453        );
454    }
455
456    #[test]
457    fn no_output_on_success() {
458        let mut r = renderer();
459        let step_id = Uuid::new_v4();
460
461        r.on_event(&BuildEvent::BuildStart {
462            run_id: Uuid::nil(),
463            plan: PlanSummary {
464                step_count: 1,
465                chain_count: 1,
466                default_runner: "docker".into(),
467            },
468            started_at: chrono::Utc::now(),
469        });
470
471        r.on_event(&BuildEvent::StepQueued {
472            step_id,
473            key: "build".into(),
474            chain_idx: 0,
475            parent_key: None,
476            display_name: "build".into(),
477        });
478
479        r.on_event(&BuildEvent::StepLog {
480            step_id,
481            stream: StdStream::Stdout,
482            line: "all good".into(),
483            ts: chrono::Utc::now(),
484        });
485
486        r.on_event(&BuildEvent::StepEnd {
487            step_id,
488            exit_code: 0,
489            duration_ms: 200,
490            snapshot: None,
491        });
492
493        r.on_event(&BuildEvent::BuildEnd {
494            exit_code: 0,
495            duration_ms: 250,
496        });
497
498        assert!(
499            output(&r).contains("Build succeeded"),
500            "expected success message on success: {:?}",
501            output(&r)
502        );
503    }
504
505    #[test]
506    fn color_flag_stored() {
507        let r = ProgressRenderer::new(Vec::<u8>::new(), true);
508        assert!(r.color);
509        let r2 = ProgressRenderer::new(Vec::<u8>::new(), false);
510        assert!(!r2.color);
511    }
512
513    #[test]
514    fn cache_hit_increments_root() {
515        let mut r = renderer();
516        let step_id = Uuid::new_v4();
517
518        r.on_event(&BuildEvent::BuildStart {
519            run_id: Uuid::nil(),
520            plan: PlanSummary {
521                step_count: 2,
522                chain_count: 1,
523                default_runner: "docker".into(),
524            },
525            started_at: chrono::Utc::now(),
526        });
527
528        r.on_event(&BuildEvent::StepQueued {
529            step_id,
530            key: "cached-step".into(),
531            chain_idx: 0,
532            parent_key: None,
533            display_name: "cached-step".into(),
534        });
535
536        r.on_event(&BuildEvent::StepCacheHit {
537            step_id,
538            key: "cache-key".into(),
539            tag: "img:tag".into(),
540        });
541
542        assert!(
543            r.step_spans.contains_key(&step_id),
544            "cached step span should stay alive"
545        );
546    }
547
548    #[test]
549    fn step_outcome_tracks_failure() {
550        let mut r = renderer();
551        let step_id = Uuid::new_v4();
552
553        r.on_event(&BuildEvent::BuildStart {
554            run_id: Uuid::nil(),
555            plan: PlanSummary {
556                step_count: 1,
557                chain_count: 1,
558                default_runner: "docker".into(),
559            },
560            started_at: chrono::Utc::now(),
561        });
562        r.on_event(&BuildEvent::StepQueued {
563            step_id,
564            key: "test".into(),
565            chain_idx: 0,
566            parent_key: None,
567            display_name: "test".into(),
568        });
569        r.on_event(&BuildEvent::StepEnd {
570            step_id,
571            exit_code: 1,
572            duration_ms: 500,
573            snapshot: None,
574        });
575
576        assert!(
577            matches!(
578                r.step_outcomes.get(&step_id),
579                Some(StepOutcome::Failed { exit_code: 1, .. })
580            ),
581            "expected Failed outcome"
582        );
583    }
584
585    #[test]
586    fn colored_summary_has_indicators() {
587        let mut r = ProgressRenderer::new(Vec::new(), true);
588        let s1 = Uuid::new_v4();
589        let s2 = Uuid::new_v4();
590
591        r.on_event(&BuildEvent::BuildStart {
592            run_id: Uuid::nil(),
593            plan: PlanSummary {
594                step_count: 2,
595                chain_count: 1,
596                default_runner: "docker".into(),
597            },
598            started_at: chrono::Utc::now(),
599        });
600        r.on_event(&BuildEvent::StepQueued {
601            step_id: s1,
602            key: "build".into(),
603            chain_idx: 0,
604            parent_key: None,
605            display_name: "build".into(),
606        });
607        r.on_event(&BuildEvent::StepEnd {
608            step_id: s1,
609            exit_code: 0,
610            duration_ms: 200,
611            snapshot: None,
612        });
613        r.on_event(&BuildEvent::StepQueued {
614            step_id: s2,
615            key: "test".into(),
616            chain_idx: 0,
617            parent_key: None,
618            display_name: "test".into(),
619        });
620        r.on_event(&BuildEvent::StepEnd {
621            step_id: s2,
622            exit_code: 1,
623            duration_ms: 300,
624            snapshot: None,
625        });
626        r.on_event(&BuildEvent::BuildEnd {
627            exit_code: 1,
628            duration_ms: 600,
629        });
630
631        let s = output(&r);
632        assert!(
633            s.contains("\x1b[32m") && s.contains("✓"),
634            "expected green ✓: {s}"
635        );
636        assert!(
637            s.contains("\x1b[31m") && s.contains("✗"),
638            "expected red ✗: {s}"
639        );
640        assert!(s.contains("Build failed"), "expected failure banner: {s}");
641    }
642
643    #[test]
644    fn colored_success_banner() {
645        let mut r = ProgressRenderer::new(Vec::new(), true);
646        let s1 = Uuid::new_v4();
647
648        r.on_event(&BuildEvent::BuildStart {
649            run_id: Uuid::nil(),
650            plan: PlanSummary {
651                step_count: 1,
652                chain_count: 1,
653                default_runner: "docker".into(),
654            },
655            started_at: chrono::Utc::now(),
656        });
657        r.on_event(&BuildEvent::StepQueued {
658            step_id: s1,
659            key: "build".into(),
660            chain_idx: 0,
661            parent_key: None,
662            display_name: "build".into(),
663        });
664        r.on_event(&BuildEvent::StepEnd {
665            step_id: s1,
666            exit_code: 0,
667            duration_ms: 100,
668            snapshot: None,
669        });
670        r.on_event(&BuildEvent::BuildEnd {
671            exit_code: 0,
672            duration_ms: 150,
673        });
674
675        let s = output(&r);
676        assert!(
677            s.contains("\x1b[") && s.contains("Build succeeded"),
678            "expected green bold success: {s}"
679        );
680        assert!(s.contains("Build succeeded"), "expected success: {s}");
681    }
682}