haz-cli 0.1.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Run-level summary emitted after the scheduler returns.
//!
//! A blank separator line followed by two summary lines:
//!
//! ```text
//!
//! Tasks: 7 completed (2 cached), 1 failed
//!  Time: 1m 23s
//! ```
//!
//! The leading blank line visually separates the epilogue from the
//! preceding stream of per-task summary lines. The leading space on
//! `" Time:"` aligns the colon with `"Tasks:"` (six characters vs
//! five) when rendered in a monospaced font. Sub-counts appear only
//! when non-zero; a run with zero failures / skips / cancellations
//! renders the simplest possible line.
//!
//! Counts come from the [`RunGraphOutcome`] handed back by
//! [`haz_exec::run_graph::run_graph`]. The function in this module
//! is a pure rendering helper: it does not consult the cache, the
//! filesystem, or the workspace.

use std::io::{self, Write};
use std::time::Duration;

use haz_exec::run_graph::RunGraphOutcome;
use haz_exec::run_task::{RunOutcome, RunSource, RunState};

use super::color_choice::ColorChoice;
use super::duration::format_duration;
use super::palette::ansi;

/// Per-outcome-class counts derived from a [`RunGraphOutcome`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct OutcomeCounts {
    /// Tasks that reached [`RunState::Succeeded`], whether through
    /// a fresh run or a cache hit.
    completed: usize,
    /// Subset of `completed` whose [`RunSource`] is
    /// [`RunSource::CacheHit`].
    cached: usize,
    /// Tasks that reached [`RunState::Failed`], plus tasks the
    /// pipeline could not execute at all (entries in
    /// `RunGraphOutcome::task_errors`).
    failed: usize,
    /// Tasks the cascade marked do-not-schedule per `EXEC-010` /
    /// `EXEC-011`.
    skipped: usize,
    /// Tasks the cancellation flow caught, any variant.
    cancelled: usize,
}

impl OutcomeCounts {
    fn from_outcome(outcome: &RunGraphOutcome) -> Self {
        let mut counts = Self::default();
        for run_outcome in outcome.outcomes.values() {
            match run_outcome {
                RunOutcome::Completed(record) => match record.state {
                    RunState::Succeeded => {
                        counts.completed += 1;
                        if matches!(record.source, RunSource::CacheHit) {
                            counts.cached += 1;
                        }
                    }
                    RunState::Failed => counts.failed += 1,
                    RunState::Cancelled => counts.cancelled += 1,
                },
                RunOutcome::Skipped(_) => counts.skipped += 1,
                RunOutcome::Cancelled(_) => counts.cancelled += 1,
            }
        }
        counts.failed += outcome.task_errors.len();
        counts
    }
}

/// Render the epilogue bytes to `sink`.
///
/// `color` controls whether ANSI status colours wrap the counts
/// ([`ColorChoice::Always`]) or every span is plain text
/// ([`ColorChoice::Never`]). `elapsed` is the wall-clock duration
/// between the moment the binary handed control to the scheduler
/// and the moment the scheduler returned.
///
/// # Errors
///
/// Forwards every [`io::Write::write_all`] failure from `sink`.
/// Production callers wire `sink` to `stderr`, where write failure
/// is a terminal-condition concern out of scope here.
pub fn write_epilogue<W: Write>(
    sink: &mut W,
    outcome: &RunGraphOutcome,
    elapsed: Duration,
    color: ColorChoice,
) -> io::Result<()> {
    let line = format_epilogue(outcome, elapsed, color);
    sink.write_all(line.as_bytes())
}

fn format_epilogue(outcome: &RunGraphOutcome, elapsed: Duration, color: ColorChoice) -> String {
    let counts = OutcomeCounts::from_outcome(outcome);
    // Lead with a blank line so the epilogue visually separates
    // from the preceding stream of per-task lines.
    let mut tasks_line = String::from("\nTasks: ");
    push_completed_chunk(&mut tasks_line, counts, color);
    push_optional_count(
        &mut tasks_line,
        counts.failed,
        "failed",
        ansi::FG_RED,
        color,
    );
    push_optional_count(
        &mut tasks_line,
        counts.skipped,
        "skipped",
        ansi::FG_YELLOW,
        color,
    );
    push_optional_count(
        &mut tasks_line,
        counts.cancelled,
        "cancelled",
        ansi::FG_YELLOW,
        color,
    );
    tasks_line.push('\n');

    let time_line = format!(" Time: {}\n", dim(&format_duration(elapsed), color));

    format!("{tasks_line}{time_line}")
}

/// Push the "<N> completed[ (<M> cached)]" chunk into `dest`. When
/// `counts.completed` is zero the chunk degrades to `0 completed`
/// (without the cache parenthetical); the optional secondary
/// counts appended by the caller carry the rest of the story.
fn push_completed_chunk(dest: &mut String, counts: OutcomeCounts, color: ColorChoice) {
    let completed_chunk = colorise(
        &format!("{} completed", counts.completed),
        ansi::FG_GREEN,
        color,
    );
    dest.push_str(&completed_chunk);
    if counts.cached > 0 {
        dest.push(' ');
        dest.push_str(&colorise(
            &format!("({} cached)", counts.cached),
            ansi::FG_GREEN,
            color,
        ));
    }
}

fn push_optional_count(
    dest: &mut String,
    count: usize,
    label: &str,
    status_color: &str,
    color: ColorChoice,
) {
    if count == 0 {
        return;
    }
    dest.push_str(", ");
    dest.push_str(&colorise(&format!("{count} {label}"), status_color, color));
}

