1use 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
21const GLYPH_DONE: &str = "\u{2713}"; const GLYPH_CACHED: &str = "\u{25c9}"; const GLYPH_FAILED: &str = "\u{2717}"; const GLYPH_SKIPPED: &str = "-";
31const GLYPH_CANCELLED: &str = "!";
32
33pub struct CliPresenter {
42 color: ColorChoice,
43}
44
45impl CliPresenter {
46 #[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
178fn 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}