clawless_core/event/process/mod.rs
1//! Lifecycle events for the run of an external program
2//!
3//! A command that runs an external program reports what the program does as it
4//! does it. This module defines [`ProcessEvent`], the event that carries that
5//! report, and the types that describe a run: [`RunId`] identifies it, and
6//! [`Outcome`] says how it ended.
7//!
8//! A run produces one [`Started`] event, one [`Line`] event per line that the
9//! program writes to either of its output streams, and one [`Finished`] event.
10//! A consumer can therefore show the program while it runs, keep the last few
11//! lines of its output, and drop the whole group when the program ends.
12//!
13//! [`Finished`]: ProcessEvent::Finished
14//! [`Line`]: ProcessEvent::Line
15//! [`Started`]: ProcessEvent::Started
16
17use std::fmt::{self, Display};
18use std::time::Duration;
19
20use kawauso_process::run::Line;
21use kawauso_process::{Invocation, ProcessId};
22
23pub use self::outcome::Outcome;
24pub use self::run_id::RunId;
25
26/// The way in which a run ended
27mod outcome;
28/// The identity that ties the events of one run together
29mod run_id;
30
31/// One step in the run of an external program
32///
33/// Clawless sends these events while a program runs, so that a presenter can
34/// render the output of the program as it arrives instead of after the program
35/// ends. A build that takes two minutes therefore shows two minutes of
36/// progress.
37///
38/// Every run sends [`Started`] first and [`Finished`] last, with one [`Line`]
39/// in between for each line that the program wrote. Two things end a run
40/// without a [`Finished`]: a consumer that stopped listening, which cannot be
41/// told anything, and a caller that dropped the run instead of cancelling it,
42/// which leaves no code to send the event.
43///
44/// Each variant carries the [`RunId`] of its run. Two programs that run at
45/// the same time interleave their lines in the channel, and the identity is
46/// what separates them again. [`Started`] and [`Finished`] also carry the
47/// command itself, so that a presenter which keeps no state can name the
48/// program it is reporting on.
49///
50/// [`Started`] carries the [`ProcessId`] of the program as well. That value is
51/// the name the operating system knows the program by, which is what an
52/// operator needs to find it in a process list.
53///
54/// [`ProcessId`]: crate::process::ProcessId
55///
56/// # Examples
57///
58/// ```
59/// use clawless_core::event::process::{ProcessEvent, RunId};
60/// use clawless_core::process::Invocation;
61///
62/// let event = ProcessEvent::Started {
63/// id: RunId::next(),
64/// invocation: Invocation::new("git").arg("status"),
65/// process_id: None,
66/// };
67///
68/// assert_eq!(event.to_string(), "$ git status");
69/// ```
70///
71/// [`Finished`]: ProcessEvent::Finished
72/// [`Line`]: ProcessEvent::Line
73/// [`Started`]: ProcessEvent::Started
74// r[impl process.event.lifecycle]
75#[derive(Clone, Eq, PartialEq, Debug)]
76pub enum ProcessEvent {
77 /// The program started
78 ///
79 /// The event names the command, which is what a consumer shows while the
80 /// program runs.
81 // r[impl process.event.started]
82 Started {
83 /// The run that started
84 id: RunId,
85
86 /// The command that the run starts
87 invocation: Invocation,
88
89 /// The identifier that the operating system gave the program
90 ///
91 /// `None` when the operating system did not report one. A consumer
92 /// that names the program to a tool of the platform, or that writes
93 /// the identifier to a log, reads it here.
94 // r[impl process.event.started.identifier]
95 process_id: Option<ProcessId>,
96 },
97
98 /// The program wrote one line
99 ///
100 /// The line carries the stream that produced it, so that a consumer can
101 /// show a diagnostic apart from a result, or drop one of the two. It holds
102 /// no characters that end a line.
103 // r[impl process.event.line]
104 Line {
105 /// The run that wrote the line
106 id: RunId,
107
108 /// The line that the program wrote
109 line: Line,
110 },
111
112 /// The program ended
113 ///
114 /// No further event carries this identity. A consumer that shows running
115 /// programs removes the run here.
116 // r[impl process.event.finished]
117 Finished {
118 /// The run that ended
119 id: RunId,
120
121 /// The command that ran
122 invocation: Invocation,
123
124 /// The way in which the run ended
125 outcome: Outcome,
126
127 /// The time from the start of the program to the end of the run
128 duration: Duration,
129 },
130}
131
132impl ProcessEvent {
133 /// Returns the run that the event belongs to
134 ///
135 /// A consumer that groups the events of one program reads the identity
136 /// here instead of matching on the variant.
137 ///
138 /// # Examples
139 ///
140 /// ```
141 /// use clawless_core::event::process::{ProcessEvent, RunId};
142 /// use clawless_core::process::Invocation;
143 ///
144 /// let id = RunId::next();
145 /// let event = ProcessEvent::Started {
146 /// id,
147 /// invocation: Invocation::new("git"),
148 /// process_id: None,
149 /// };
150 ///
151 /// assert_eq!(event.id(), id);
152 /// ```
153 // r[impl process.event.correlation.accessor]
154 pub fn id(&self) -> RunId {
155 match self {
156 Self::Started { id, .. } => *id,
157 Self::Line { id, .. } => *id,
158 Self::Finished { id, .. } => *id,
159 }
160 }
161}
162
163/// Renders the event as one line of a transcript
164///
165/// The start of a run reads as a shell prompt, a line of output reads as the
166/// program wrote it, and the end of a run states what became of the command.
167/// A consumer that wants another shape matches on the variant and builds its
168/// own.
169///
170/// The text never names the stream that produced a line. A presenter that
171/// separates the two writes them to different places instead of labelling them,
172/// which keeps the output of the program readable.
173// r[impl process.event.display]
174impl Display for ProcessEvent {
175 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176 match self {
177 Self::Started { invocation, .. } => write!(formatter, "$ {invocation}"),
178 Self::Line { line, .. } => Display::fmt(line, formatter),
179 Self::Finished {
180 invocation,
181 outcome,
182 ..
183 } => write!(formatter, "{invocation} {outcome}"),
184 }
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 // An assertion in a test panics by design. A `# Panics` section on every test
191 // would repeat that and give the reader no information.
192 #![allow(clippy::missing_panics_doc)]
193
194 use kawauso_process::run::Stream;
195
196 use super::*;
197
198 #[test]
199 fn display_with_finished_names_the_command_and_the_outcome() {
200 let event = ProcessEvent::Finished {
201 id: RunId::next(),
202 invocation: Invocation::new("git").arg("status"),
203 outcome: Outcome::Cancelled,
204 duration: Duration::from_secs(1),
205 };
206
207 let text = event.to_string();
208
209 assert_eq!(text, "git status was cancelled before it ended");
210 }
211
212 #[test]
213 fn display_with_line_returns_the_line_alone() {
214 let event = ProcessEvent::Line {
215 id: RunId::next(),
216 line: Line::new(Stream::StandardError, "no such file"),
217 };
218
219 let text = event.to_string();
220
221 assert_eq!(text, "no such file");
222 }
223
224 // r[verify process.event.display]
225 #[test]
226 fn display_with_started_reads_as_a_prompt() {
227 let event = ProcessEvent::Started {
228 id: RunId::next(),
229 invocation: Invocation::new("git").arg("status"),
230 process_id: None,
231 };
232
233 let text = event.to_string();
234
235 assert_eq!(text, "$ git status");
236 }
237
238 #[test]
239 fn id_with_finished_returns_the_run() {
240 let id = RunId::next();
241 let event = ProcessEvent::Finished {
242 id,
243 invocation: Invocation::new("git"),
244 outcome: Outcome::Incomplete,
245 duration: Duration::ZERO,
246 };
247
248 let found = event.id();
249
250 assert_eq!(found, id);
251 }
252
253 // r[verify process.event.correlation.accessor]
254 #[test]
255 fn id_with_line_returns_the_run() {
256 let id = RunId::next();
257 let event = ProcessEvent::Line {
258 id,
259 line: Line::new(Stream::StandardOutput, "hello"),
260 };
261
262 let found = event.id();
263
264 assert_eq!(found, id);
265 }
266
267 #[test]
268 fn id_with_started_returns_the_run() {
269 let id = RunId::next();
270 let event = ProcessEvent::Started {
271 id,
272 invocation: Invocation::new("git"),
273 process_id: None,
274 };
275
276 let found = event.id();
277
278 assert_eq!(found, id);
279 }
280
281 #[test]
282 fn trait_send() {
283 fn assert_send<T: Send>() {}
284 assert_send::<ProcessEvent>();
285 }
286
287 #[test]
288 fn trait_sync() {
289 fn assert_sync<T: Sync>() {}
290 assert_sync::<ProcessEvent>();
291 }
292
293 #[test]
294 fn trait_unpin() {
295 fn assert_unpin<T: Unpin>() {}
296 assert_unpin::<ProcessEvent>();
297 }
298}