Skip to main content

hm_render/
human.rs

1//! Human-readable [`OutputRenderer`] — replaces the former
2//! `hm-plugin-output-human` WASM plugin with a plain struct that
3//! writes formatted lines to any [`std::io::Write`] target.
4
5use 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/// Renders [`BuildEvent`]s as human-readable log lines.
16///
17/// Generic over the writer so tests can capture output into a
18/// `Vec<u8>` while production code writes to `stderr`.
19#[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    /// Create a new renderer writing to `out`.
28    #[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    /// Look up the human-readable key for a step, falling back to `"?"`.
67    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; // no visible output
87            }
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, // unknown future event: no visible output
156        };
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    /// Helper: create a renderer backed by an in-memory buffer (no color).
169    fn renderer() -> HumanRenderer<Vec<u8>> {
170        HumanRenderer::new(Vec::new(), false)
171    }
172
173    /// Helper: drain the buffer as a UTF-8 string.
174    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        // Queue the step so the key is recorded.
202        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        // Emit a log without a prior StepQueued.
226        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}