use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const MAX_DIMENSION: u16 = 512;
pub const MAX_CAPTURE_CELLS: usize = 16_384;
pub const MAX_INPUT_BYTES: usize = 65_536;
pub const DEFAULT_RAW_CAPACITY: usize = 256 * 1024;
#[derive(Clone, Debug)]
pub struct SessionOptions {
pub argv: Vec<String>,
pub rows: u16,
pub cols: u16,
pub cwd: Option<PathBuf>,
pub env: BTreeMap<String, String>,
pub env_remove: Vec<String>,
pub raw_capacity: usize,
}
impl SessionOptions {
pub(crate) fn validate(&self) -> Result<(), TerminalError> {
if self.argv.first().is_none_or(String::is_empty) {
return Err(TerminalError::InvalidArgument(
"argv must start with a non-empty executable".to_string(),
));
}
validate_dimensions(self.rows, self.cols)?;
if self.raw_capacity == 0 {
return Err(TerminalError::InvalidArgument(
"raw_capacity must be greater than zero".to_string(),
));
}
if self.env_remove.iter().any(String::is_empty) {
return Err(TerminalError::InvalidArgument(
"env_remove entries must not be empty".to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ProcessStatus {
Running,
Exited {
code: Option<u32>,
signal: Option<String>,
},
Failed {
message: String,
},
}
impl ProcessStatus {
pub fn finished(&self) -> bool {
!matches!(self, Self::Running)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CellRegion {
pub row: u16,
pub column: u16,
pub rows: u16,
pub columns: u16,
}
impl CellRegion {
pub(crate) fn validate(self, screen_rows: u16, screen_cols: u16) -> Result<(), TerminalError> {
if self.rows == 0 || self.columns == 0 {
return Err(TerminalError::InvalidArgument(
"cell region rows and columns must be greater than zero".to_string(),
));
}
let end_row = self
.row
.checked_add(self.rows)
.ok_or_else(|| TerminalError::InvalidArgument("cell row range overflowed".into()))?;
let end_col = self
.column
.checked_add(self.columns)
.ok_or_else(|| TerminalError::InvalidArgument("cell column range overflowed".into()))?;
if end_row > screen_rows || end_col > screen_cols {
return Err(TerminalError::InvalidArgument(format!(
"cell region ({}, {}) {}x{} exceeds screen {}x{}",
self.row, self.column, self.rows, self.columns, screen_rows, screen_cols
)));
}
let count = usize::from(self.rows) * usize::from(self.columns);
if count > MAX_CAPTURE_CELLS {
return Err(TerminalError::InvalidArgument(format!(
"cell region contains {count} cells; maximum is {MAX_CAPTURE_CELLS}"
)));
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TerminalColor {
Default,
Indexed {
index: u8,
},
Rgb {
red: u8,
green: u8,
blue: u8,
},
}
impl From<vt100::Color> for TerminalColor {
fn from(value: vt100::Color) -> Self {
match value {
vt100::Color::Default => Self::Default,
vt100::Color::Idx(index) => Self::Indexed { index },
vt100::Color::Rgb(red, green, blue) => Self::Rgb { red, green, blue },
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct CellCapture {
pub row: u16,
pub column: u16,
pub text: String,
pub foreground: TerminalColor,
pub background: TerminalColor,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
pub wide: bool,
pub wide_continuation: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ScreenCapture {
pub schema_version: u32,
pub rows: u16,
pub columns: u16,
pub text_rows: Vec<String>,
pub cursor_row: u16,
pub cursor_column: u16,
pub cursor_visible: bool,
pub alternate_screen: bool,
pub revision: u64,
pub bytes_received: u64,
pub raw_truncated: bool,
pub parser_errors: usize,
pub reader_error: Option<String>,
pub status: ProcessStatus,
pub cells: Option<Vec<CellCapture>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WaitIdleResult {
pub revision: u64,
pub quiet_ms: u64,
pub status: ProcessStatus,
}
#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
#[error("invalid terminal-session argument: {0}")]
InvalidArgument(String),
#[error("failed to allocate pseudo-terminal: {0}")]
OpenPty(String),
#[error("failed to spawn terminal child: {0}")]
Spawn(String),
#[error("terminal I/O failed: {0}")]
Io(String),
#[error("terminal session is closed")]
Closed,
#[error("terminal session timed out after {timeout_ms}ms while waiting for {operation}")]
Timeout {
operation: &'static str,
timeout_ms: u64,
},
#[error("terminal session state was poisoned")]
Poisoned,
}
pub(crate) fn validate_dimensions(rows: u16, cols: u16) -> Result<(), TerminalError> {
if rows == 0 || cols == 0 {
return Err(TerminalError::InvalidArgument(
"rows and columns must be greater than zero".to_string(),
));
}
if rows > MAX_DIMENSION || cols > MAX_DIMENSION {
return Err(TerminalError::InvalidArgument(format!(
"rows and columns must not exceed {MAX_DIMENSION}"
)));
}
Ok(())
}