use std::io::{Read, Write};
use portable_pty::{CommandBuilder, MasterPty, PtySize as PortablePtySize};
#[cfg(unix)]
mod platform {
const SIGTERM: i32 = 15;
const SIGKILL: i32 = 9;
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
pub fn send_signal(pid: u32, sig: i32) -> Result<(), String> {
let ret = unsafe { kill(pid as i32, sig) };
if ret != 0 { Err(std::io::Error::last_os_error().to_string()) } else { Ok(()) }
}
pub fn terminate(pid: u32) -> Result<(), String> {
send_signal(pid, SIGTERM).or_else(|_| send_signal(pid, SIGKILL))
}
}
#[cfg(windows)]
mod platform {
pub fn terminate(_pid: u32) -> Result<(), String> {
Err("Force-kill not supported on Windows yet".to_string())
}
}
#[derive(Debug, Clone)]
pub struct PtyConfig {
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub working_directory: Option<String>,
pub size: PtySize,
}
impl Default for PtyConfig {
fn default() -> Self {
Self {
program: "bash".to_string(),
args: Vec::new(),
env: Vec::new(),
working_directory: None,
size: PtySize::default(),
}
}
}
impl PtyConfig {
pub fn new(program: &str) -> Self {
Self { program: program.to_string(), ..Default::default() }
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
self.env = env;
self
}
pub fn with_working_directory(mut self, dir: String) -> Self {
self.working_directory = Some(dir);
self
}
pub fn with_size(mut self, size: PtySize) -> Self {
self.size = size;
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct PtySize {
pub cols: u16,
pub rows: u16,
pub pixel_width: u32,
pub pixel_height: u32,
}
impl Default for PtySize {
fn default() -> Self {
Self { cols: 80, rows: 24, pixel_width: 0, pixel_height: 0 }
}
}
impl PtySize {
pub fn new(cols: u16, rows: u16) -> Self {
Self { cols, rows, pixel_width: 0, pixel_height: 0 }
}
pub fn with_pixel_size(mut self, width: u32, height: u32) -> Self {
self.pixel_width = width;
self.pixel_height = height;
self
}
#[allow(clippy::wrong_self_convention)]
fn to_portable(self) -> PortablePtySize {
PortablePtySize {
rows: self.rows,
cols: self.cols,
pixel_width: self.pixel_width as u16,
pixel_height: self.pixel_height as u16,
}
}
}
pub struct PtyProcess {
child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
reader: Option<Box<dyn Read + Send>>,
writer: Option<Box<dyn Write + Send>>,
master: Option<Box<dyn MasterPty + Send>>,
size: PtySize,
pid: Option<u32>,
}
impl PtyProcess {
pub fn spawn(config: PtyConfig) -> Result<Self, PtyError> {
let system = portable_pty::native_pty_system();
let pair = system.openpty(config.size.to_portable()).map_err(|e| PtyError::SpawnFailed(e.to_string()))?;
let mut cmd = CommandBuilder::new(&config.program);
for arg in &config.args {
cmd.arg(arg);
}
for (key, value) in &config.env {
cmd.env(key, value);
}
if let Some(ref dir) = config.working_directory {
cmd.cwd(dir);
}
let child = pair.slave.spawn_command(cmd).map_err(|e| PtyError::SpawnFailed(e.to_string()))?;
let pid = child.process_id();
let reader = pair.master.try_clone_reader().map_err(|e| PtyError::SpawnFailed(e.to_string()))?;
let writer = pair.master.take_writer().map_err(|e| PtyError::SpawnFailed(e.to_string()))?;
Ok(Self {
child: Some(child),
reader: Some(reader),
writer: Some(writer),
master: Some(pair.master),
size: config.size,
pid,
})
}
pub fn read(&mut self, buf: &mut [u8]) -> Result<usize, PtyError> {
if let Some(ref mut reader) = self.reader {
reader.read(buf).map_err(|e| PtyError::ReadFailed(e.to_string()))
} else {
Err(PtyError::ReadFailed("No reader".to_string()))
}
}
pub fn write(&mut self, data: &[u8]) -> Result<usize, PtyError> {
if let Some(ref mut writer) = self.writer {
writer.write(data).map_err(|e| PtyError::WriteFailed(e.to_string()))
} else {
Err(PtyError::WriteFailed("No writer".to_string()))
}
}
pub fn resize(&mut self, size: PtySize) -> Result<(), PtyError> {
self.size = size;
if let Some(ref master) = self.master {
master.resize(size.to_portable()).map_err(|e| PtyError::ResizeFailed(e.to_string()))?;
}
Ok(())
}
pub fn is_running(&mut self) -> bool {
if let Some(ref mut child) = self.child { child.try_wait().is_ok_and(|status| status.is_none()) } else { false }
}
pub fn exit_status(&mut self) -> Option<i32> {
if let Some(ref mut child) = self.child {
child.try_wait().ok().flatten().map(|status| status.exit_code() as i32)
} else {
None
}
}
pub fn kill(&mut self) -> Result<(), PtyError> {
self.master.take();
self.reader.take();
self.writer.take();
if let Some(pid) = self.pid {
platform::terminate(pid).map_err(PtyError::KillFailed)?;
}
Ok(())
}
pub fn wait(&mut self) -> Result<Option<i32>, PtyError> {
if let Some(ref mut child) = self.child {
let status = child.wait().map_err(|e| PtyError::KillFailed(e.to_string()))?;
Ok(Some(status.exit_code() as i32))
} else {
Err(PtyError::NotRunning)
}
}
pub fn size(&self) -> PtySize {
self.size
}
pub fn pid(&self) -> Option<u32> {
self.pid
}
}
#[derive(Debug, Clone)]
pub struct PtyOutput {
pub data: Vec<u8>,
pub size: PtySize,
}
impl PtyOutput {
pub fn new(data: Vec<u8>, size: PtySize) -> Self {
Self { data, size }
}
}
pub struct PtyReader {
buffer: Vec<u8>,
position: usize,
}
impl PtyReader {
pub fn new() -> Self {
Self { buffer: Vec::with_capacity(4096), position: 0 }
}
pub fn read_from(&mut self, reader: &mut dyn Read) -> Result<usize, PtyError> {
let mut temp = [0u8; 4096];
let n = reader.read(&mut temp).map_err(|e| PtyError::ReadFailed(e.to_string()))?;
if n > 0 {
self.buffer.extend_from_slice(&temp[..n]);
}
Ok(n)
}
pub fn read_line(&mut self) -> Option<String> {
if let Some(pos) = self.buffer[self.position..].iter().position(|&b| b == b'\n') {
let end = self.position + pos + 1;
let line = String::from_utf8_lossy(&self.buffer[self.position..end]).to_string();
self.position = end;
Some(line)
} else {
None
}
}
pub fn read_bytes(&mut self, count: usize) -> Vec<u8> {
let end = std::cmp::min(self.position + count, self.buffer.len());
let data = self.buffer[self.position..end].to_vec();
self.position = end;
data
}
pub fn available(&self) -> usize {
self.buffer.len() - self.position
}
pub fn is_empty(&self) -> bool {
self.available() == 0
}
pub fn clear(&mut self) {
self.buffer.clear();
self.position = 0;
}
pub fn compact(&mut self) {
if self.position > 0 {
self.buffer.drain(0..self.position);
self.position = 0;
}
}
}
impl Default for PtyReader {
fn default() -> Self {
Self::new()
}
}
pub struct PtyWriter {
buffer: Vec<u8>,
flushed: bool,
}
impl PtyWriter {
pub fn new() -> Self {
Self { buffer: Vec::with_capacity(4096), flushed: true }
}
pub fn write(&mut self, data: &[u8]) {
self.buffer.extend_from_slice(data);
self.flushed = false;
}
pub fn write_str(&mut self, s: &str) {
self.write(s.as_bytes());
}
pub fn write_byte(&mut self, b: u8) {
self.buffer.push(b);
self.flushed = false;
}
pub fn flush(&mut self, writer: &mut dyn Write) -> Result<(), PtyError> {
if !self.flushed && !self.buffer.is_empty() {
writer.write_all(&self.buffer).map_err(|e| PtyError::WriteFailed(e.to_string()))?;
writer.flush().map_err(|e| PtyError::WriteFailed(e.to_string()))?;
self.buffer.clear();
self.flushed = true;
}
Ok(())
}
pub fn pending(&self) -> usize {
self.buffer.len()
}
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
pub fn clear(&mut self) {
self.buffer.clear();
self.flushed = true;
}
}
impl Default for PtyWriter {
fn default() -> Self {
Self::new()
}
}
pub struct PtyRuntime {
process: Option<PtyProcess>,
}
impl Default for PtyRuntime {
fn default() -> Self {
Self::new()
}
}
impl PtyRuntime {
pub fn new() -> Self {
Self { process: None }
}
pub fn spawn(&mut self, config: PtyConfig) -> Result<(), PtyError> {
let process = PtyProcess::spawn(config)?;
self.process = Some(process);
Ok(())
}
pub fn read(&mut self, buf: &mut [u8]) -> Result<usize, PtyError> {
if let Some(ref mut process) = self.process { process.read(buf) } else { Err(PtyError::NotRunning) }
}
pub fn write(&mut self, data: &[u8]) -> Result<usize, PtyError> {
if let Some(ref mut process) = self.process { process.write(data) } else { Err(PtyError::NotRunning) }
}
pub fn resize(&mut self, size: PtySize) -> Result<(), PtyError> {
if let Some(ref mut process) = self.process { process.resize(size) } else { Err(PtyError::NotRunning) }
}
pub fn is_running(&mut self) -> bool {
self.process.as_mut().is_some_and(|p| p.is_running())
}
pub fn exit_status(&mut self) -> Option<i32> {
self.process.as_mut().and_then(|p| p.exit_status())
}
pub fn kill(&mut self) -> Result<(), PtyError> {
if let Some(ref mut process) = self.process { process.kill() } else { Err(PtyError::NotRunning) }
}
pub fn wait(&mut self) -> Result<Option<i32>, PtyError> {
if let Some(ref mut process) = self.process { process.wait() } else { Err(PtyError::NotRunning) }
}
}
#[derive(Debug, Clone)]
pub enum PtyError {
SpawnFailed(String),
ResizeFailed(String),
ReadFailed(String),
WriteFailed(String),
KillFailed(String),
NotRunning,
ProcessExited(i32),
}
impl std::fmt::Display for PtyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SpawnFailed(msg) => write!(f, "Failed to spawn PTY: {}", msg),
Self::ResizeFailed(msg) => write!(f, "Failed to resize PTY: {}", msg),
Self::ReadFailed(msg) => write!(f, "Failed to read from PTY: {}", msg),
Self::WriteFailed(msg) => write!(f, "Failed to write to PTY: {}", msg),
Self::KillFailed(msg) => write!(f, "Failed to kill PTY process: {}", msg),
Self::NotRunning => write!(f, "PTY is not running"),
Self::ProcessExited(code) => write!(f, "PTY process exited with code: {}", code),
}
}
}
impl std::error::Error for PtyError {}
impl From<std::io::Error> for PtyError {
fn from(err: std::io::Error) -> Self {
Self::SpawnFailed(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pty_config_default() {
let cfg = PtyConfig::default();
assert_eq!(cfg.program, "bash");
assert!(cfg.args.is_empty());
assert!(cfg.env.is_empty());
assert!(cfg.working_directory.is_none());
assert_eq!(cfg.size.cols, 80);
assert_eq!(cfg.size.rows, 24);
}
#[test]
fn pty_config_builder() {
let cfg = PtyConfig::new("zsh")
.with_args(vec!["-l".into()])
.with_env(vec![("KEY".into(), "VAL".into())])
.with_working_directory("/tmp".into())
.with_size(PtySize::new(120, 40));
assert_eq!(cfg.program, "zsh");
assert_eq!(cfg.args, vec!["-l"]);
assert_eq!(cfg.env, vec![("KEY".to_string(), "VAL".to_string())]);
assert_eq!(cfg.working_directory, Some("/tmp".into()));
assert_eq!(cfg.size.cols, 120);
assert_eq!(cfg.size.rows, 40);
}
#[test]
fn pty_config_new_custom() {
let cfg = PtyConfig::new("fish");
assert_eq!(cfg.program, "fish");
assert!(cfg.working_directory.is_none());
}
#[test]
fn pty_size_default() {
let s = PtySize::default();
assert_eq!(s.cols, 80);
assert_eq!(s.rows, 24);
assert_eq!(s.pixel_width, 0);
assert_eq!(s.pixel_height, 0);
}
#[test]
fn pty_size_new() {
let s = PtySize::new(100, 30);
assert_eq!(s.cols, 100);
assert_eq!(s.rows, 30);
}
#[test]
fn pty_size_with_pixel_size() {
let s = PtySize::new(80, 24).with_pixel_size(800, 600);
assert_eq!(s.pixel_width, 800);
assert_eq!(s.pixel_height, 600);
}
#[test]
fn pty_reader_new_is_empty() {
let r = PtyReader::new();
assert!(r.is_empty());
assert_eq!(r.available(), 0);
}
#[test]
fn pty_reader_read_line() {
let mut r = PtyReader::new();
r.buffer = b"hello\nworld\n".to_vec();
assert_eq!(r.read_line(), Some("hello\n".into()));
assert_eq!(r.read_line(), Some("world\n".into()));
assert!(r.is_empty());
assert_eq!(r.available(), 0);
}
#[test]
fn pty_reader_read_line_partial() {
let mut r = PtyReader::new();
r.buffer = b"no newline here".to_vec();
assert_eq!(r.read_line(), None);
assert_eq!(r.available(), 15);
}
#[test]
fn pty_reader_read_bytes() {
let mut r = PtyReader::new();
r.buffer = b"abcdefgh".to_vec();
let data = r.read_bytes(3);
assert_eq!(data, b"abc");
assert_eq!(r.available(), 5);
}
#[test]
fn pty_reader_read_bytes_beyond() {
let mut r = PtyReader::new();
r.buffer = b"abc".to_vec();
let data = r.read_bytes(100);
assert_eq!(data, b"abc");
assert!(r.is_empty());
}
#[test]
fn pty_reader_clear() {
let mut r = PtyReader::new();
r.buffer = b"data".to_vec();
r.position = 2;
r.clear();
assert!(r.is_empty());
}
#[test]
fn pty_reader_compact() {
let mut r = PtyReader::new();
r.buffer = b"abcdef".to_vec();
r.position = 3;
r.compact();
assert_eq!(r.buffer, b"def");
assert_eq!(r.position, 0);
}
#[test]
fn pty_reader_compact_when_empty() {
let mut r = PtyReader::new();
r.buffer = b"".to_vec();
r.position = 0;
r.compact();
assert!(r.buffer.is_empty());
}
#[test]
fn pty_writer_new() {
let w = PtyWriter::new();
assert!(w.is_empty());
assert_eq!(w.pending(), 0);
}
#[test]
fn pty_writer_write_and_pending() {
let mut w = PtyWriter::new();
w.write(b"hello");
assert_eq!(w.pending(), 5);
assert!(!w.is_empty());
}
#[test]
fn pty_writer_write_str() {
let mut w = PtyWriter::new();
w.write_str("world");
assert_eq!(w.pending(), 5);
}
#[test]
fn pty_writer_write_byte() {
let mut w = PtyWriter::new();
w.write_byte(b'A');
assert_eq!(w.pending(), 1);
}
#[test]
fn pty_writer_clear() {
let mut w = PtyWriter::new();
w.write(b"data");
w.clear();
assert!(w.is_empty());
assert!(w.flushed);
}
#[test]
fn pty_error_display() {
assert_eq!(format!("{}", PtyError::SpawnFailed("bad".into())), "Failed to spawn PTY: bad");
assert_eq!(format!("{}", PtyError::ReadFailed("eof".into())), "Failed to read from PTY: eof");
assert_eq!(format!("{}", PtyError::WriteFailed("full".into())), "Failed to write to PTY: full");
assert_eq!(format!("{}", PtyError::KillFailed("sig".into())), "Failed to kill PTY process: sig");
assert_eq!(format!("{}", PtyError::NotRunning), "PTY is not running");
assert_eq!(format!("{}", PtyError::ProcessExited(1)), "PTY process exited with code: 1");
assert_eq!(format!("{}", PtyError::ResizeFailed("dim".into())), "Failed to resize PTY: dim");
}
#[test]
fn pty_error_impl_error() {
use std::error::Error;
let err = PtyError::NotRunning;
assert!(err.source().is_none());
}
#[test]
fn pty_from_io_error() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let pty: PtyError = io.into();
assert!(format!("{}", pty).contains("Failed to spawn PTY"));
}
#[test]
fn pty_runtime_new_not_running() {
let mut rt = PtyRuntime::new();
assert!(!rt.is_running());
assert!(rt.read(&mut [0; 4]).is_err());
assert!(rt.write(b"x").is_err());
assert!(rt.kill().is_err());
assert!(rt.wait().is_err());
assert!(rt.resize(PtySize::default()).is_err());
}
}