1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6pub const MAX_DIMENSION: u16 = 512;
8pub const MAX_CAPTURE_CELLS: usize = 16_384;
10pub const MAX_INPUT_BYTES: usize = 65_536;
12pub const DEFAULT_RAW_CAPACITY: usize = 256 * 1024;
14
15#[derive(Clone, Debug)]
17pub struct SessionOptions {
18 pub argv: Vec<String>,
20 pub rows: u16,
22 pub cols: u16,
24 pub cwd: Option<PathBuf>,
26 pub env: BTreeMap<String, String>,
28 pub env_remove: Vec<String>,
30 pub raw_capacity: usize,
32}
33
34impl SessionOptions {
35 pub(crate) fn validate(&self) -> Result<(), TerminalError> {
36 if self.argv.first().is_none_or(String::is_empty) {
37 return Err(TerminalError::InvalidArgument(
38 "argv must start with a non-empty executable".to_string(),
39 ));
40 }
41 validate_dimensions(self.rows, self.cols)?;
42 if self.raw_capacity == 0 {
43 return Err(TerminalError::InvalidArgument(
44 "raw_capacity must be greater than zero".to_string(),
45 ));
46 }
47 if self.env_remove.iter().any(String::is_empty) {
48 return Err(TerminalError::InvalidArgument(
49 "env_remove entries must not be empty".to_string(),
50 ));
51 }
52 Ok(())
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
58#[serde(tag = "state", rename_all = "snake_case")]
59pub enum ProcessStatus {
60 Running,
62 Exited {
64 code: Option<u32>,
66 signal: Option<String>,
68 },
69 Failed {
71 message: String,
73 },
74}
75
76impl ProcessStatus {
77 pub fn finished(&self) -> bool {
79 !matches!(self, Self::Running)
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct CellRegion {
87 pub row: u16,
89 pub column: u16,
91 pub rows: u16,
93 pub columns: u16,
95}
96
97impl CellRegion {
98 pub(crate) fn validate(self, screen_rows: u16, screen_cols: u16) -> Result<(), TerminalError> {
99 if self.rows == 0 || self.columns == 0 {
100 return Err(TerminalError::InvalidArgument(
101 "cell region rows and columns must be greater than zero".to_string(),
102 ));
103 }
104 let end_row = self
105 .row
106 .checked_add(self.rows)
107 .ok_or_else(|| TerminalError::InvalidArgument("cell row range overflowed".into()))?;
108 let end_col = self
109 .column
110 .checked_add(self.columns)
111 .ok_or_else(|| TerminalError::InvalidArgument("cell column range overflowed".into()))?;
112 if end_row > screen_rows || end_col > screen_cols {
113 return Err(TerminalError::InvalidArgument(format!(
114 "cell region ({}, {}) {}x{} exceeds screen {}x{}",
115 self.row, self.column, self.rows, self.columns, screen_rows, screen_cols
116 )));
117 }
118 let count = usize::from(self.rows) * usize::from(self.columns);
119 if count > MAX_CAPTURE_CELLS {
120 return Err(TerminalError::InvalidArgument(format!(
121 "cell region contains {count} cells; maximum is {MAX_CAPTURE_CELLS}"
122 )));
123 }
124 Ok(())
125 }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum TerminalColor {
132 Default,
134 Indexed {
136 index: u8,
138 },
139 Rgb {
141 red: u8,
143 green: u8,
145 blue: u8,
147 },
148}
149
150impl From<vt100::Color> for TerminalColor {
151 fn from(value: vt100::Color) -> Self {
152 match value {
153 vt100::Color::Default => Self::Default,
154 vt100::Color::Idx(index) => Self::Indexed { index },
155 vt100::Color::Rgb(red, green, blue) => Self::Rgb { red, green, blue },
156 }
157 }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
162pub struct CellCapture {
163 pub row: u16,
165 pub column: u16,
167 pub text: String,
169 pub foreground: TerminalColor,
171 pub background: TerminalColor,
173 pub bold: bool,
175 pub italic: bool,
177 pub underline: bool,
179 pub inverse: bool,
181 pub wide: bool,
183 pub wide_continuation: bool,
185}
186
187#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
189pub struct ScreenCapture {
190 pub schema_version: u32,
192 pub rows: u16,
194 pub columns: u16,
196 pub text_rows: Vec<String>,
198 pub cursor_row: u16,
200 pub cursor_column: u16,
202 pub cursor_visible: bool,
204 pub alternate_screen: bool,
206 pub revision: u64,
208 pub bytes_received: u64,
210 pub raw_truncated: bool,
212 pub parser_errors: usize,
214 pub reader_error: Option<String>,
216 pub status: ProcessStatus,
218 pub cells: Option<Vec<CellCapture>>,
220}
221
222#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
224pub struct WaitIdleResult {
225 pub revision: u64,
227 pub quiet_ms: u64,
229 pub status: ProcessStatus,
231}
232
233#[derive(Debug, thiserror::Error)]
235pub enum TerminalError {
236 #[error("invalid terminal-session argument: {0}")]
238 InvalidArgument(String),
239 #[error("failed to allocate pseudo-terminal: {0}")]
241 OpenPty(String),
242 #[error("failed to spawn terminal child: {0}")]
244 Spawn(String),
245 #[error("terminal I/O failed: {0}")]
247 Io(String),
248 #[error("terminal session is closed")]
250 Closed,
251 #[error("terminal session timed out after {timeout_ms}ms while waiting for {operation}")]
253 Timeout {
254 operation: &'static str,
256 timeout_ms: u64,
258 },
259 #[error("terminal session state was poisoned")]
261 Poisoned,
262}
263
264pub(crate) fn validate_dimensions(rows: u16, cols: u16) -> Result<(), TerminalError> {
265 if rows == 0 || cols == 0 {
266 return Err(TerminalError::InvalidArgument(
267 "rows and columns must be greater than zero".to_string(),
268 ));
269 }
270 if rows > MAX_DIMENSION || cols > MAX_DIMENSION {
271 return Err(TerminalError::InvalidArgument(format!(
272 "rows and columns must not exceed {MAX_DIMENSION}"
273 )));
274 }
275 Ok(())
276}