Skip to main content

haz_cli/output/
presenter.rs

1//! `haz run`'s [`TaskPresenter`] implementation: colour, glyphs,
2//! and per-task duration in summary lines.
3//!
4//! See [`crate::output`] for the design rationale. This file
5//! produces the terminal bytes; the colour palette lives in
6//! [`super::palette`], the duration grammar lives in
7//! [`super::duration`].
8
9use std::time::Duration;
10
11use haz_domain::task_id::TaskId;
12use haz_exec::presenter::TaskPresenter;
13use haz_exec::run_task::{
14    CancelledRecord, CompletedRecord, RunSource, RunState, SkipCause, SkipRecord,
15};
16
17use super::color_choice::ColorChoice;
18use super::duration::format_duration;
19use super::palette::{ansi, task_color};
20
21/// Status glyph used in front of the summary-line verb.
22///
23/// The glyphs are single-codepoint markers that pair with a
24/// verb so the line stays readable when the terminal font lacks
25/// the glyph (the verb carries the meaning, the glyph is the
26/// at-a-glance hint).
27const GLYPH_DONE: &str = "\u{2713}"; // ✓
28const GLYPH_CACHED: &str = "\u{25c9}"; // ◉
29const GLYPH_FAILED: &str = "\u{2717}"; // ✗
30const GLYPH_SKIPPED: &str = "-";
31const GLYPH_CANCELLED: &str = "!";
32
33/// `haz run`'s [`TaskPresenter`].
34///
35/// Carries a single configuration knob: the [`ColorChoice`]
36/// decided at construction time per
37/// [`super::color_choice::ColorChoice::detect`]. Every method
38/// builds its output bytes against that decision; a
39/// [`ColorChoice::Never`] presenter emits the same line shapes
40/// without ANSI escapes.
41pub struct CliPresenter {
42    color: ColorChoice,
43}
44
45impl CliPresenter {
46    /// Build a presenter that emits colour iff `color` is
47    /// [`ColorChoice::Always`].
48    #[must_use]
49    pub fn new(color: ColorChoice) -> Self {
50        Self { color }
51    }
52
53    fn coloured_prefix(&self, task: &TaskId) -> String {
54        if self.color.is_enabled() {
55            let rgb = task_color(task);
56            format!(
57                "{bold}{fg}[{task}]{reset} ",
58                bold = ansi::BOLD,
59                fg = ansi::fg(rgb),
60                task = task,
61                reset = ansi::RESET,
62            )
63        } else {
64            format!("[{task}] ")
65        }
66    }
67
68    fn status_chunk(&self, glyph: &str, verb: &str, status_color: &str) -> String {
69        if self.color.is_enabled() {
70            format!(
71                "{color}{glyph} {verb}{reset}",
72                color = status_color,
73                reset = ansi::RESET,
74            )
75        } else {
76            format!("{glyph} {verb}")
77        }
78    }
79
80    fn dim(&self, text: &str) -> String {
81        if self.color.is_enabled() {
82            format!("{}{}{}", ansi::DIM, text, ansi::RESET)
83        } else {
84            text.to_owned()
85        }
86    }
87}
88
89impl TaskPresenter for CliPresenter {
90    fn prefix(&self, task: &TaskId) -> Vec<u8> {
91        self.coloured_prefix(task).into_bytes()
92    }
93
94    fn summary_completed(
95        &self,
96        task: &TaskId,
97        record: &CompletedRecord,
98        duration: Duration,
99    ) -> Option<Vec<u8>> {
100        let prefix = self.coloured_prefix(task);
101        let body = match (record.source, record.state) {
102            (RunSource::CacheHit, _) => format!(
103                "{} in {}",
104                self.status_chunk(GLYPH_CACHED, "cached", ansi::FG_GREEN),
105                self.dim(&format_duration(duration)),
106            ),
107            (RunSource::FreshRun, RunState::Succeeded) => format!(
108                "{} in {}",
109                self.status_chunk(GLYPH_DONE, "done", ansi::FG_GREEN),
110                self.dim(&format_duration(duration)),
111            ),
112            (RunSource::FreshRun, RunState::Failed) => {
113                let exit_code_text = exit_status_suffix(record.exit_status.as_ref());
114                format!(
115                    "{} in {}{}",
116                    self.status_chunk(GLYPH_FAILED, "failed", ansi::FG_RED),
117                    self.dim(&format_duration(duration)),
118                    exit_code_text,
119                )
120            }
121            (RunSource::FreshRun, RunState::Cancelled) => format!(
122                "{} in {}",
123                self.status_chunk(GLYPH_CANCELLED, "cancelled", ansi::FG_YELLOW),
124                self.dim(&format_duration(duration)),
125            ),
126        };
127        Some(format!("{prefix}{body}\n").into_bytes())
128    }
129
130    fn summary_skipped(&self, task: &TaskId, record: &SkipRecord) -> Option<Vec<u8>> {
131        let prefix = self.coloured_prefix(task);
132        let cause_text = match &record.cause {
133            SkipCause::UpstreamFailed { upstream } => format!("upstream {upstream} failed"),
134            SkipCause::UpstreamErrored { upstream } => format!("upstream {upstream} errored"),
135            SkipCause::RuntimeCycle => "in runtime cycle".to_owned(),
136        };
137        Some(
138            format!(
139                "{prefix}{} ({})\n",
140                self.status_chunk(GLYPH_SKIPPED, "skipped", ansi::FG_YELLOW),
141                self.dim(&cause_text),
142            )
143            .into_bytes(),
144        )
145    }
146
147    fn summary_cancelled(
148        &self,
149        task: &TaskId,
150        record: &CancelledRecord,
151        duration: Option<Duration>,
152    ) -> Option<Vec<u8>> {
153        let prefix = self.coloured_prefix(task);
154        let body = match record {
155            CancelledRecord::SignaledInFlight { .. } => {
156                let dur_text = duration.map_or_else(|| "unknown".to_owned(), format_duration);
157                format!(
158                    "{} in {}",
159                    self.status_chunk(GLYPH_CANCELLED, "cancelled", ansi::FG_YELLOW),
160                    self.dim(&dur_text),
161                )
162            }
163            CancelledRecord::UpstreamCancelled { upstream, .. } => format!(
164                "{} ({})",
165                self.status_chunk(GLYPH_SKIPPED, "cancelled", ansi::FG_YELLOW),
166                self.dim(&format!("upstream {upstream} cancelled")),
167            ),
168            CancelledRecord::RunCancelled { .. } => format!(
169                "{} ({})",
170                self.status_chunk(GLYPH_SKIPPED, "cancelled", ansi::FG_YELLOW),
171                self.dim("run cancelled"),
172            ),
173        };
174        Some(format!("{prefix}{body}\n").into_bytes())
175    }
176}
177
178/// Append ` (exit N)` when the exit status carries a numeric
179/// code. POSIX hosts surface signal-terminated children with a
180/// signal number but no exit code; in that case the suffix is the
181/// signal-name marker `(signal)`. Failure modes the executor never
182/// produces (e.g. exit status without a `code()` AND without a
183/// signal) collapse to the empty string.
184fn exit_status_suffix(status: Option<&std::process::ExitStatus>) -> String {
185    let Some(status) = status else {
186        return String::new();
187    };
188    if let Some(code) = status.code() {
189        format!(" (exit {code})")
190    } else {
191        #[cfg(unix)]
192        {
193            use std::os::unix::process::ExitStatusExt;
194            if let Some(signum) = status.signal() {
195                return format!(" (signal {signum})");
196            }
197        }
198        String::new()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::str::FromStr;
205
206    use haz_domain::name::{ProjectName, TaskName};
207
208    use super::*;
209
210    fn task_id_for(project: &str, task: &str) -> TaskId {
211        TaskId {
212            project: ProjectName::from_str(project).unwrap(),
213            task: TaskName::from_str(task).unwrap(),
214        }
215    }
216
217    fn completed(task: &TaskId, source: RunSource, state: RunState) -> CompletedRecord {
218        CompletedRecord {
219            task: task.clone(),
220            source,
221            state,
222            exit_status: None,
223            stdout_hash: [0u8; 32],
224            stderr_hash: [0u8; 32],
225            materialised_outputs: Vec::new(),
226        }
227    }
228
229    #[test]
230    fn no_color_prefix_matches_plain_format() {
231        let presenter = CliPresenter::new(ColorChoice::Never);
232        let task = task_id_for("lib", "build");
233        assert_eq!(presenter.prefix(&task), b"[lib:build] ");
234    }
235
236    #[test]
237    fn color_prefix_wraps_in_truecolor_sgr() {
238        let presenter = CliPresenter::new(ColorChoice::Always);
239        let task = task_id_for("lib", "build");
240        let prefix = String::from_utf8(presenter.prefix(&task)).unwrap();
241        assert!(prefix.starts_with("\x1b[1m"), "bold first: {prefix:?}");
242        assert!(
243            prefix.contains("\x1b[38;2;"),
244            "truecolor SGR present: {prefix:?}",
245        );
246        assert!(
247            prefix.ends_with("\x1b[0m "),
248            "ends with reset + space: {prefix:?}"
249        );
250        assert!(prefix.contains("[lib:build]"));
251    }
252
253    #[test]
254    fn summary_completed_fresh_uses_done_glyph() {
255        let presenter = CliPresenter::new(ColorChoice::Never);
256        let task = task_id_for("lib", "build");
257        let record = completed(&task, RunSource::FreshRun, RunState::Succeeded);
258        let line = presenter
259            .summary_completed(&task, &record, Duration::from_millis(1_234))
260            .unwrap();
261        assert_eq!(line, "[lib:build] \u{2713} done in 1s 234ms\n".as_bytes());
262    }
263
264    #[test]
265    fn summary_completed_cache_hit_uses_cached_glyph() {
266        let presenter = CliPresenter::new(ColorChoice::Never);
267        let task = task_id_for("lib", "test");
268        let record = completed(&task, RunSource::CacheHit, RunState::Succeeded);
269        let line = presenter
270            .summary_completed(&task, &record, Duration::from_millis(12))
271            .unwrap();
272        assert_eq!(line, "[lib:test] \u{25c9} cached in 12ms\n".as_bytes());
273    }
274
275    #[test]
276    fn summary_completed_failed_includes_exit_code() {
277        let presenter = CliPresenter::new(ColorChoice::Never);
278        let task = task_id_for("lib", "build");
279        let mut record = completed(&task, RunSource::FreshRun, RunState::Failed);
280        record.exit_status = Some(exit_with_code(1));
281        let line = presenter
282            .summary_completed(&task, &record, Duration::from_millis(850))
283            .unwrap();
284        assert_eq!(
285            line,
286            "[lib:build] \u{2717} failed in 850ms (exit 1)\n".as_bytes(),
287        );
288    }
289
290    #[test]
291    fn summary_skipped_upstream_failed_names_upstream() {
292        let presenter = CliPresenter::new(ColorChoice::Never);
293        let task = task_id_for("lib", "doc");
294        let upstream = task_id_for("lib", "build");
295        let record = SkipRecord {
296            task: task.clone(),
297            cause: SkipCause::UpstreamFailed {
298                upstream: upstream.clone(),
299            },
300        };
301        let line = presenter.summary_skipped(&task, &record).unwrap();
302        assert_eq!(
303            line,
304            "[lib:doc] - skipped (upstream lib:build failed)\n".as_bytes(),
305        );
306    }
307
308    #[test]
309    fn summary_cancelled_signaled_in_flight_uses_duration() {
310        let presenter = CliPresenter::new(ColorChoice::Never);
311        let task = task_id_for("lib", "build");
312        let record = CancelledRecord::SignaledInFlight {
313            task: task.clone(),
314            exit_status: exit_with_code(143),
315            stdout_hash: [0u8; 32],
316            stderr_hash: [0u8; 32],
317        };
318        let line = presenter
319            .summary_cancelled(&task, &record, Some(Duration::from_millis(320)))
320            .unwrap();
321        assert_eq!(line, "[lib:build] ! cancelled in 320ms\n".as_bytes());
322    }
323
324    #[test]
325    fn summary_cancelled_upstream_names_upstream() {
326        let presenter = CliPresenter::new(ColorChoice::Never);
327        let task = task_id_for("lib", "down");
328        let upstream = task_id_for("lib", "root");
329        let record = CancelledRecord::UpstreamCancelled {
330            task: task.clone(),
331            upstream: upstream.clone(),
332        };
333        let line = presenter.summary_cancelled(&task, &record, None).unwrap();
334        assert_eq!(
335            line,
336            "[lib:down] - cancelled (upstream lib:root cancelled)\n".as_bytes(),
337        );
338    }
339
340    #[test]
341    fn summary_cancelled_run_cancelled_uses_run_label() {
342        let presenter = CliPresenter::new(ColorChoice::Never);
343        let task = task_id_for("lib", "drained");
344        let record = CancelledRecord::RunCancelled { task: task.clone() };
345        let line = presenter.summary_cancelled(&task, &record, None).unwrap();
346        assert_eq!(
347            line,
348            "[lib:drained] - cancelled (run cancelled)\n".as_bytes(),
349        );
350    }
351
352    fn exit_with_code(code: i32) -> std::process::ExitStatus {
353        #[cfg(unix)]
354        {
355            use std::os::unix::process::ExitStatusExt;
356            std::process::ExitStatus::from_raw(code << 8)
357        }
358        #[cfg(not(unix))]
359        {
360            use std::os::windows::process::ExitStatusExt;
361            std::process::ExitStatus::from_raw(code as u32)
362        }
363    }
364}