Skip to main content

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}