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}