Skip to main content

haz_exec/
presenter.rs

1//! Presenter port for [`crate::output`] observers.
2//!
3//! The execution engine (`haz-exec`) emits a stream of lifecycle
4//! events through [`crate::run_task::RunObserver`]. The bytes that
5//! reach the user's terminal are produced by an observer
6//! implementation in [`crate::output`]. Two of those bytes are
7//! presentation-policy choices the engine has no opinion on:
8//!
9//! - the per-task *line prefix* that tags each captured line with
10//!   the bearing task identity in `live` mode;
11//! - the optional per-task *summary line* emitted on a task's
12//!   terminal callback ("done", "cached", "failed", "skipped",
13//!   "cancelled").
14//!
15//! The [`TaskPresenter`] trait is the seam: an observer holds an
16//! `Arc<dyn TaskPresenter>` and asks it for prefix bytes and
17//! summary-line bytes at the right moments in the lifecycle. The
18//! presenter sees only [`TaskId`]s and the run records the
19//! observer already has in hand; it has no view of the underlying
20//! sink, the cancellation token, or the cache.
21//!
22//! The library ships [`PlainPresenter`] as the default: it emits
23//! the historical `[project:task] ` prefix and returns [`None`]
24//! for every summary method, so a `cargo test --all-features` run
25//! sees identical bytes to the pre-trait behaviour. A
26//! terminal-aware presenter (color, glyphs, durations) lives in
27//! the consuming binary (`haz-cli`'s `output` module) and never
28//! pulls a colour crate into `haz-exec`.
29
30use std::time::Duration;
31
32use haz_domain::task_id::TaskId;
33
34use crate::run_task::{CancelledRecord, CompletedRecord, SkipRecord};
35
36/// Strategy object the [`crate::output`] observers consult for
37/// presentation-policy bytes.
38///
39/// Implementations decide how to render the bearing-task prefix
40/// and the optional terminal summary line. The trait is
41/// intentionally narrow: it does not see the observer's sinks,
42/// any timing source other than the duration the observer hands
43/// it, or any byte the task wrote. A presenter is a pure function
44/// of the lifecycle event plus its own configuration (palette,
45/// glyph set, etc.).
46///
47/// `Send + Sync` lets the observers store the presenter behind an
48/// `Arc<dyn TaskPresenter>` and share it across concurrent
49/// `on_*` calls without further coordination.
50pub trait TaskPresenter: Send + Sync {
51    /// Bytes that prefix each captured line of `task` in `live`
52    /// mode. Buffered observers ignore this method; the prefix
53    /// has no role when the task's bytes flush as a single block.
54    ///
55    /// The returned slice MUST NOT contain a trailing newline:
56    /// the observer concatenates it with the captured line plus
57    /// `\n`.
58    fn prefix(&self, task: &TaskId) -> Vec<u8>;
59
60    /// Bytes of the summary line for a task whose lookup-then-
61    /// spawn pipeline completed (succeeded or failed per
62    /// `EXEC-009`). Returning [`None`] suppresses the line; the
63    /// observer emits nothing for the terminal callback.
64    ///
65    /// `duration` is the wall-clock elapsed between
66    /// [`crate::run_task::RunObserver::on_task_started`] and the
67    /// terminal callback the observer is currently handling.
68    ///
69    /// The returned bytes MUST be a complete line including the
70    /// trailing newline; the observer writes them verbatim.
71    fn summary_completed(
72        &self,
73        task: &TaskId,
74        record: &CompletedRecord,
75        duration: Duration,
76    ) -> Option<Vec<u8>>;
77
78    /// Bytes of the summary line for a task the scheduler
79    /// cascade-skipped per `EXEC-010` / `EXEC-011`. No duration
80    /// is associated with a skip: the task never entered the
81    /// pipeline.
82    ///
83    /// Same `None` / newline contract as [`Self::summary_completed`].
84    fn summary_skipped(&self, task: &TaskId, record: &SkipRecord) -> Option<Vec<u8>>;
85
86    /// Bytes of the summary line for a task whose terminal state
87    /// was reached through the cancellation flow per
88    /// `EXEC-012`..`EXEC-015`.
89    ///
90    /// `duration` is [`Some`] for
91    /// [`CancelledRecord::SignaledInFlight`] (the task ran for
92    /// `duration` before the signal). For the other two variants
93    /// (`UpstreamCancelled`, `RunCancelled`) the task never
94    /// entered the spawn step, so `duration` is [`None`].
95    ///
96    /// Same `None` / newline contract as [`Self::summary_completed`].
97    fn summary_cancelled(
98        &self,
99        task: &TaskId,
100        record: &CancelledRecord,
101        duration: Option<Duration>,
102    ) -> Option<Vec<u8>>;
103}
104
105/// Default [`TaskPresenter`] used by the bare
106/// [`crate::output::LiveOutputObserver::new`] and
107/// [`crate::output::BufferedOutputObserver::new`] constructors.
108///
109/// Renders the per-line tag prefix as the historical
110/// `[project:task] ` byte sequence and returns [`None`] for every
111/// summary method, so the observers emit identical bytes to the
112/// pre-trait behaviour. The unit-struct shape carries no state:
113/// every instance is interchangeable.
114#[derive(Debug, Default, Clone, Copy)]
115pub struct PlainPresenter;
116
117impl TaskPresenter for PlainPresenter {
118    fn prefix(&self, task: &TaskId) -> Vec<u8> {
119        format!("[{task}] ").into_bytes()
120    }
121
122    fn summary_completed(
123        &self,
124        _task: &TaskId,
125        _record: &CompletedRecord,
126        _duration: Duration,
127    ) -> Option<Vec<u8>> {
128        None
129    }
130
131    fn summary_skipped(&self, _task: &TaskId, _record: &SkipRecord) -> Option<Vec<u8>> {
132        None
133    }
134
135    fn summary_cancelled(
136        &self,
137        _task: &TaskId,
138        _record: &CancelledRecord,
139        _duration: Option<Duration>,
140    ) -> Option<Vec<u8>> {
141        None
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use std::str::FromStr;
148
149    use haz_domain::name::{ProjectName, TaskName};
150
151    use super::*;
152
153    fn task_id_for(project: &str, task: &str) -> TaskId {
154        TaskId {
155            project: ProjectName::from_str(project).unwrap(),
156            task: TaskName::from_str(task).unwrap(),
157        }
158    }
159
160    #[test]
161    fn plain_presenter_prefix_matches_historical_format() {
162        let presenter = PlainPresenter;
163        let task = task_id_for("lib", "build");
164        assert_eq!(presenter.prefix(&task), b"[lib:build] ");
165    }
166
167    #[test]
168    fn plain_presenter_returns_none_for_every_summary_method() {
169        let presenter = PlainPresenter;
170        let task = task_id_for("lib", "build");
171        let upstream = task_id_for("lib", "root");
172
173        let completed = CompletedRecord {
174            task: task.clone(),
175            source: crate::run_task::RunSource::FreshRun,
176            state: crate::run_task::RunState::Succeeded,
177            exit_status: None,
178            stdout_hash: [0u8; 32],
179            stderr_hash: [0u8; 32],
180            materialised_outputs: Vec::new(),
181        };
182        assert!(
183            presenter
184                .summary_completed(&task, &completed, Duration::from_secs(1))
185                .is_none()
186        );
187
188        let skip = SkipRecord {
189            task: task.clone(),
190            cause: crate::run_task::SkipCause::UpstreamFailed {
191                upstream: upstream.clone(),
192            },
193        };
194        assert!(presenter.summary_skipped(&task, &skip).is_none());
195
196        let cancelled = CancelledRecord::RunCancelled { task: task.clone() };
197        assert!(
198            presenter
199                .summary_cancelled(&task, &cancelled, None)
200                .is_none()
201        );
202    }
203}