fn colorise(text: &str, status_color: &str, color: ColorChoice) -> String {
    if color.is_enabled() {
        format!("{status_color}{text}{}", ansi::RESET)
    } else {
        text.to_owned()
    }
}

fn dim(text: &str, color: ColorChoice) -> String {
    if color.is_enabled() {
        format!("{}{}{}", ansi::DIM, text, ansi::RESET)
    } else {
        text.to_owned()
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::str::FromStr;

    use haz_domain::name::{ProjectName, TaskName};
    use haz_domain::task_id::TaskId;
    use haz_exec::run_task::{
        CancelledRecord, CompletedRecord, RunOutcome, RunSource, RunState, SkipCause, SkipRecord,
    };

    use super::*;

    fn task(project: &str, name: &str) -> TaskId {
        TaskId {
            project: ProjectName::from_str(project).unwrap(),
            task: TaskName::from_str(name).unwrap(),
        }
    }

    fn empty_outcome() -> RunGraphOutcome {
        RunGraphOutcome {
            outcomes: BTreeMap::new(),
            task_errors: BTreeMap::new(),
            invariant_violations: Vec::new(),
        }
    }

    fn completed_outcome(task: &TaskId, source: RunSource, state: RunState) -> RunOutcome {
        RunOutcome::Completed(CompletedRecord {
            task: task.clone(),
            source,
            state,
            exit_status: None,
            stdout_hash: [0u8; 32],
            stderr_hash: [0u8; 32],
            materialised_outputs: Vec::new(),
        })
    }

    fn skipped_outcome(task: &TaskId, upstream: &TaskId) -> RunOutcome {
        RunOutcome::Skipped(SkipRecord {
            task: task.clone(),
            cause: SkipCause::UpstreamFailed {
                upstream: upstream.clone(),
            },
        })
    }

    fn cancelled_outcome(task: &TaskId) -> RunOutcome {
        RunOutcome::Cancelled(CancelledRecord::RunCancelled { task: task.clone() })
    }

    #[test]
    fn epilogue_all_completed_no_cache() {
        let t1 = task("lib", "build");
        let t2 = task("lib", "test");
        let mut outcome = empty_outcome();
        outcome.outcomes.insert(
            t1.clone(),
            completed_outcome(&t1, RunSource::FreshRun, RunState::Succeeded),
        );
        outcome.outcomes.insert(
            t2.clone(),
            completed_outcome(&t2, RunSource::FreshRun, RunState::Succeeded),
        );
        let rendered = format_epilogue(&outcome, Duration::from_millis(7_856), ColorChoice::Never);
        assert_eq!(rendered, "\nTasks: 2 completed\n Time: 7s 856ms\n");
    }

    #[test]
    fn epilogue_all_cached_carries_cached_subcount() {
        let t1 = task("lib", "build");
        let mut outcome = empty_outcome();
        outcome.outcomes.insert(
            t1.clone(),
            completed_outcome(&t1, RunSource::CacheHit, RunState::Succeeded),
        );
        let rendered = format_epilogue(&outcome, Duration::from_millis(176), ColorChoice::Never);
        assert_eq!(rendered, "\nTasks: 1 completed (1 cached)\n Time: 176ms\n");
    }

    #[test]
    fn epilogue_mixed_results() {
        let t1 = task("lib", "build");
        let t2 = task("lib", "test");
        let t3 = task("lib", "doc");
        let t4 = task("lib", "drained");
        let mut outcome = empty_outcome();
        outcome.outcomes.insert(
            t1.clone(),
            completed_outcome(&t1, RunSource::CacheHit, RunState::Succeeded),
        );
        outcome.outcomes.insert(
            t2.clone(),
            completed_outcome(&t2, RunSource::FreshRun, RunState::Failed),
        );
        outcome
            .outcomes
            .insert(t3.clone(), skipped_outcome(&t3, &t2));
        outcome.outcomes.insert(t4.clone(), cancelled_outcome(&t4));
        let rendered = format_epilogue(&outcome, Duration::from_secs(1), ColorChoice::Never);
        assert_eq!(
            rendered,
            "\nTasks: 1 completed (1 cached), 1 failed, 1 skipped, 1 cancelled\n Time: 1s 0ms\n",
        );
    }

    #[test]
    fn epilogue_color_enabled_wraps_status_chunks() {
        let t1 = task("lib", "build");
        let mut outcome = empty_outcome();
        outcome.outcomes.insert(
            t1.clone(),
            completed_outcome(&t1, RunSource::FreshRun, RunState::Succeeded),
        );
        let rendered = format_epilogue(&outcome, Duration::from_millis(50), ColorChoice::Always);
        // The completed count wraps in green; the duration wraps
        // in dim. Both end with the reset escape.
        assert!(rendered.contains("\x1b[32m1 completed\x1b[0m"));
        assert!(rendered.contains("\x1b[2m50ms\x1b[0m"));
    }

    #[test]
    fn epilogue_task_errors_fold_into_failed_count() {
        // A RunGraphOutcome with no `outcomes` entry but a
        // non-empty `task_errors` map (e.g. SpawnFailed). The
        // epilogue should report the count of executor-level
        // failures alongside any task-level failures.
        let mut outcome = empty_outcome();
        outcome.task_errors.insert(
            task("lib", "broken"),
            haz_exec::run_task::RunTaskError::BuildKeyFailed {
                source: haz_exec::cache_key::BuildKeyError::TaskNotInWorkspace {
                    task: task("lib", "broken"),
                },
            },
        );
        let rendered = format_epilogue(&outcome, Duration::ZERO, ColorChoice::Never);
        assert_eq!(rendered, "\nTasks: 0 completed, 1 failed\n Time: 0ms\n");
    }
}