Skip to main content

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