Skip to main content

command_stream/terminal/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fmt;
4use std::path::PathBuf;
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct TerminalResize {
11    pub cols: u16,
12    pub rows: u16,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum TerminalKey {
17    Backspace,
18    CtrlC,
19    CtrlD,
20    Down,
21    Enter,
22    Escape,
23    Left,
24    Right,
25    Tab,
26    Up,
27    Raw(String),
28}
29
30impl TerminalKey {
31    pub(crate) fn sequence(&self) -> &str {
32        match self {
33            Self::Backspace => "\u{7f}",
34            Self::CtrlC => "\u{3}",
35            Self::CtrlD => "\u{4}",
36            Self::Down => "\u{1b}[B",
37            Self::Enter => "\r",
38            Self::Escape => "\u{1b}",
39            Self::Left => "\u{1b}[D",
40            Self::Right => "\u{1b}[C",
41            Self::Tab => "\t",
42            Self::Up => "\u{1b}[A",
43            Self::Raw(sequence) => sequence,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct TerminalInteraction {
50    pub after: Option<String>,
51    pub after_regex: Option<String>,
52    pub idle_duration: Duration,
53    pub text: Option<String>,
54    pub key: Option<TerminalKey>,
55    pub resize: Option<TerminalResize>,
56}
57
58/// One file written to [`TerminalCaptureOptions::artifact_directory`]
59/// (issue #231). The names match the JS `artifacts` option.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum TerminalArtifact {
62    /// `transcript.txt`: the unrolled transcript.
63    Transcript,
64    /// `frames.json`: every retained frame.
65    Frames,
66    /// `session.cast`: the asciicast v2 recording.
67    Cast,
68    /// `snapshot.svg`: the final frame.
69    Snapshot,
70    /// `recording.svg`: every frame, animated.
71    Recording,
72    /// `recording.gif`: every frame, animated, as a GIF.
73    Gif,
74}
75
76impl TerminalArtifact {
77    /// Every artifact, in the order they are written.
78    pub const ALL: [Self; 6] = [
79        Self::Transcript,
80        Self::Frames,
81        Self::Cast,
82        Self::Snapshot,
83        Self::Recording,
84        Self::Gif,
85    ];
86
87    /// The selection name, as used by the JS `artifacts` option.
88    pub fn name(self) -> &'static str {
89        match self {
90            Self::Transcript => "transcript",
91            Self::Frames => "frames",
92            Self::Cast => "cast",
93            Self::Snapshot => "snapshot",
94            Self::Recording => "recording",
95            Self::Gif => "gif",
96        }
97    }
98
99    /// The file written inside the artifact directory.
100    pub fn file_name(self) -> &'static str {
101        match self {
102            Self::Transcript => "transcript.txt",
103            Self::Frames => "frames.json",
104            Self::Cast => "session.cast",
105            Self::Snapshot => "snapshot.svg",
106            Self::Recording => "recording.svg",
107            Self::Gif => "recording.gif",
108        }
109    }
110}
111
112impl FromStr for TerminalArtifact {
113    type Err = TerminalCaptureError;
114
115    fn from_str(name: &str) -> Result<Self, Self::Err> {
116        Self::ALL
117            .into_iter()
118            .find(|artifact| artifact.name() == name)
119            .ok_or_else(|| {
120                let expected = Self::ALL.map(Self::name).join(", ");
121                TerminalCaptureError::new(
122                    format!("unknown terminal artifact {name:?}; expected one of {expected}"),
123                    None,
124                )
125            })
126    }
127}
128
129/// What happened during a capture, reported to
130/// [`TerminalCaptureOptions::on_trace`]. The kinds match the JS `onTrace`
131/// event `type`s (see [`TerminalTraceEvent::kind`]).
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum TerminalTraceEvent {
134    /// A scripted interaction became ready and is about to be applied.
135    InteractionScheduled { interaction_index: usize },
136    /// A scripted interaction is being written to the terminal.
137    InteractionApplied { interaction_index: usize },
138    /// The deadline passed; the child is being stopped.
139    Timeout { timeout_milliseconds: u128 },
140    /// The child wrote to the terminal.
141    Output { data: String },
142    /// The child exited and all of its output was read; always the last event.
143    Exit {
144        exit_code: i32,
145        signal: Option<String>,
146    },
147}
148
149impl TerminalTraceEvent {
150    /// The JS `onTrace` event `type`.
151    pub fn kind(&self) -> &'static str {
152        match self {
153            Self::InteractionScheduled { .. } => "interaction-scheduled",
154            Self::InteractionApplied { .. } => "interaction-applied",
155            Self::Timeout { .. } => "timeout",
156            Self::Output { .. } => "output",
157            Self::Exit { .. } => "exit",
158        }
159    }
160}
161
162/// A trace event with the seconds elapsed since the capture started.
163#[derive(Debug, Clone, PartialEq)]
164pub struct TerminalTrace {
165    pub time: f64,
166    pub event: TerminalTraceEvent,
167}
168
169/// Callback for [`TerminalCaptureOptions::on_trace`]. It runs on the thread
170/// driving the capture, so it should return quickly.
171#[derive(Clone)]
172pub struct TerminalTraceCallback(Arc<dyn Fn(&TerminalTrace) + Send + Sync>);
173
174impl TerminalTraceCallback {
175    pub fn new(callback: impl Fn(&TerminalTrace) + Send + Sync + 'static) -> Self {
176        Self(Arc::new(callback))
177    }
178
179    pub(crate) fn call(&self, trace: &TerminalTrace) {
180        (self.0)(trace);
181    }
182}
183
184impl fmt::Debug for TerminalTraceCallback {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        formatter.write_str("TerminalTraceCallback")
187    }
188}
189
190#[derive(Debug, Clone)]
191pub struct TerminalCaptureOptions {
192    pub file: String,
193    pub args: Vec<String>,
194    pub cwd: Option<PathBuf>,
195    /// Variables set on top of the inherited environment.
196    pub env: HashMap<String, String>,
197    /// Inherited variables to drop, applied after `env` (issue #229).
198    pub env_remove: Vec<String>,
199    /// Start from an empty environment instead of the parent's (issue #229).
200    /// `TERM`, `COLUMNS`, `LINES` and `env` are still set.
201    pub env_clear: bool,
202    pub cols: u16,
203    /// Ignored when `aspect_ratio` is set.
204    pub rows: u16,
205    /// Derive `rows` as `max(1, round(cols / (2 * aspect_ratio)))`, like the JS
206    /// `aspectRatio` option (JS defaults to `4 / 3`, i.e. 30 rows for 80
207    /// columns; `None` keeps `rows`, 24 by default).
208    pub aspect_ratio: Option<f64>,
209    pub settle_duration: Duration,
210    /// Retain a frame after every parsed PTY write instead of waiting for idle.
211    pub frames_on_write: bool,
212    /// Disable recording history; keep the latest frame and last 1MB of output.
213    pub recording: bool,
214    pub interactions: Vec<TerminalInteraction>,
215    pub stop_marker: Option<String>,
216    pub stop_marker_grace: Duration,
217    /// Deadline for the whole run. `None` keeps the child alive indefinitely,
218    /// which is the default for [`crate::terminal::open_terminal`] sessions.
219    pub timeout: Option<Duration>,
220    pub artifact_directory: Option<PathBuf>,
221    /// Which files to write to `artifact_directory`; `None` writes all of
222    /// [`TerminalArtifact::ALL`]. Renderers of unselected files are skipped.
223    pub artifacts: Option<Vec<TerminalArtifact>>,
224    /// Receives output, interaction, timeout and exit events as they happen.
225    pub on_trace: Option<TerminalTraceCallback>,
226}
227
228impl Default for TerminalCaptureOptions {
229    fn default() -> Self {
230        Self {
231            file: String::new(),
232            args: Vec::new(),
233            cwd: None,
234            env: HashMap::new(),
235            env_remove: Vec::new(),
236            env_clear: false,
237            cols: 80,
238            rows: 24,
239            aspect_ratio: None,
240            settle_duration: Duration::from_millis(35),
241            frames_on_write: false,
242            recording: true,
243            interactions: Vec::new(),
244            stop_marker: None,
245            stop_marker_grace: Duration::from_millis(250),
246            timeout: Some(Duration::from_secs(30)),
247            artifact_directory: None,
248            artifacts: None,
249            on_trace: None,
250        }
251    }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct TerminalCursor {
256    pub x: u16,
257    pub y: u16,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct TerminalFrame {
262    pub time: f64,
263    pub cols: u16,
264    pub rows: u16,
265    pub cursor: TerminalCursor,
266    pub alternate: bool,
267    pub lines: Vec<String>,
268    pub screen: Vec<String>,
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272pub struct AsciicastHeader {
273    pub version: u8,
274    pub width: u16,
275    pub height: u16,
276    pub timestamp: i64,
277    pub env: HashMap<String, String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct AsciicastEvent {
282    pub time: f64,
283    pub code: String,
284    pub data: String,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288pub struct Asciicast {
289    pub header: AsciicastHeader,
290    pub events: Vec<AsciicastEvent>,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294pub struct TerminalCapture {
295    pub exit_code: i32,
296    pub signal: Option<String>,
297    pub output: String,
298    pub transcript: String,
299    pub frames: Vec<TerminalFrame>,
300    pub interaction_count: usize,
301    pub asciicast: Asciicast,
302}
303
304#[derive(Debug)]
305pub struct TerminalCaptureError {
306    message: String,
307    partial: Option<Box<TerminalCapture>>,
308}
309
310impl TerminalCaptureError {
311    pub(crate) fn new(message: impl Into<String>, partial: Option<TerminalCapture>) -> Self {
312        Self {
313            message: message.into(),
314            partial: partial.map(Box::new),
315        }
316    }
317
318    pub fn partial_capture(&self) -> Option<&TerminalCapture> {
319        self.partial.as_deref()
320    }
321}
322
323impl fmt::Display for TerminalCaptureError {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        formatter.write_str(&self.message)
326    }
327}
328
329impl std::error::Error for TerminalCaptureError {}