haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! `haz run`'s [`TaskPresenter`] implementation: colour, glyphs,
//! and per-task duration in summary lines.
//!
//! See [`crate::output`] for the design rationale. This file
//! produces the terminal bytes; the colour palette lives in
//! [`super::palette`], the duration grammar lives in
//! [`super::duration`].

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};

/// Status glyph used in front of the summary-line verb.
///
/// The glyphs are single-codepoint markers that pair with a
/// verb so the line stays readable when the terminal font lacks
/// the glyph (the verb carries the meaning, the glyph is the
/// at-a-glance hint).
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 = "!";

/// `haz run`'s [`TaskPresenter`].
///
/// Carries a single configuration knob: the [`ColorChoice`]
/// decided at construction time per
/// [`super::color_choice::ColorChoice::detect`]. Every method
/// builds its output bytes against that decision; a
/// [`ColorChoice::Never`] presenter emits the same line shapes
/// without ANSI escapes.
pub struct CliPresenter {
    color: ColorChoice,
}

impl CliPresenter {
    /// Build a presenter that emits colour iff `color` is
    /// [`ColorChoice::Always`].
    #[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())
    }
}

/// Append ` (exit N)` when the exit status carries a numeric
/// code. POSIX hosts surface signal-terminated children with a
/// signal number but no exit code; in that case the suffix is the
/// signal-name marker `(signal)`. Failure modes the executor never
/// produces (e.g. exit status without a `code()` AND without a
/// signal) collapse to the empty string.
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)
        }
    }
}