haz-exec 0.1.0

Async task execution engine for haz.
Documentation
//! Presenter port for [`crate::output`] observers.
//!
//! The execution engine (`haz-exec`) emits a stream of lifecycle
//! events through [`crate::run_task::RunObserver`]. The bytes that
//! reach the user's terminal are produced by an observer
//! implementation in [`crate::output`]. Two of those bytes are
//! presentation-policy choices the engine has no opinion on:
//!
//! - the per-task *line prefix* that tags each captured line with
//!   the bearing task identity in `live` mode;
//! - the optional per-task *summary line* emitted on a task's
//!   terminal callback ("done", "cached", "failed", "skipped",
//!   "cancelled").
//!
//! The [`TaskPresenter`] trait is the seam: an observer holds an
//! `Arc<dyn TaskPresenter>` and asks it for prefix bytes and
//! summary-line bytes at the right moments in the lifecycle. The
//! presenter sees only [`TaskId`]s and the run records the
//! observer already has in hand; it has no view of the underlying
//! sink, the cancellation token, or the cache.
//!
//! The library ships [`PlainPresenter`] as the default: it emits
//! the historical `[project:task] ` prefix and returns [`None`]
//! for every summary method, so a `cargo test --all-features` run
//! sees identical bytes to the pre-trait behaviour. A
//! terminal-aware presenter (color, glyphs, durations) lives in
//! the consuming binary (`haz-cli`'s `output` module) and never
//! pulls a colour crate into `haz-exec`.

use std::time::Duration;

use haz_domain::task_id::TaskId;

use crate::run_task::{CancelledRecord, CompletedRecord, SkipRecord};

/// Strategy object the [`crate::output`] observers consult for
/// presentation-policy bytes.
///
/// Implementations decide how to render the bearing-task prefix
/// and the optional terminal summary line. The trait is
/// intentionally narrow: it does not see the observer's sinks,
/// any timing source other than the duration the observer hands
/// it, or any byte the task wrote. A presenter is a pure function
/// of the lifecycle event plus its own configuration (palette,
/// glyph set, etc.).
///
/// `Send + Sync` lets the observers store the presenter behind an
/// `Arc<dyn TaskPresenter>` and share it across concurrent
/// `on_*` calls without further coordination.
pub trait TaskPresenter: Send + Sync {
    /// Bytes that prefix each captured line of `task` in `live`
    /// mode. Buffered observers ignore this method; the prefix
    /// has no role when the task's bytes flush as a single block.
    ///
    /// The returned slice MUST NOT contain a trailing newline:
    /// the observer concatenates it with the captured line plus
    /// `\n`.
    fn prefix(&self, task: &TaskId) -> Vec<u8>;

    /// Bytes of the summary line for a task whose lookup-then-
    /// spawn pipeline completed (succeeded or failed per
    /// `EXEC-009`). Returning [`None`] suppresses the line; the
    /// observer emits nothing for the terminal callback.
    ///
    /// `duration` is the wall-clock elapsed between
    /// [`crate::run_task::RunObserver::on_task_started`] and the
    /// terminal callback the observer is currently handling.
    ///
    /// The returned bytes MUST be a complete line including the
    /// trailing newline; the observer writes them verbatim.
    fn summary_completed(
        &self,
        task: &TaskId,
        record: &CompletedRecord,
        duration: Duration,
    ) -> Option<Vec<u8>>;

    /// Bytes of the summary line for a task the scheduler
    /// cascade-skipped per `EXEC-010` / `EXEC-011`. No duration
    /// is associated with a skip: the task never entered the
    /// pipeline.
    ///
    /// Same `None` / newline contract as [`Self::summary_completed`].
    fn summary_skipped(&self, task: &TaskId, record: &SkipRecord) -> Option<Vec<u8>>;

    /// Bytes of the summary line for a task whose terminal state
    /// was reached through the cancellation flow per
    /// `EXEC-012`..`EXEC-015`.
    ///
    /// `duration` is [`Some`] for
    /// [`CancelledRecord::SignaledInFlight`] (the task ran for
    /// `duration` before the signal). For the other two variants
    /// (`UpstreamCancelled`, `RunCancelled`) the task never
    /// entered the spawn step, so `duration` is [`None`].
    ///
    /// Same `None` / newline contract as [`Self::summary_completed`].
    fn summary_cancelled(
        &self,
        task: &TaskId,
        record: &CancelledRecord,
        duration: Option<Duration>,
    ) -> Option<Vec<u8>>;
}

/// Default [`TaskPresenter`] used by the bare
/// [`crate::output::LiveOutputObserver::new`] and
/// [`crate::output::BufferedOutputObserver::new`] constructors.
///
/// Renders the per-line tag prefix as the historical
/// `[project:task] ` byte sequence and returns [`None`] for every
/// summary method, so the observers emit identical bytes to the
/// pre-trait behaviour. The unit-struct shape carries no state:
/// every instance is interchangeable.
#[derive(Debug, Default, Clone, Copy)]
pub struct PlainPresenter;

impl TaskPresenter for PlainPresenter {
    fn prefix(&self, task: &TaskId) -> Vec<u8> {
        format!("[{task}] ").into_bytes()
    }

    fn summary_completed(
        &self,
        _task: &TaskId,
        _record: &CompletedRecord,
        _duration: Duration,
    ) -> Option<Vec<u8>> {
        None
    }

    fn summary_skipped(&self, _task: &TaskId, _record: &SkipRecord) -> Option<Vec<u8>> {
        None
    }

    fn summary_cancelled(
        &self,
        _task: &TaskId,
        _record: &CancelledRecord,
        _duration: Option<Duration>,
    ) -> Option<Vec<u8>> {
        None
    }
}

#[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(),
        }
    }

    #[test]
    fn plain_presenter_prefix_matches_historical_format() {
        let presenter = PlainPresenter;
        let task = task_id_for("lib", "build");
        assert_eq!(presenter.prefix(&task), b"[lib:build] ");
    }

    #[test]
    fn plain_presenter_returns_none_for_every_summary_method() {
        let presenter = PlainPresenter;
        let task = task_id_for("lib", "build");
        let upstream = task_id_for("lib", "root");

        let completed = CompletedRecord {
            task: task.clone(),
            source: crate::run_task::RunSource::FreshRun,
            state: crate::run_task::RunState::Succeeded,
            exit_status: None,
            stdout_hash: [0u8; 32],
            stderr_hash: [0u8; 32],
            materialised_outputs: Vec::new(),
        };
        assert!(
            presenter
                .summary_completed(&task, &completed, Duration::from_secs(1))
                .is_none()
        );

        let skip = SkipRecord {
            task: task.clone(),
            cause: crate::run_task::SkipCause::UpstreamFailed {
                upstream: upstream.clone(),
            },
        };
        assert!(presenter.summary_skipped(&task, &skip).is_none());

        let cancelled = CancelledRecord::RunCancelled { task: task.clone() };
        assert!(
            presenter
                .summary_cancelled(&task, &cancelled, None)
                .is_none()
        );
    }
}