1use super::artifacts::{unroll_terminal_frames, write_terminal_artifacts};
2use super::types::{
3 Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError,
4 TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalResize,
5};
6use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, MasterPty, PtySize};
7use regex::Regex;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::sync::mpsc;
11use std::time::{Duration, Instant};
12
13const ERASE_SCREEN: &[u8] = b"\x1b[2J";
14
15fn elapsed(started: Instant) -> f64 {
16 (started.elapsed().as_secs_f64() * 1_000_000.0).round() / 1_000_000.0
17}
18
19fn trim_trailing_blank(mut lines: Vec<String>) -> Vec<String> {
20 while lines.last().is_some_and(String::is_empty) {
21 lines.pop();
22 }
23 lines
24}
25
26fn frame(parser: &vt100::Parser, started: Instant) -> TerminalFrame {
27 let screen = parser.screen();
28 let (rows, cols) = screen.size();
29 let (cursor_y, cursor_x) = screen.cursor_position();
30 let lines = trim_trailing_blank(screen.rows(0, cols).collect());
31 TerminalFrame {
32 time: elapsed(started),
33 cols,
34 rows,
35 cursor: TerminalCursor {
36 x: cursor_x,
37 y: cursor_y,
38 },
39 alternate: screen.alternate_screen(),
40 screen: lines.clone(),
41 lines,
42 }
43}
44
45fn same_frame(left: &TerminalFrame, right: &TerminalFrame) -> bool {
46 left.cols == right.cols
47 && left.rows == right.rows
48 && left.cursor == right.cursor
49 && left.alternate == right.alternate
50 && left.lines == right.lines
51}
52
53fn append_frame(frames: &mut Vec<TerminalFrame>, parser: &vt100::Parser, started: Instant) {
54 let next = frame(parser, started);
55 if frames
56 .last()
57 .is_none_or(|previous| !same_frame(previous, &next))
58 {
59 frames.push(next);
60 }
61}
62
63fn render_segments(data: &[u8]) -> Vec<&[u8]> {
64 let positions = data
65 .windows(ERASE_SCREEN.len())
66 .enumerate()
67 .filter_map(|(index, window)| (window == ERASE_SCREEN).then_some(index))
68 .collect::<Vec<_>>();
69 if positions.is_empty() {
70 return vec![data];
71 }
72
73 let mut segments = Vec::new();
74 if positions[0] > 0 {
75 segments.push(&data[..positions[0]]);
76 }
77 for (index, position) in positions.iter().enumerate() {
78 let end = positions.get(index + 1).copied().unwrap_or(data.len());
79 segments.push(&data[*position..end]);
80 }
81 segments
82}
83
84fn drain_complete_render_data(pending: &mut Vec<u8>) -> Vec<u8> {
85 let maximum = pending.len().min(ERASE_SCREEN.len() - 1);
86 let pending_length = (1..=maximum)
87 .rev()
88 .find(|length| ERASE_SCREEN.starts_with(&pending[pending.len() - length..]))
89 .unwrap_or(0);
90 pending.drain(..pending.len() - pending_length).collect()
91}
92
93fn record(asciicast: &mut Asciicast, started: Instant, code: &str, data: impl Into<String>) {
94 asciicast.events.push(AsciicastEvent {
95 time: elapsed(started),
96 code: code.into(),
97 data: data.into(),
98 });
99}
100
101fn validate_interaction(interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
102 let has_action =
103 interaction.text.is_some() || interaction.key.is_some() || interaction.resize.is_some();
104 let has_wait = interaction.after.is_some()
105 || interaction.after_regex.is_some()
106 || interaction.idle_duration > Duration::ZERO;
107 if has_action || has_wait {
108 return Ok(());
109 }
110
111 Err(TerminalCaptureError::new(
112 format!(
113 "invalid terminal interaction {interaction:?}; expected an action with text, key, or \
114 resize, or a wait with after, after_regex, or idle_duration"
115 ),
116 None,
117 ))
118}
119
120fn apply_interaction(
121 interaction: &TerminalInteraction,
122 writer: &mut dyn Write,
123 master: &dyn MasterPty,
124 parser: &mut vt100::Parser,
125 asciicast: &mut Asciicast,
126 started: Instant,
127) -> Result<(), TerminalCaptureError> {
128 validate_interaction(interaction)?;
129 if let Some(text) = &interaction.text {
130 writer
131 .write_all(text.as_bytes())
132 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
133 writer
134 .flush()
135 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
136 record(asciicast, started, "i", text.clone());
137 }
138 if let Some(key) = &interaction.key {
139 writer
140 .write_all(key.sequence().as_bytes())
141 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
142 writer
143 .flush()
144 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
145 record(asciicast, started, "i", key.sequence());
146 }
147 if let Some(resize) = interaction.resize {
148 resize_terminal(master, parser, resize)?;
149 record(
150 asciicast,
151 started,
152 "r",
153 format!("{}x{}", resize.cols, resize.rows),
154 );
155 }
156 Ok(())
157}
158
159fn resize_terminal(
160 master: &dyn MasterPty,
161 parser: &mut vt100::Parser,
162 resize: TerminalResize,
163) -> Result<(), TerminalCaptureError> {
164 master
165 .resize(PtySize {
166 rows: resize.rows,
167 cols: resize.cols,
168 pixel_width: 0,
169 pixel_height: 0,
170 })
171 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
172 parser.set_size(resize.rows, resize.cols);
173 Ok(())
174}
175
176fn asciicast(options: &TerminalCaptureOptions) -> Asciicast {
177 let mut env = HashMap::new();
178 env.insert("SHELL".into(), options.file.clone());
179 env.insert(
180 "TERM".into(),
181 options
182 .env
183 .get("TERM")
184 .cloned()
185 .unwrap_or_else(|| "xterm-256color".into()),
186 );
187 Asciicast {
188 header: AsciicastHeader {
189 version: 2,
190 width: options.cols,
191 height: options.rows,
192 timestamp: chrono::Utc::now().timestamp(),
193 env,
194 },
195 events: Vec::new(),
196 }
197}
198
199fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
200 let (sender, receiver) = mpsc::channel();
201 std::thread::spawn(move || {
202 let mut buffer = [0_u8; 8192];
203 loop {
204 match reader.read(&mut buffer) {
205 Ok(0) | Err(_) => break,
206 Ok(length) => {
207 if sender.send(buffer[..length].to_vec()).is_err() {
208 break;
209 }
210 }
211 }
212 }
213 });
214 receiver
215}
216
217fn capture_result(
218 status: portable_pty::ExitStatus,
219 output: String,
220 frames: Vec<TerminalFrame>,
221 interaction_count: usize,
222 asciicast: Asciicast,
223) -> TerminalCapture {
224 TerminalCapture {
225 exit_code: status.exit_code() as i32,
226 signal: status.signal().map(str::to_owned),
227 transcript: unroll_terminal_frames(&frames),
228 output,
229 frames,
230 interaction_count,
231 asciicast,
232 }
233}
234
235#[derive(Debug, Clone)]
238pub enum TerminalPattern {
239 Text(String),
240 Regex(Regex),
241}
242
243impl TerminalPattern {
244 pub fn text(value: impl Into<String>) -> Self {
245 Self::Text(value.into())
246 }
247
248 pub fn regex(pattern: &str) -> Result<Self, TerminalCaptureError> {
249 Regex::new(pattern).map(Self::Regex).map_err(|error| {
250 TerminalCaptureError::new(format!("invalid terminal pattern regex: {error}"), None)
251 })
252 }
253
254 fn matches(&self, output: &str) -> bool {
255 match self {
256 Self::Text(value) => output.contains(value),
257 Self::Regex(pattern) => pattern.is_match(output),
258 }
259 }
260}
261
262pub struct TerminalSession {
265 options: TerminalCaptureOptions,
266 interaction_regexes: Vec<Option<Regex>>,
267 master: Box<dyn MasterPty + Send>,
268 writer: Box<dyn Write + Send>,
269 child: Box<dyn Child + Send + Sync>,
270 receiver: mpsc::Receiver<Vec<u8>>,
271 started: Instant,
272 parser: vt100::Parser,
273 recording: Asciicast,
274 output: String,
275 frames: Vec<TerminalFrame>,
276 pending_render: Vec<u8>,
277 terminal_has_output: bool,
278 interaction_index: usize,
279 last_output: Option<Instant>,
280 dirty: bool,
281 reader_closed: bool,
282 status: Option<ExitStatus>,
283 timed_out: bool,
284 stop_deadline: Option<Instant>,
285}
286
287impl TerminalSession {
288 fn open(options: TerminalCaptureOptions) -> Result<Self, TerminalCaptureError> {
289 if options.file.is_empty() {
290 return Err(TerminalCaptureError::new(
291 "open_terminal requires a file",
292 None,
293 ));
294 }
295 for interaction in &options.interactions {
296 validate_interaction(interaction)?;
297 }
298 let interaction_regexes = options
299 .interactions
300 .iter()
301 .map(|interaction| {
302 interaction
303 .after_regex
304 .as_ref()
305 .map(|pattern| {
306 Regex::new(pattern).map_err(|error| {
307 TerminalCaptureError::new(
308 format!("invalid terminal interaction regex: {error}"),
309 None,
310 )
311 })
312 })
313 .transpose()
314 })
315 .collect::<Result<Vec<_>, _>>()?;
316 let pty = native_pty_system()
317 .openpty(PtySize {
318 rows: options.rows,
319 cols: options.cols,
320 pixel_width: 0,
321 pixel_height: 0,
322 })
323 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
324 let mut command = CommandBuilder::new(&options.file);
325 command.args(&options.args);
326 if let Some(cwd) = &options.cwd {
327 command.cwd(cwd);
328 }
329 command.env(
330 "TERM",
331 options
332 .env
333 .get("TERM")
334 .map_or("xterm-256color", String::as_str),
335 );
336 for (name, value) in &options.env {
337 command.env(name, value);
338 }
339 let child = pty
340 .slave
341 .spawn_command(command)
342 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
343 drop(pty.slave);
344 let reader = pty
345 .master
346 .try_clone_reader()
347 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
348 let writer = pty
349 .master
350 .take_writer()
351 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
352 let receiver = spawn_reader(reader);
353 let recording = asciicast(&options);
354 let parser = vt100::Parser::new(options.rows, options.cols, 100_000);
355 Ok(Self {
356 interaction_regexes,
357 master: pty.master,
358 writer,
359 child,
360 receiver,
361 started: Instant::now(),
362 parser,
363 recording,
364 output: String::new(),
365 frames: Vec::new(),
366 pending_render: Vec::new(),
367 terminal_has_output: false,
368 interaction_index: 0,
369 last_output: None,
370 dirty: false,
371 reader_closed: false,
372 status: None,
373 timed_out: false,
374 stop_deadline: None,
375 options,
376 })
377 }
378
379 pub fn output(&self) -> &str {
381 &self.output
382 }
383
384 pub fn frames(&self) -> &[TerminalFrame] {
386 &self.frames
387 }
388
389 pub fn transcript(&self) -> String {
391 unroll_terminal_frames(&self.frames)
392 }
393
394 pub fn running(&self) -> bool {
396 self.status.is_none()
397 }
398
399 fn read_available(&mut self) {
400 match self.receiver.recv_timeout(Duration::from_millis(5)) {
401 Ok(data) => {
402 let text = String::from_utf8_lossy(&data);
403 self.output.push_str(&text);
404 record(&mut self.recording, self.started, "o", text.into_owned());
405 self.pending_render.extend_from_slice(&data);
406 let render_data = drain_complete_render_data(&mut self.pending_render);
407 let segments = render_segments(&render_data);
408 let segment_count = segments.len();
409 if self.terminal_has_output && render_data.starts_with(ERASE_SCREEN) {
410 append_frame(&mut self.frames, &self.parser, self.started);
411 }
412 for (index, segment) in segments.into_iter().enumerate() {
413 self.parser.process(segment);
414 self.terminal_has_output |= !segment.is_empty();
415 if index + 1 < segment_count {
416 append_frame(&mut self.frames, &self.parser, self.started);
417 }
418 }
419 self.last_output = Some(Instant::now());
420 self.dirty = true;
421 if self
422 .options
423 .stop_marker
424 .as_ref()
425 .is_some_and(|marker| self.output.contains(marker))
426 && self.stop_deadline.is_none()
427 {
428 append_frame(&mut self.frames, &self.parser, self.started);
429 self.stop_deadline = Some(Instant::now() + self.options.stop_marker_grace);
430 }
431 }
432 Err(mpsc::RecvTimeoutError::Disconnected) => self.reader_closed = true,
433 Err(mpsc::RecvTimeoutError::Timeout) => {}
434 }
435 }
436
437 fn idle_for(&self) -> Duration {
438 self.last_output
439 .map_or_else(|| self.started.elapsed(), |instant| instant.elapsed())
440 }
441
442 fn apply_scripted_interactions(&mut self) -> Result<(), TerminalCaptureError> {
443 while let Some(interaction) = self.options.interactions.get(self.interaction_index) {
444 if interaction
445 .after
446 .as_ref()
447 .is_some_and(|marker| !self.output.contains(marker))
448 {
449 break;
450 }
451 if self.interaction_regexes[self.interaction_index]
452 .as_ref()
453 .is_some_and(|pattern| !pattern.is_match(&self.output))
454 {
455 break;
456 }
457 if interaction.idle_duration > Duration::ZERO
458 && self.idle_for() < interaction.idle_duration
459 {
460 break;
461 }
462 let interaction = interaction.clone();
463 append_frame(&mut self.frames, &self.parser, self.started);
464 apply_interaction(
465 &interaction,
466 self.writer.as_mut(),
467 self.master.as_ref(),
468 &mut self.parser,
469 &mut self.recording,
470 self.started,
471 )?;
472 self.interaction_index += 1;
473 }
474 Ok(())
475 }
476
477 fn poll(&mut self) -> Result<(), TerminalCaptureError> {
481 self.read_available();
482 self.apply_scripted_interactions()?;
483
484 if self.dirty
485 && self
486 .last_output
487 .is_some_and(|instant| instant.elapsed() >= self.options.settle_duration)
488 {
489 append_frame(&mut self.frames, &self.parser, self.started);
490 self.dirty = false;
491 }
492 if self.status.is_none() {
493 self.status = self
494 .child
495 .try_wait()
496 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
497 }
498 if self.status.is_none() {
499 let expired = self
500 .options
501 .timeout
502 .is_some_and(|timeout| self.started.elapsed() >= timeout);
503 let stopped = self
504 .stop_deadline
505 .is_some_and(|deadline| Instant::now() >= deadline);
506 if expired || stopped {
507 self.timed_out = expired;
508 self.stop()?;
509 }
510 }
511 Ok(())
512 }
513
514 fn finished(&self) -> bool {
515 self.status.is_some() && self.reader_closed
516 }
517
518 fn stop(&mut self) -> Result<(), TerminalCaptureError> {
519 let _ = self.child.kill();
520 self.status = Some(
521 self.child
522 .wait()
523 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
524 );
525 Ok(())
526 }
527
528 pub fn wait_for(
531 &mut self,
532 pattern: &TerminalPattern,
533 idle: Duration,
534 timeout: Option<Duration>,
535 ) -> Result<(), TerminalCaptureError> {
536 let deadline = timeout.map(|limit| Instant::now() + limit);
537 loop {
538 if pattern.matches(&self.output) && self.idle_for() >= idle {
539 return Ok(());
540 }
541 if self.status.is_some() {
542 self.poll()?;
543 if pattern.matches(&self.output) {
544 return Ok(());
545 }
546 if self.finished() {
547 return Err(TerminalCaptureError::new(
548 "terminal exited before the expected output arrived",
549 None,
550 ));
551 }
552 continue;
553 }
554 if deadline.is_some_and(|limit| Instant::now() >= limit) {
555 return Err(TerminalCaptureError::new(
556 format!(
557 "terminal wait_for timed out after {} ms",
558 timeout.unwrap_or_default().as_millis()
559 ),
560 None,
561 ));
562 }
563 self.poll()?;
564 }
565 }
566
567 pub fn send(&mut self, interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
570 validate_interaction(interaction)?;
571 if let Some(marker) = &interaction.after {
572 let pattern = TerminalPattern::text(marker.clone());
573 self.wait_for(&pattern, interaction.idle_duration, None)?;
574 } else if let Some(expression) = &interaction.after_regex {
575 let pattern = TerminalPattern::regex(expression)?;
576 self.wait_for(&pattern, interaction.idle_duration, None)?;
577 } else if interaction.idle_duration > Duration::ZERO {
578 while self.idle_for() < interaction.idle_duration && self.status.is_none() {
579 self.poll()?;
580 }
581 }
582 if self.status.is_some() {
583 return Err(TerminalCaptureError::new(
584 "terminal session has already exited",
585 None,
586 ));
587 }
588 append_frame(&mut self.frames, &self.parser, self.started);
589 apply_interaction(
590 interaction,
591 self.writer.as_mut(),
592 self.master.as_ref(),
593 &mut self.parser,
594 &mut self.recording,
595 self.started,
596 )
597 }
598
599 pub fn finish(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
601 while !self.finished() {
602 self.poll()?;
603 }
604 self.into_capture()
605 }
606
607 pub fn close(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
610 if self.status.is_none() {
611 self.stop()?;
612 }
613 while !self.finished() {
614 self.read_available();
615 }
616 self.into_capture()
617 }
618
619 fn into_capture(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
620 let pending = std::mem::take(&mut self.pending_render);
621 self.parser.process(&pending);
622 append_frame(&mut self.frames, &self.parser, self.started);
623 let capture = capture_result(
624 self.status
625 .clone()
626 .expect("child status is available after the capture loop"),
627 std::mem::take(&mut self.output),
628 std::mem::take(&mut self.frames),
629 self.interaction_index,
630 std::mem::replace(&mut self.recording, asciicast(&self.options)),
631 );
632 if let Some(directory) = &self.options.artifact_directory {
633 write_terminal_artifacts(
634 directory,
635 &capture.frames,
636 &capture.transcript,
637 &capture.asciicast,
638 )?;
639 }
640 if self.timed_out {
641 return Err(TerminalCaptureError::new(
642 format!(
643 "terminal command timed out after {} ms",
644 self.options.timeout.unwrap_or_default().as_millis()
645 ),
646 Some(capture),
647 ));
648 }
649 Ok(capture)
650 }
651}
652
653pub fn open_terminal(
660 options: TerminalCaptureOptions,
661) -> Result<TerminalSession, TerminalCaptureError> {
662 TerminalSession::open(TerminalCaptureOptions {
663 timeout: None,
664 ..options
665 })
666}
667
668pub fn capture_terminal(
670 options: TerminalCaptureOptions,
671) -> Result<TerminalCapture, TerminalCaptureError> {
672 if options.file.is_empty() {
673 return Err(TerminalCaptureError::new(
674 "capture_terminal requires a file",
675 None,
676 ));
677 }
678 TerminalSession::open(options)?.finish()
679}
680
681pub async fn capture_terminal_async(
682 options: TerminalCaptureOptions,
683) -> Result<TerminalCapture, TerminalCaptureError> {
684 tokio::task::spawn_blocking(move || capture_terminal(options))
685 .await
686 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
687}