1use 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#[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}