use std::process::ExitStatus;
use std::time::Duration;
use thiserror::Error;
const MAX_BUFFER_DISPLAY: usize = 500;
const CONTEXT_LINES: usize = 3;
fn format_buffer_snippet(buffer: &str) -> String {
if buffer.is_empty() {
return "(empty buffer)".to_string();
}
let buffer_len = buffer.len();
if buffer_len <= MAX_BUFFER_DISPLAY {
return format!(
"┌─ buffer ({} bytes) ──────────────────────\n│ {}\n└────────────────────────────────────────",
buffer_len,
buffer.lines().collect::<Vec<_>>().join("\n│ ")
);
}
let lines: Vec<&str> = buffer.lines().collect();
let total_lines = lines.len();
if total_lines <= CONTEXT_LINES * 2 {
return format!(
"┌─ buffer ({} bytes, {} lines) ─────────────\n│ {}\n└────────────────────────────────────────",
buffer_len,
total_lines,
lines.join("\n│ ")
);
}
let tail_lines = &lines[lines.len().saturating_sub(CONTEXT_LINES * 2)..];
let hidden = total_lines - tail_lines.len();
format!(
"┌─ buffer ({} bytes, {} lines) ─────────────\n│ ... ({} lines hidden)\n│ {}\n└────────────────────────────────────────",
buffer_len,
total_lines,
hidden,
tail_lines.join("\n│ ")
)
}
fn format_timeout_error(duration: Duration, pattern: &str, buffer: &str) -> String {
let buffer_snippet = format_buffer_snippet(buffer);
format!(
"timeout after {duration:?} waiting for pattern\n\
\n\
Pattern: '{pattern}'\n\
\n\
{buffer_snippet}\n\
\n\
Tip: The pattern was not found in the output. Check that:\n\
- The expected text actually appears in the output\n\
- The pattern is correct (regex special chars may need escaping)\n\
- The timeout duration is sufficient"
)
}
fn format_pattern_not_found_error(pattern: &str, buffer: &str) -> String {
let buffer_snippet = format_buffer_snippet(buffer);
format!(
"pattern not found before EOF\n\
\n\
Pattern: '{pattern}'\n\
\n\
{buffer_snippet}\n\
\n\
Tip: The process closed before the pattern was found."
)
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn format_process_exited_error(exit_status: &ExitStatus, buffer: &str) -> String {
let buffer_snippet = format_buffer_snippet(buffer);
format!(
"process exited unexpectedly with {exit_status:?}\n\
\n\
{buffer_snippet}"
)
}
fn format_eof_error(buffer: &str) -> String {
let buffer_snippet = format_buffer_snippet(buffer);
format!(
"end of file reached unexpectedly\n\
\n\
{buffer_snippet}"
)
}
#[derive(Debug, Error)]
pub enum ExpectError {
#[error("failed to spawn process: {0}")]
Spawn(#[from] SpawnError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{context}: {source}")]
IoWithContext {
context: String,
#[source]
source: std::io::Error,
},
#[error("{}", format_timeout_error(*duration, pattern, buffer))]
Timeout {
duration: Duration,
pattern: String,
buffer: String,
},
#[error("{}", format_pattern_not_found_error(pattern, buffer))]
PatternNotFound {
pattern: String,
buffer: String,
},
#[error("{}", format_process_exited_error(exit_status, buffer))]
ProcessExited {
exit_status: ExitStatus,
buffer: String,
},
#[error("{}", format_eof_error(buffer))]
Eof {
buffer: String,
},
#[error("invalid pattern: {message}")]
InvalidPattern {
message: String,
},
#[error("invalid regex pattern: {0}")]
Regex(#[from] regex::Error),
#[error("session is closed")]
SessionClosed,
#[error("session with id {id} not found")]
SessionNotFound {
id: usize,
},
#[error("no sessions available for operation")]
NoSessions,
#[error("multi-session error in session {session_id}: {error}")]
MultiSessionError {
session_id: usize,
error: Box<Self>,
},
#[error("session is not in interact mode")]
NotInteracting,
#[error("buffer overflow: maximum size of {max_size} bytes exceeded")]
BufferOverflow {
max_size: usize,
},
#[error("encoding error: {message}")]
Encoding {
message: String,
},
#[cfg(feature = "ssh")]
#[error("SSH error: {0}")]
Ssh(#[from] SshError),
#[error("configuration error: {message}")]
Config {
message: String,
},
#[cfg(unix)]
#[error("signal error: {message}")]
Signal {
message: String,
},
}
#[derive(Debug, Error)]
pub enum SpawnError {
#[error("command not found: {command}")]
CommandNotFound {
command: String,
},
#[error("permission denied: {path}")]
PermissionDenied {
path: String,
},
#[error("failed to allocate PTY: {reason}")]
PtyAllocation {
reason: String,
},
#[error("failed to set up terminal: {reason}")]
TerminalSetup {
reason: String,
},
#[error("invalid environment variable: {name}")]
InvalidEnv {
name: String,
},
#[error("invalid working directory: {path}")]
InvalidWorkingDir {
path: String,
},
#[error("I/O error during spawn: {0}")]
Io(#[from] std::io::Error),
#[error("invalid {kind}: {reason}")]
InvalidArgument {
kind: String,
value: String,
reason: String,
},
}
#[cfg(feature = "ssh")]
#[derive(Debug, Error)]
pub enum SshError {
#[error("failed to connect to {host}:{port}: {reason}")]
Connection {
host: String,
port: u16,
reason: String,
},
#[error("authentication failed for user '{user}': {reason}")]
Authentication {
user: String,
reason: String,
},
#[error("host key verification failed for {host}: {reason}")]
HostKeyVerification {
host: String,
reason: String,
},
#[error("SSH channel error: {reason}")]
Channel {
reason: String,
},
#[error("SSH session error: {reason}")]
Session {
reason: String,
},
#[error("SSH operation timed out after {duration:?}")]
Timeout {
duration: Duration,
},
}
pub type Result<T> = std::result::Result<T, ExpectError>;
impl ExpectError {
pub fn timeout(
duration: Duration,
pattern: impl Into<String>,
buffer: impl Into<String>,
) -> Self {
Self::Timeout {
duration,
pattern: pattern.into(),
buffer: buffer.into(),
}
}
pub fn pattern_not_found(pattern: impl Into<String>, buffer: impl Into<String>) -> Self {
Self::PatternNotFound {
pattern: pattern.into(),
buffer: buffer.into(),
}
}
pub fn process_exited(exit_status: ExitStatus, buffer: impl Into<String>) -> Self {
Self::ProcessExited {
exit_status,
buffer: buffer.into(),
}
}
pub fn eof(buffer: impl Into<String>) -> Self {
Self::Eof {
buffer: buffer.into(),
}
}
pub fn invalid_pattern(message: impl Into<String>) -> Self {
Self::InvalidPattern {
message: message.into(),
}
}
#[must_use]
pub const fn buffer_overflow(max_size: usize) -> Self {
Self::BufferOverflow { max_size }
}
pub fn encoding(message: impl Into<String>) -> Self {
Self::Encoding {
message: message.into(),
}
}
pub fn config(message: impl Into<String>) -> Self {
Self::Config {
message: message.into(),
}
}
pub fn io_context(context: impl Into<String>, source: std::io::Error) -> Self {
Self::IoWithContext {
context: context.into(),
source,
}
}
pub fn with_io_context<T>(result: std::io::Result<T>, context: impl Into<String>) -> Result<T> {
result.map_err(|e| Self::io_context(context, e))
}
#[must_use]
pub const fn is_timeout(&self) -> bool {
matches!(self, Self::Timeout { .. })
}
#[must_use]
pub const fn is_eof(&self) -> bool {
matches!(self, Self::Eof { .. } | Self::ProcessExited { .. })
}
#[must_use]
pub fn buffer(&self) -> Option<&str> {
match self {
Self::Timeout { buffer, .. }
| Self::PatternNotFound { buffer, .. }
| Self::ProcessExited { buffer, .. }
| Self::Eof { buffer, .. } => Some(buffer),
_ => None,
}
}
}
impl SpawnError {
pub fn command_not_found(command: impl Into<String>) -> Self {
Self::CommandNotFound {
command: command.into(),
}
}
pub fn permission_denied(path: impl Into<String>) -> Self {
Self::PermissionDenied { path: path.into() }
}
pub fn pty_allocation(reason: impl Into<String>) -> Self {
Self::PtyAllocation {
reason: reason.into(),
}
}
pub fn terminal_setup(reason: impl Into<String>) -> Self {
Self::TerminalSetup {
reason: reason.into(),
}
}
pub fn invalid_env(name: impl Into<String>) -> Self {
Self::InvalidEnv { name: name.into() }
}
pub fn invalid_working_dir(path: impl Into<String>) -> Self {
Self::InvalidWorkingDir { path: path.into() }
}
}
#[cfg(feature = "ssh")]
impl SshError {
pub fn connection(host: impl Into<String>, port: u16, reason: impl Into<String>) -> Self {
Self::Connection {
host: host.into(),
port,
reason: reason.into(),
}
}
pub fn authentication(user: impl Into<String>, reason: impl Into<String>) -> Self {
Self::Authentication {
user: user.into(),
reason: reason.into(),
}
}
pub fn host_key_verification(host: impl Into<String>, reason: impl Into<String>) -> Self {
Self::HostKeyVerification {
host: host.into(),
reason: reason.into(),
}
}
pub fn channel(reason: impl Into<String>) -> Self {
Self::Channel {
reason: reason.into(),
}
}
pub fn session(reason: impl Into<String>) -> Self {
Self::Session {
reason: reason.into(),
}
}
#[must_use]
pub const fn timeout(duration: Duration) -> Self {
Self::Timeout { duration }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display() {
let err = ExpectError::timeout(
Duration::from_secs(5),
"password:",
"Enter username: admin\n",
);
let msg = err.to_string();
assert!(msg.contains("timeout"));
assert!(msg.contains("password:"));
assert!(msg.contains("admin"));
assert!(msg.contains("Pattern:"));
assert!(msg.contains("buffer"));
}
#[test]
fn error_display_with_tips() {
let err = ExpectError::timeout(Duration::from_secs(5), "password:", "output here\n");
let msg = err.to_string();
assert!(msg.contains("Tip:"));
}
#[test]
fn error_display_empty_buffer() {
let err = ExpectError::eof("");
let msg = err.to_string();
assert!(msg.contains("empty buffer"));
}
#[test]
fn error_display_large_buffer_truncation() {
let large_buffer: String = (0..50).fold(String::new(), |mut acc, i| {
use std::fmt::Write;
let _ = writeln!(acc, "Line {i}: Some content here");
acc
});
let err = ExpectError::timeout(Duration::from_secs(1), "pattern", &large_buffer);
let msg = err.to_string();
assert!(msg.contains("lines hidden"));
assert!(msg.contains("lines)"));
}
#[test]
fn error_is_timeout() {
let timeout = ExpectError::timeout(Duration::from_secs(1), "test", "buffer");
assert!(timeout.is_timeout());
let eof = ExpectError::eof("buffer");
assert!(!eof.is_timeout());
}
#[test]
fn error_buffer() {
let err = ExpectError::timeout(Duration::from_secs(1), "test", "the buffer");
assert_eq!(err.buffer(), Some("the buffer"));
let io_err = ExpectError::Io(std::io::Error::other("test"));
assert!(io_err.buffer().is_none());
}
#[test]
fn spawn_error_display() {
let err = SpawnError::command_not_found("/usr/bin/nonexistent");
assert!(err.to_string().contains("nonexistent"));
}
#[test]
fn format_buffer_snippet_empty() {
let result = format_buffer_snippet("");
assert_eq!(result, "(empty buffer)");
}
#[test]
fn format_buffer_snippet_small() {
let result = format_buffer_snippet("hello\nworld");
assert!(result.contains("hello"));
assert!(result.contains("world"));
assert!(result.contains("bytes"));
}
#[test]
fn pattern_not_found_error() {
let err = ExpectError::pattern_not_found("prompt>", "some output");
let msg = err.to_string();
assert!(msg.contains("prompt>"));
assert!(msg.contains("some output"));
assert!(msg.contains("EOF"));
}
#[test]
fn eof_error() {
let err = ExpectError::eof("final output");
let msg = err.to_string();
assert!(msg.contains("end of file"));
assert!(msg.contains("final output"));
}
#[test]
fn io_with_context_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = ExpectError::io_context("reading config file", io_err);
let msg = err.to_string();
assert!(msg.contains("reading config file"));
assert!(msg.contains("file not found"));
}
#[test]
fn with_io_context_helper() {
let result: std::io::Result<()> = Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"access denied",
));
let err = ExpectError::with_io_context(result, "writing to log file").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("writing to log file"));
assert!(msg.contains("access denied"));
}
#[test]
fn with_io_context_success() {
let result: std::io::Result<i32> = Ok(42);
let value = ExpectError::with_io_context(result, "some operation").unwrap();
assert_eq!(value, 42);
}
}