1use std::collections::HashMap;
6use std::fmt;
7use std::io::Write;
8
9use hm_plugin_protocol::BuildEvent;
10use owo_colors::{AnsiColors, OwoColorize};
11use uuid::Uuid;
12
13use crate::runner::OutputRenderer;
14
15#[derive(Debug)]
20pub struct HumanRenderer<W> {
21 out: W,
22 step_keys: HashMap<Uuid, String>,
23 color: bool,
24}
25
26impl<W> HumanRenderer<W> {
27 #[must_use]
29 pub fn new(out: W, color: bool) -> Self {
30 Self {
31 out,
32 step_keys: HashMap::new(),
33 color,
34 }
35 }
36}
37
38fn key_color(key: &str) -> AnsiColors {
39 const PALETTE: [AnsiColors; 6] = [
40 AnsiColors::Cyan,
41 AnsiColors::Magenta,
42 AnsiColors::Yellow,
43 AnsiColors::Green,
44 AnsiColors::Blue,
45 AnsiColors::BrightRed,
46 ];
47 let mut h: u32 = 0;
48 for b in key.bytes() {
49 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
50 }
51 PALETTE[(h as usize) % PALETTE.len()]
52}
53
54fn fmt_key(key: &str, color: bool) -> String {
55 if color {
56 format!("[{}]", key.color(key_color(key)))
57 } else {
58 format!("[{key}]")
59 }
60}
61
62impl<W> HumanRenderer<W>
63where
64 W: Write,
65{
66 fn step_key(&self, id: &Uuid) -> &str {
68 self.step_keys.get(id).map_or("?", String::as_str)
69 }
70}
71
72impl<W> OutputRenderer for HumanRenderer<W>
73where
74 W: Write + Send + fmt::Debug,
75{
76 fn on_event(&mut self, event: &BuildEvent) {
77 let bytes: Vec<u8> = match event {
78 BuildEvent::BuildStart { plan, .. } => format!(
79 "build: {} steps in {} chain(s)\n",
80 plan.step_count, plan.chain_count,
81 )
82 .into_bytes(),
83
84 BuildEvent::StepQueued { step_id, key, .. } => {
85 self.step_keys.insert(*step_id, key.clone());
86 return; }
88
89 BuildEvent::StepStart {
90 step_id,
91 runner,
92 image,
93 } => {
94 let prefix = fmt_key(self.step_key(step_id), self.color);
95 image
96 .as_ref()
97 .map_or_else(
98 || format!("{prefix} start (runner={runner})\n"),
99 |img| format!("{prefix} start (runner={runner} image={img})\n"),
100 )
101 .into_bytes()
102 }
103
104 BuildEvent::StepLog { step_id, line, .. } => {
105 let prefix = fmt_key(self.step_key(step_id), self.color);
106 format!("{prefix} {line}\n").into_bytes()
107 }
108
109 BuildEvent::StepCacheHit { step_id, tag, .. } => {
110 let prefix = fmt_key(self.step_key(step_id), self.color);
111 format!("{prefix} cache hit ({tag})\n").into_bytes()
112 }
113
114 BuildEvent::StepEnd {
115 step_id,
116 exit_code,
117 duration_ms,
118 ..
119 } => {
120 let prefix = fmt_key(self.step_key(step_id), self.color);
121 format!("{prefix} end exit={exit_code} duration={duration_ms}ms\n").into_bytes()
122 }
123
124 BuildEvent::BuildEnd {
125 exit_code,
126 duration_ms,
127 } => format!("build: end exit={exit_code} duration={duration_ms}ms\n").into_bytes(),
128
129 BuildEvent::ChainFailed {
130 chain_idx,
131 failed_step_key,
132 exit_code,
133 message,
134 ..
135 } => {
136 let styled_key = if self.color {
137 format!("{}", failed_step_key.color(key_color(failed_step_key)))
138 } else {
139 failed_step_key.clone()
140 };
141 format!(
142 "chain {chain_idx}: FAILED at step '{styled_key}' (exit={exit_code}): {message}\n"
143 )
144 .into_bytes()
145 }
146 };
147
148 let _ = self.out.write_all(&bytes);
149 }
150}
151
152#[cfg(test)]
153#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
154mod tests {
155 use super::*;
156 use hm_plugin_protocol::{PlanSummary, StdStream};
157
158 fn renderer() -> HumanRenderer<Vec<u8>> {
160 HumanRenderer::new(Vec::new(), false)
161 }
162
163 fn output(r: &HumanRenderer<Vec<u8>>) -> String {
165 String::from_utf8(r.out.clone()).unwrap()
166 }
167
168 #[test]
169 fn build_start_renders_counts() {
170 let mut r = renderer();
171 r.on_event(&BuildEvent::BuildStart {
172 run_id: Uuid::nil(),
173 plan: PlanSummary {
174 step_count: 5,
175 chain_count: 3,
176 default_runner: "docker".into(),
177 },
178 started_at: chrono::Utc::now(),
179 });
180
181 let s = output(&r);
182 assert!(s.contains("5 steps"), "expected step count: {s}");
183 assert!(s.contains("3 chain(s)"), "expected chain count: {s}");
184 }
185
186 #[test]
187 fn step_log_with_key() {
188 let mut r = renderer();
189 let step_id = Uuid::new_v4();
190
191 r.on_event(&BuildEvent::StepQueued {
193 step_id,
194 key: "build".into(),
195 chain_idx: 0,
196 parent_key: None,
197 display_name: "build".into(),
198 });
199
200 r.on_event(&BuildEvent::StepLog {
201 step_id,
202 stream: StdStream::Stdout,
203 line: "compiling...".into(),
204 ts: chrono::Utc::now(),
205 });
206
207 let s = output(&r);
208 assert_eq!(s, "[build] compiling...\n");
209 }
210
211 #[test]
212 fn step_log_unknown_key() {
213 let mut r = renderer();
214
215 r.on_event(&BuildEvent::StepLog {
217 step_id: Uuid::new_v4(),
218 stream: StdStream::Stdout,
219 line: "orphan line".into(),
220 ts: chrono::Utc::now(),
221 });
222
223 let s = output(&r);
224 assert!(s.starts_with("[?]"), "expected [?] prefix: {s}");
225 }
226
227 #[test]
228 fn colored_output_wraps_key_in_ansi() {
229 let mut r = HumanRenderer::new(Vec::new(), true);
230 let step_id = Uuid::new_v4();
231
232 r.on_event(&BuildEvent::StepQueued {
233 step_id,
234 key: "build".into(),
235 chain_idx: 0,
236 parent_key: None,
237 display_name: "build".into(),
238 });
239 r.on_event(&BuildEvent::StepLog {
240 step_id,
241 stream: StdStream::Stdout,
242 line: "hello".into(),
243 ts: chrono::Utc::now(),
244 });
245
246 let s = output(&r);
247 assert!(s.contains("\x1b["), "expected ANSI codes: {s}");
248 assert!(s.contains("hello"), "expected log line: {s}");
249 }
250}