use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
pub const PTY_COLS: u16 = 400;
pub const PTY_ROWS: u16 = 60;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub struct PtySession {
writer: Mutex<Box<dyn Write + Send>>,
output: Arc<Mutex<Output>>,
child: Mutex<Box<dyn Child + Send + Sync>>,
eof: Arc<AtomicBool>,
_master: Mutex<Box<dyn MasterPty + Send>>,
}
struct Output {
bytes: Vec<u8>,
last_write: Instant,
}
#[derive(Debug)]
pub enum WaitError {
Timeout,
ChildExited(Option<u32>),
}
impl std::fmt::Display for WaitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Timeout => write!(f, "timed out waiting for expected terminal output"),
Self::ChildExited(code) => match code {
Some(code) => write!(f, "process exited with status {code} before it was ready"),
None => write!(f, "process exited before it was ready"),
},
}
}
}
impl std::error::Error for WaitError {}
impl PtySession {
pub fn spawn(command: CommandBuilder) -> std::io::Result<Self> {
let pty = native_pty_system()
.openpty(PtySize {
rows: PTY_ROWS,
cols: PTY_COLS,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| std::io::Error::other(e.to_string()))?;
let child = pty
.slave
.spawn_command(command)
.map_err(|e| std::io::Error::other(e.to_string()))?;
drop(pty.slave);
let mut reader = pty
.master
.try_clone_reader()
.map_err(|e| std::io::Error::other(e.to_string()))?;
let writer = pty
.master
.take_writer()
.map_err(|e| std::io::Error::other(e.to_string()))?;
let output = Arc::new(Mutex::new(Output {
bytes: Vec::new(),
last_write: Instant::now(),
}));
let eof = Arc::new(AtomicBool::new(false));
let thread_output = Arc::clone(&output);
let thread_eof = Arc::clone(&eof);
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if let Ok(mut guard) = thread_output.lock() {
guard.bytes.extend_from_slice(&buf[..n]);
guard.last_write = Instant::now();
}
}
}
}
thread_eof.store(true, Ordering::SeqCst);
});
Ok(Self {
writer: Mutex::new(writer),
output,
child: Mutex::new(child),
eof,
_master: Mutex::new(pty.master),
})
}
#[must_use]
pub fn transcript(&self) -> String {
let bytes = self
.output
.lock()
.map(|guard| guard.bytes.clone())
.unwrap_or_default();
strip_ansi(&String::from_utf8_lossy(&bytes))
}
#[must_use]
pub fn transcript_tail(&self, limit: usize) -> String {
let text = self.transcript();
let trimmed = text.trim();
if trimmed.chars().count() <= limit {
return trimmed.to_string();
}
let skip = trimmed.chars().count() - limit;
trimmed.chars().skip(skip).collect()
}
pub fn wait_for<F>(
&self,
predicate: F,
idle: Duration,
timeout: Duration,
) -> Result<String, WaitError>
where
F: Fn(&str) -> bool,
{
let deadline = Instant::now() + timeout;
loop {
let (text, quiet_for) = self.snapshot();
if predicate(&text) && quiet_for >= idle {
return Ok(text);
}
if self.eof.load(Ordering::SeqCst) && quiet_for >= idle {
return if predicate(&text) {
Ok(text)
} else {
Err(WaitError::ChildExited(self.exit_code()))
};
}
if Instant::now() >= deadline {
return Err(WaitError::Timeout);
}
std::thread::sleep(POLL_INTERVAL);
}
}
pub fn wait_for_exit(&self, timeout: Duration) -> Result<Option<u32>, WaitError> {
let deadline = Instant::now() + timeout;
loop {
if let Some(code) = self.exit_code() {
return Ok(Some(code));
}
if Instant::now() >= deadline {
return Err(WaitError::Timeout);
}
std::thread::sleep(POLL_INTERVAL);
}
}
fn snapshot(&self) -> (String, Duration) {
let (bytes, quiet_for) = self.output.lock().map_or_else(
|_| (Vec::new(), Duration::ZERO),
|guard| (guard.bytes.clone(), guard.last_write.elapsed()),
);
(strip_ansi(&String::from_utf8_lossy(&bytes)), quiet_for)
}
#[must_use]
pub fn exit_code(&self) -> Option<u32> {
let mut guard = self.child.lock().ok()?;
match guard.try_wait() {
Ok(Some(status)) => Some(status.exit_code()),
_ => None,
}
}
#[must_use]
pub fn is_running(&self) -> bool {
self.exit_code().is_none()
}
pub fn send_text(&self, text: &str) -> std::io::Result<()> {
let mut writer = self
.writer
.lock()
.map_err(|_| std::io::Error::other("PTY writer poisoned"))?;
writer.write_all(text.as_bytes())?;
writer.flush()
}
pub fn send_key(&self, key: Key) -> std::io::Result<()> {
self.send_text(key.sequence())
}
pub fn kill(&self) {
if let Ok(mut guard) = self.child.lock() {
let _ = guard.kill();
let _ = guard.wait();
}
}
}
impl Drop for PtySession {
fn drop(&mut self) {
self.kill();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Enter,
Escape,
CtrlC,
}
impl Key {
#[must_use]
pub const fn sequence(self) -> &'static str {
match self {
Self::Enter => "\r",
Self::Escape => "\x1b",
Self::CtrlC => "\x03",
}
}
}
#[must_use]
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\x1b' => match chars.next() {
Some('[') => {
for next in chars.by_ref() {
if ('\x40'..='\x7e').contains(&next) {
break;
}
}
}
Some(']') => {
while let Some(next) = chars.next() {
if next == '\x07' {
break;
}
if next == '\x1b' {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
}
}
Some(_) | None => {}
},
'\r' => {}
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_colour_and_cursor_sequences() {
let raw = "\x1b[2J\x1b[H\x1b[1;32mReady\x1b[0m\r\nnext";
assert_eq!(strip_ansi(raw), "Ready\nnext");
}
#[test]
fn strips_osc_title_sequences() {
assert_eq!(strip_ansi("\x1b]0;window title\x07text"), "text");
assert_eq!(strip_ansi("\x1b]0;window title\x1b\\text"), "text");
}
#[test]
fn keys_map_to_terminal_sequences() {
assert_eq!(Key::Enter.sequence(), "\r");
assert_eq!(Key::CtrlC.sequence(), "\x03");
}
}