Skip to main content

harmont_cli/output/
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::runner::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::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    /// Helper: create a renderer backed by an in-memory buffer (no color).
159    fn renderer() -> HumanRenderer<Vec<u8>> {
160        HumanRenderer::new(Vec::new(), false)
161    }
162
163    /// Helper: drain the buffer as a UTF-8 string.
164    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        // Queue the step so the key is recorded.
192        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        // Emit a log without a prior StepQueued.
216        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}