use std::time::Duration;
use haz_domain::task_id::TaskId;
use haz_exec::presenter::TaskPresenter;
use haz_exec::run_task::{
CancelledRecord, CompletedRecord, RunSource, RunState, SkipCause, SkipRecord,
};
use super::color_choice::ColorChoice;
use super::duration::format_duration;
use super::palette::{ansi, task_color};
const GLYPH_DONE: &str = "\u{2713}"; const GLYPH_CACHED: &str = "\u{25c9}"; const GLYPH_FAILED: &str = "\u{2717}"; const GLYPH_SKIPPED: &str = "-";
const GLYPH_CANCELLED: &str = "!";
pub struct CliPresenter {
color: ColorChoice,
}
impl CliPresenter {
#[must_use]
pub fn new(color: ColorChoice) -> Self {
Self { color }
}
fn coloured_prefix(&self, task: &TaskId) -> String {
if self.color.is_enabled() {
let rgb = task_color(task);
format!(
"{bold}{fg}[{task}]{reset} ",
bold = ansi::BOLD,
fg = ansi::fg(rgb),
task = task,
reset = ansi::RESET,
)
} else {
format!("[{task}] ")
}
}
fn status_chunk(&self, glyph: &str, verb: &str, status_color: &str) -> String {
if self.color.is_enabled() {
format!(
"{color}{glyph} {verb}{reset}",
color = status_color,
reset = ansi::RESET,
)
} else {
format!("{glyph} {verb}")
}
}
fn dim(&self, text: &str) -> String {
if self.color.is_enabled() {
format!("{}{}{}", ansi::DIM, text, ansi::RESET)
} else {
text.to_owned()
}
}
}
impl TaskPresenter for CliPresenter {
fn prefix(&self, task: &TaskId) -> Vec<u8> {
self.coloured_prefix(task).into_bytes()
}
fn summary_completed(
&self,
task: &TaskId,
record: &CompletedRecord,
duration: Duration,
) -> Option<Vec<u8>> {
let prefix = self.coloured_prefix(task);
let body = match (record.source, record.state) {
(RunSource::CacheHit, _) => format!(
"{} in {}",
self.status_chunk(GLYPH_CACHED, "cached", ansi::FG_GREEN),
self.dim(&format_duration(duration)),
),
(RunSource::FreshRun, RunState::Succeeded) => format!(
"{} in {}",
self.status_chunk(GLYPH_DONE, "done", ansi::FG_GREEN),
self.dim(&format_duration(duration)),
),
(RunSource::FreshRun, RunState::Failed) => {
let exit_code_text = exit_status_suffix(record.exit_status.as_ref());
format!(
"{} in {}{}",
self.status_chunk(GLYPH_FAILED, "failed", ansi::FG_RED),
self.dim(&format_duration(duration)),
exit_code_text,
)
}
(RunSource::FreshRun, RunState::Cancelled) => format!(
"{} in {}",
self.status_chunk(GLYPH_CANCELLED, "cancelled", ansi::FG_YELLOW),
self.dim(&format_duration(duration)),
),
};
Some(format!("{prefix}{body}\n").into_bytes())
}
fn summary_skipped(&self, task: &TaskId, record: &SkipRecord) -> Option<Vec<u8>> {
let prefix = self.coloured_prefix(task);
let cause_text = match &record.cause {
SkipCause::UpstreamFailed { upstream } => format!("upstream {upstream} failed"),
SkipCause::UpstreamErrored { upstream } => format!("upstream {upstream} errored"),
SkipCause::RuntimeCycle => "in runtime cycle".to_owned(),
};
Some(
format!(
"{prefix}{} ({})\n",
self.status_chunk(GLYPH_SKIPPED, "skipped", ansi::FG_YELLOW),
self.dim(&cause_text),
)
.into_bytes(),
)
}
fn summary_cancelled(
&self,
task: &TaskId,
record: &CancelledRecord,
duration: Option<Duration>,
) -> Option<Vec<u8>> {
let prefix = self.coloured_prefix(task);
let body = match record {
CancelledRecord::SignaledInFlight { .. } => {
let dur_text = duration.map_or_else(|| "unknown".to_owned(), format_duration);
format!(
"{} in {}",
self.status_chunk(GLYPH_CANCELLED, "cancelled", ansi::FG_YELLOW),
self.dim(&dur_text),
)
}
CancelledRecord::UpstreamCancelled { upstream, .. } => format!(
"{} ({})",
self.status_chunk(GLYPH_SKIPPED, "cancelled", ansi::FG_YELLOW),
self.dim(&format!("upstream {upstream} cancelled")),
),
CancelledRecord::RunCancelled { .. } => format!(
"{} ({})",
self.status_chunk(GLYPH_SKIPPED, "cancelled", ansi::FG_YELLOW),
self.dim("run cancelled"),
),
};
Some(format!("{prefix}{body}\n").into_bytes())
}
}
fn exit_status_suffix(status: Option<&std::process::ExitStatus>) -> String {
let Some(status) = status else {
return String::new();
};
if let Some(code) = status.code() {
format!(" (exit {code})")
} else {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signum) = status.signal() {
return format!(" (signal {signum})");
}
}
String::new()
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use haz_domain::name::{ProjectName, TaskName};
use super::*;
fn task_id_for(project: &str, task: &str) -> TaskId {
TaskId {
project: ProjectName::from_str(project).unwrap(),
task: TaskName::from_str(task).unwrap(),
}
}
fn completed(task: &TaskId, source: RunSource, state: RunState) -> CompletedRecord {
CompletedRecord {
task: task.clone(),
source,
state,
exit_status: None,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
materialised_outputs: Vec::new(),
}
}
#[test]
fn no_color_prefix_matches_plain_format() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "build");
assert_eq!(presenter.prefix(&task), b"[lib:build] ");
}
#[test]
fn color_prefix_wraps_in_truecolor_sgr() {
let presenter = CliPresenter::new(ColorChoice::Always);
let task = task_id_for("lib", "build");
let prefix = String::from_utf8(presenter.prefix(&task)).unwrap();
assert!(prefix.starts_with("\x1b[1m"), "bold first: {prefix:?}");
assert!(
prefix.contains("\x1b[38;2;"),
"truecolor SGR present: {prefix:?}",
);
assert!(
prefix.ends_with("\x1b[0m "),
"ends with reset + space: {prefix:?}"
);
assert!(prefix.contains("[lib:build]"));
}
#[test]
fn summary_completed_fresh_uses_done_glyph() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "build");
let record = completed(&task, RunSource::FreshRun, RunState::Succeeded);
let line = presenter
.summary_completed(&task, &record, Duration::from_millis(1_234))
.unwrap();
assert_eq!(line, "[lib:build] \u{2713} done in 1s 234ms\n".as_bytes());
}
#[test]
fn summary_completed_cache_hit_uses_cached_glyph() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "test");
let record = completed(&task, RunSource::CacheHit, RunState::Succeeded);
let line = presenter
.summary_completed(&task, &record, Duration::from_millis(12))
.unwrap();
assert_eq!(line, "[lib:test] \u{25c9} cached in 12ms\n".as_bytes());
}
#[test]
fn summary_completed_failed_includes_exit_code() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "build");
let mut record = completed(&task, RunSource::FreshRun, RunState::Failed);
record.exit_status = Some(exit_with_code(1));
let line = presenter
.summary_completed(&task, &record, Duration::from_millis(850))
.unwrap();
assert_eq!(
line,
"[lib:build] \u{2717} failed in 850ms (exit 1)\n".as_bytes(),
);
}
#[test]
fn summary_skipped_upstream_failed_names_upstream() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "doc");
let upstream = task_id_for("lib", "build");
let record = SkipRecord {
task: task.clone(),
cause: SkipCause::UpstreamFailed {
upstream: upstream.clone(),
},
};
let line = presenter.summary_skipped(&task, &record).unwrap();
assert_eq!(
line,
"[lib:doc] - skipped (upstream lib:build failed)\n".as_bytes(),
);
}
#[test]
fn summary_cancelled_signaled_in_flight_uses_duration() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "build");
let record = CancelledRecord::SignaledInFlight {
task: task.clone(),
exit_status: exit_with_code(143),
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
};
let line = presenter
.summary_cancelled(&task, &record, Some(Duration::from_millis(320)))
.unwrap();
assert_eq!(line, "[lib:build] ! cancelled in 320ms\n".as_bytes());
}
#[test]
fn summary_cancelled_upstream_names_upstream() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "down");
let upstream = task_id_for("lib", "root");
let record = CancelledRecord::UpstreamCancelled {
task: task.clone(),
upstream: upstream.clone(),
};
let line = presenter.summary_cancelled(&task, &record, None).unwrap();
assert_eq!(
line,
"[lib:down] - cancelled (upstream lib:root cancelled)\n".as_bytes(),
);
}
#[test]
fn summary_cancelled_run_cancelled_uses_run_label() {
let presenter = CliPresenter::new(ColorChoice::Never);
let task = task_id_for("lib", "drained");
let record = CancelledRecord::RunCancelled { task: task.clone() };
let line = presenter.summary_cancelled(&task, &record, None).unwrap();
assert_eq!(
line,
"[lib:drained] - cancelled (run cancelled)\n".as_bytes(),
);
}
fn exit_with_code(code: i32) -> std::process::ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(not(unix))]
{
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code as u32)
}
}
}