Skip to main content

harn_terminal/
types.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6/// Maximum supported rows or columns for one terminal.
7pub const MAX_DIMENSION: u16 = 512;
8/// Maximum number of detailed cells returned by one capture.
9pub const MAX_CAPTURE_CELLS: usize = 16_384;
10/// Maximum encoded input accepted by one send operation.
11pub const MAX_INPUT_BYTES: usize = 65_536;
12/// Default bounded raw-output history retained for diagnostics.
13pub const DEFAULT_RAW_CAPACITY: usize = 256 * 1024;
14
15/// Options used to start one terminal session.
16#[derive(Clone, Debug)]
17pub struct SessionOptions {
18    /// Program and arguments. The first item is the executable.
19    pub argv: Vec<String>,
20    /// Initial number of terminal rows.
21    pub rows: u16,
22    /// Initial number of terminal columns.
23    pub cols: u16,
24    /// Child working directory.
25    pub cwd: Option<PathBuf>,
26    /// Explicit environment overlay. The caller owns inherited-env policy.
27    pub env: BTreeMap<String, String>,
28    /// Inherited environment keys to remove before applying `env`.
29    pub env_remove: Vec<String>,
30    /// Maximum raw-output bytes retained in memory.
31    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/// Current child-process state.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
58#[serde(tag = "state", rename_all = "snake_case")]
59pub enum ProcessStatus {
60    /// The child is still running.
61    Running,
62    /// The child exited or was terminated by a signal.
63    Exited {
64        /// Numeric process exit code, when available.
65        code: Option<u32>,
66        /// Platform signal name, when available.
67        signal: Option<String>,
68    },
69    /// Waiting for the child failed.
70    Failed {
71        /// Stable diagnostic explaining the wait failure.
72        message: String,
73    },
74}
75
76impl ProcessStatus {
77    /// Whether the process has reached a terminal state.
78    pub fn finished(&self) -> bool {
79        !matches!(self, Self::Running)
80    }
81}
82
83/// Optional bounded rectangle for detailed cell capture.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct CellRegion {
87    /// Zero-based first row.
88    pub row: u16,
89    /// Zero-based first column.
90    pub column: u16,
91    /// Number of rows to capture.
92    pub rows: u16,
93    /// Number of columns to capture.
94    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/// Terminal color attached to a captured cell.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum TerminalColor {
132    /// Terminal default color.
133    Default,
134    /// Indexed terminal palette color.
135    Indexed {
136        /// Palette index.
137        index: u8,
138    },
139    /// Explicit RGB color.
140    Rgb {
141        /// Red channel.
142        red: u8,
143        /// Green channel.
144        green: u8,
145        /// Blue channel.
146        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/// One typed cell from a requested capture rectangle.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
162pub struct CellCapture {
163    /// Zero-based row.
164    pub row: u16,
165    /// Zero-based column.
166    pub column: u16,
167    /// Cell text, including combining characters when present.
168    pub text: String,
169    /// Foreground color.
170    pub foreground: TerminalColor,
171    /// Background color.
172    pub background: TerminalColor,
173    /// Bold attribute.
174    pub bold: bool,
175    /// Italic attribute.
176    pub italic: bool,
177    /// Underline attribute.
178    pub underline: bool,
179    /// Inverse-video attribute.
180    pub inverse: bool,
181    /// Whether this cell contains a wide character.
182    pub wide: bool,
183    /// Whether this cell is the continuation half of a wide character.
184    pub wide_continuation: bool,
185}
186
187/// Typed snapshot of the current visible screen and child state.
188#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
189pub struct ScreenCapture {
190    /// Contract version for forwards-compatible consumers.
191    pub schema_version: u32,
192    /// Screen row count.
193    pub rows: u16,
194    /// Screen column count.
195    pub columns: u16,
196    /// Visible rows with terminal escapes resolved.
197    pub text_rows: Vec<String>,
198    /// Cursor row.
199    pub cursor_row: u16,
200    /// Cursor column.
201    pub cursor_column: u16,
202    /// Whether the cursor is visible.
203    pub cursor_visible: bool,
204    /// Whether the alternate screen buffer is active.
205    pub alternate_screen: bool,
206    /// Monotonic state revision.
207    pub revision: u64,
208    /// Total bytes received from the child.
209    pub bytes_received: u64,
210    /// Whether the bounded raw history discarded older bytes.
211    pub raw_truncated: bool,
212    /// VT parser error count.
213    pub parser_errors: usize,
214    /// Reader failure, if the PTY reader ended with an error.
215    pub reader_error: Option<String>,
216    /// Child process status.
217    pub status: ProcessStatus,
218    /// Detailed cells when a region was requested.
219    pub cells: Option<Vec<CellCapture>>,
220}
221
222/// Result returned once a session has been quiet for the requested interval.
223#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
224pub struct WaitIdleResult {
225    /// Revision observed at the idle boundary.
226    pub revision: u64,
227    /// Quiet duration requested by the caller.
228    pub quiet_ms: u64,
229    /// Child status at the idle boundary.
230    pub status: ProcessStatus,
231}
232
233/// Errors returned by terminal-session operations.
234#[derive(Debug, thiserror::Error)]
235pub enum TerminalError {
236    /// A caller-provided argument is invalid.
237    #[error("invalid terminal-session argument: {0}")]
238    InvalidArgument(String),
239    /// The platform could not allocate a PTY.
240    #[error("failed to allocate pseudo-terminal: {0}")]
241    OpenPty(String),
242    /// The child could not be spawned.
243    #[error("failed to spawn terminal child: {0}")]
244    Spawn(String),
245    /// Terminal input or resize I/O failed.
246    #[error("terminal I/O failed: {0}")]
247    Io(String),
248    /// A session operation was attempted after its terminal handles closed.
249    #[error("terminal session is closed")]
250    Closed,
251    /// A bounded wait expired.
252    #[error("terminal session timed out after {timeout_ms}ms while waiting for {operation}")]
253    Timeout {
254        /// Wait operation label.
255        operation: &'static str,
256        /// Configured timeout in milliseconds.
257        timeout_ms: u64,
258    },
259    /// Internal synchronized state was poisoned by a panic.
260    #[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}