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};
18
19pub 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#[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 _ => {} }
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}