term_transcript/
types.rs

1//! Core type definitions.
2
3use std::{borrow::Cow, fmt, io};
4
5use styled_str::{AnsiError, StyledString};
6
7pub(crate) type BoxedError = Box<dyn std::error::Error + Send + Sync>;
8
9/// Errors that can occur when processing terminal output.
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum TermError {
13    /// Ansi escape sequence parsing error.
14    Ansi(AnsiError),
15    /// IO error.
16    Io(io::Error),
17    /// Font embedding error.
18    FontEmbedding(BoxedError),
19}
20
21impl fmt::Display for TermError {
22    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::Ansi(err) => write!(formatter, "ANSI escape sequence parsing error: {err}"),
25            Self::Io(err) => write!(formatter, "I/O error: {err}"),
26            Self::FontEmbedding(err) => write!(formatter, "font embedding error: {err}"),
27        }
28    }
29}
30
31impl std::error::Error for TermError {
32    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
33        match self {
34            Self::Ansi(err) => Some(err),
35            Self::Io(err) => Some(err),
36            _ => None,
37        }
38    }
39}
40
41/// Transcript of a user interacting with the terminal.
42#[derive(Debug, Clone, Default)]
43pub struct Transcript {
44    interactions: Vec<Interaction>,
45}
46
47impl Transcript {
48    /// Creates an empty transcript.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Returns interactions in this transcript.
54    pub fn interactions(&self) -> &[Interaction] {
55        &self.interactions
56    }
57
58    /// Returns a mutable reference to interactions in this transcript.
59    pub fn interactions_mut(&mut self) -> &mut [Interaction] {
60        &mut self.interactions
61    }
62}
63
64impl Transcript {
65    /// Manually adds a new interaction to the end of this transcript.
66    ///
67    /// This method allows capturing interactions that are difficult or impossible to capture
68    /// using more high-level methods: [`Self::from_inputs()`] or [`Self::capture_output()`].
69    /// The resulting transcript will [render](crate::svg) just fine, but there could be issues
70    /// with [testing](crate::test) it.
71    pub fn add_existing_interaction(&mut self, interaction: Interaction) -> &mut Self {
72        self.interactions.push(interaction);
73        self
74    }
75
76    /// Manually adds a new interaction to the end of this transcript.
77    ///
78    /// This is a shortcut for calling [`Self::add_existing_interaction()`].
79    pub fn add_interaction(
80        &mut self,
81        input: impl Into<UserInput>,
82        output: StyledString,
83    ) -> &mut Self {
84        self.add_existing_interaction(Interaction::new(input, output))
85    }
86}
87
88impl FromIterator<Interaction> for Transcript {
89    fn from_iter<I: IntoIterator<Item = Interaction>>(iter: I) -> Self {
90        Self {
91            interactions: iter.into_iter().collect(),
92        }
93    }
94}
95
96impl Extend<Interaction> for Transcript {
97    fn extend<I: IntoIterator<Item = Interaction>>(&mut self, iter: I) {
98        self.interactions.extend(iter);
99    }
100}
101
102/// Portable, platform-independent version of [`ExitStatus`] from the standard library.
103///
104/// # Capturing `ExitStatus`
105///
106/// Some shells have means to check whether the input command was executed successfully.
107/// For example, in `sh`-like shells, one can compare the value of `$?` to 0, and
108/// in PowerShell to `True`. The exit status can be captured when creating a [`Transcript`]
109/// by setting a *checker* in [`ShellOptions::with_status_check()`](crate::ShellOptions::with_status_check()):
110///
111/// # Examples
112///
113/// ```
114/// # use term_transcript::{ExitStatus, ShellOptions, Transcript, UserInput};
115/// # fn test_wrapper() -> anyhow::Result<()> {
116/// let options = ShellOptions::default();
117/// let mut options = options.with_status_check("echo $?", |captured| {
118///     // Get the plain text from the styled string. This transform
119///     // is especially important in transcripts captured from PTY
120///     // since they can contain a *wild* amount of escape sequences.
121///     let captured = captured.text();
122///     let code: i32 = captured.trim().parse().ok()?;
123///     Some(ExitStatus(code))
124/// });
125///
126/// let transcript = Transcript::from_inputs(&mut options, [
127///     UserInput::command("echo \"Hello world\""),
128///     UserInput::command("some-non-existing-command"),
129/// ])?;
130/// let status = transcript.interactions()[0].exit_status();
131/// assert!(status.unwrap().is_success());
132/// // The assertion above is equivalent to:
133/// assert_eq!(status, Some(ExitStatus(0)));
134///
135/// let status = transcript.interactions()[1].exit_status();
136/// assert!(!status.unwrap().is_success());
137/// # Ok(())
138/// # }
139/// # // We can compile test in any case, but it successfully executes only on *nix.
140/// # #[cfg(unix)] fn main() { test_wrapper().unwrap() }
141/// # #[cfg(not(unix))] fn main() { }
142/// ```
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub struct ExitStatus(pub i32);
145
146impl ExitStatus {
147    /// Checks if this is the successful status.
148    pub fn is_success(self) -> bool {
149        self.0 == 0
150    }
151}
152
153/// One-time interaction with the terminal.
154#[derive(Debug, Clone)]
155pub struct Interaction {
156    input: UserInput,
157    output: StyledString,
158    exit_status: Option<ExitStatus>,
159}
160
161impl Interaction {
162    /// Creates a new interaction.
163    ///
164    /// Any newlines at the end of the output will be trimmed.
165    pub fn new(input: impl Into<UserInput>, mut output: StyledString) -> Self {
166        while output.text().ends_with('\n') {
167            output.pop();
168        }
169
170        Self {
171            input: input.into(),
172            output,
173            exit_status: None,
174        }
175    }
176
177    /// Sets an exit status for this interaction.
178    pub fn set_exit_status(&mut self, exit_status: Option<ExitStatus>) {
179        self.exit_status = exit_status;
180    }
181
182    /// Assigns an exit status to this interaction.
183    #[must_use]
184    pub fn with_exit_status(mut self, exit_status: ExitStatus) -> Self {
185        self.exit_status = Some(exit_status);
186        self
187    }
188}
189
190impl Interaction {
191    /// Input provided by the user.
192    pub fn input(&self) -> &UserInput {
193        &self.input
194    }
195
196    /// Output to the terminal.
197    pub fn output(&self) -> &StyledString {
198        &self.output
199    }
200
201    /// Sets the output for this interaction.
202    pub fn set_output(&mut self, mut output: StyledString) {
203        while output.text().ends_with('\n') {
204            output.pop();
205        }
206        self.output = output;
207    }
208
209    /// Returns exit status of the interaction, if available.
210    pub fn exit_status(&self) -> Option<ExitStatus> {
211        self.exit_status
212    }
213}
214
215/// User input during interaction with a terminal.
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[cfg_attr(feature = "svg", derive(serde::Serialize))]
218pub struct UserInput {
219    text: String,
220    prompt: Option<Cow<'static, str>>,
221    hidden: bool,
222}
223
224impl UserInput {
225    #[cfg(feature = "test")]
226    pub(crate) const EMPTY: Self = Self {
227        text: String::new(),
228        prompt: None,
229        hidden: false,
230    };
231
232    #[cfg(feature = "test")]
233    pub(crate) fn new(text: String) -> Self {
234        Self {
235            prompt: None,
236            text,
237            hidden: false,
238        }
239    }
240
241    #[cfg(feature = "test")]
242    #[must_use]
243    pub(crate) fn with_prompt(mut self, prompt: Option<String>) -> Self {
244        self.prompt = prompt.map(|prompt| match prompt.as_str() {
245            "$" => Cow::Borrowed("$"),
246            ">>>" => Cow::Borrowed(">>>"),
247            "..." => Cow::Borrowed("..."),
248            _ => Cow::Owned(prompt),
249        });
250        self
251    }
252
253    /// Creates a command input.
254    pub fn command(text: impl Into<String>) -> Self {
255        Self {
256            text: text.into(),
257            prompt: Some(Cow::Borrowed("$")),
258            hidden: false,
259        }
260    }
261
262    /// Creates a standalone / starting REPL command input with the `>>>` prompt.
263    pub fn repl(text: impl Into<String>) -> Self {
264        Self {
265            text: text.into(),
266            prompt: Some(Cow::Borrowed(">>>")),
267            hidden: false,
268        }
269    }
270
271    /// Creates a REPL command continuation input with the `...` prompt.
272    pub fn repl_continuation(text: impl Into<String>) -> Self {
273        Self {
274            text: text.into(),
275            prompt: Some(Cow::Borrowed("...")),
276            hidden: false,
277        }
278    }
279
280    /// Returns the prompt part of this input.
281    pub fn prompt(&self) -> Option<&str> {
282        self.prompt.as_deref()
283    }
284
285    /// Marks this input as hidden (one that should not be displayed in the rendered transcript).
286    #[must_use]
287    pub fn hide(mut self) -> Self {
288        self.hidden = true;
289        self
290    }
291
292    /// Checks whether this input is hidden.
293    pub fn is_hidden(&self) -> bool {
294        self.hidden
295    }
296}
297
298/// Returns the command part of the input without the prompt.
299impl AsRef<str> for UserInput {
300    fn as_ref(&self) -> &str {
301        &self.text
302    }
303}
304
305/// Calls [`Self::command()`] on the provided string reference.
306impl From<&str> for UserInput {
307    fn from(command: &str) -> Self {
308        Self::command(command)
309    }
310}