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::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::BuildAccepted {
130 build,
131 watch_url: Some(url),
132 } => {
133 let n = build.number.map(|n| format!("#{n} ")).unwrap_or_default();
134 format!("build {n}\u{2192} {url}\n").into_bytes()
135 }
136
137 BuildEvent::ChainFailed {
138 chain_idx,
139 failed_step_key,
140 exit_code,
141 message,
142 ..
143 } => {
144 let styled_key = if self.color {
145 format!("{}", failed_step_key.color(key_color(failed_step_key)))
146 } else {
147 failed_step_key.clone()
148 };
149 format!(
150 "chain {chain_idx}: FAILED at step '{styled_key}' (exit={exit_code}): {message}\n"
151 )
152 .into_bytes()
153 }
154
155 _ => return, };
157
158 let _ = self.out.write_all(&bytes);
159 }
160}
161
162#[cfg(test)]
163#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
164mod tests {
165 use super::*;
166 use hm_plugin_protocol::{PlanSummary, StdStream};
167
168 fn renderer() -> HumanRenderer<Vec<u8>> {
170 HumanRenderer::new(Vec::new(), false)
171 }
172
173 fn output(r: &HumanRenderer<Vec<u8>>) -> String {
175 String::from_utf8(r.out.clone()).unwrap()
176 }
177
178 #[test]
179 fn build_start_renders_counts() {
180 let mut r = renderer();
181 r.on_event(&BuildEvent::BuildStart {
182 run_id: Uuid::nil(),
183 plan: PlanSummary {
184 step_count: 5,
185 chain_count: 3,
186 default_runner: "docker".into(),
187 },
188 started_at: chrono::Utc::now(),
189 });
190
191 let s = output(&r);
192 assert!(s.contains("5 steps"), "expected step count: {s}");
193 assert!(s.contains("3 chain(s)"), "expected chain count: {s}");
194 }
195
196 #[test]
197 fn step_log_with_key() {
198 let mut r = renderer();
199 let step_id = Uuid::new_v4();
200
201 r.on_event(&BuildEvent::StepQueued {
203 step_id,
204 key: "build".into(),
205 chain_idx: 0,
206 parent_key: None,
207 display_name: "build".into(),
208 });
209
210 r.on_event(&BuildEvent::StepLog {
211 step_id,
212 stream: StdStream::Stdout,
213 line: "compiling...".into(),
214 ts: chrono::Utc::now(),
215 });
216
217 let s = output(&r);
218 assert_eq!(s, "[build] compiling...\n");
219 }
220
221 #[test]
222 fn step_log_unknown_key() {
223 let mut r = renderer();
224
225 r.on_event(&BuildEvent::StepLog {
227 step_id: Uuid::new_v4(),
228 stream: StdStream::Stdout,
229 line: "orphan line".into(),
230 ts: chrono::Utc::now(),
231 });
232
233 let s = output(&r);
234 assert!(s.starts_with("[?]"), "expected [?] prefix: {s}");
235 }
236
237 #[test]
238 fn colored_output_wraps_key_in_ansi() {
239 let mut r = HumanRenderer::new(Vec::new(), true);
240 let step_id = Uuid::new_v4();
241
242 r.on_event(&BuildEvent::StepQueued {
243 step_id,
244 key: "build".into(),
245 chain_idx: 0,
246 parent_key: None,
247 display_name: "build".into(),
248 });
249 r.on_event(&BuildEvent::StepLog {
250 step_id,
251 stream: StdStream::Stdout,
252 line: "hello".into(),
253 ts: chrono::Utc::now(),
254 });
255
256 let s = output(&r);
257 assert!(s.contains("\x1b["), "expected ANSI codes: {s}");
258 assert!(s.contains("hello"), "expected log line: {s}");
259 }
260}