Skip to main content

rust_expect/
error.rs

1//! Error types for rust-expect.
2//!
3//! This module defines all error types used throughout the library.
4//! Errors are designed to be informative, providing context about what went wrong
5//! and including relevant data for debugging (e.g., buffer contents on timeout).
6
7use std::process::ExitStatus;
8use std::time::Duration;
9
10use thiserror::Error;
11
12/// Maximum length of buffer content to display in error messages.
13const MAX_BUFFER_DISPLAY: usize = 500;
14
15/// Context lines to show before/after truncation point.
16const CONTEXT_LINES: usize = 3;
17
18/// Format buffer content for display, truncating if necessary.
19fn format_buffer_snippet(buffer: &str) -> String {
20    if buffer.is_empty() {
21        return "(empty buffer)".to_string();
22    }
23
24    let buffer_len = buffer.len();
25
26    if buffer_len <= MAX_BUFFER_DISPLAY {
27        // Small buffer, show everything with visual markers
28        return format!(
29            "┌─ buffer ({} bytes) ──────────────────────\n│ {}\n└────────────────────────────────────────",
30            buffer_len,
31            buffer.lines().collect::<Vec<_>>().join("\n│ ")
32        );
33    }
34
35    // Large buffer - show tail with context
36    let lines: Vec<&str> = buffer.lines().collect();
37    let total_lines = lines.len();
38
39    if total_lines <= CONTEXT_LINES * 2 {
40        // Few lines, show all
41        return format!(
42            "┌─ buffer ({} bytes, {} lines) ─────────────\n│ {}\n└────────────────────────────────────────",
43            buffer_len,
44            total_lines,
45            lines.join("\n│ ")
46        );
47    }
48
49    // Show last N lines with truncation indicator
50    let tail_lines = &lines[lines.len().saturating_sub(CONTEXT_LINES * 2)..];
51    let hidden = total_lines - tail_lines.len();
52
53    format!(
54        "┌─ buffer ({} bytes, {} lines) ─────────────\n│ ... ({} lines hidden)\n│ {}\n└────────────────────────────────────────",
55        buffer_len,
56        total_lines,
57        hidden,
58        tail_lines.join("\n│ ")
59    )
60}
61
62/// Format a timeout error message with enhanced context.
63fn format_timeout_error(duration: Duration, pattern: &str, buffer: &str) -> String {
64    let buffer_snippet = format_buffer_snippet(buffer);
65
66    format!(
67        "timeout after {duration:?} waiting for pattern\n\
68         \n\
69         Pattern: '{pattern}'\n\
70         \n\
71         {buffer_snippet}\n\
72         \n\
73         Tip: The pattern was not found in the output. Check that:\n\
74         - The expected text actually appears in the output\n\
75         - The pattern is correct (regex special chars may need escaping)\n\
76         - The timeout duration is sufficient"
77    )
78}
79
80/// Format a pattern not found error message.
81fn format_pattern_not_found_error(pattern: &str, buffer: &str) -> String {
82    let buffer_snippet = format_buffer_snippet(buffer);
83
84    format!(
85        "pattern not found before EOF\n\
86         \n\
87         Pattern: '{pattern}'\n\
88         \n\
89         {buffer_snippet}\n\
90         \n\
91         Tip: The process closed before the pattern was found."
92    )
93}
94
95/// Format a process exited error message.
96#[allow(clippy::trivially_copy_pass_by_ref)]
97fn format_process_exited_error(exit_status: &ExitStatus, buffer: &str) -> String {
98    let buffer_snippet = format_buffer_snippet(buffer);
99
100    format!(
101        "process exited unexpectedly with {exit_status:?}\n\
102         \n\
103         {buffer_snippet}"
104    )
105}
106
107/// Format an EOF error message.
108fn format_eof_error(buffer: &str) -> String {
109    let buffer_snippet = format_buffer_snippet(buffer);
110
111    format!(
112        "end of file reached unexpectedly\n\
113         \n\
114         {buffer_snippet}"
115    )
116}
117
118/// The main error type for rust-expect operations.
119#[derive(Debug, Error)]
120#[non_exhaustive]
121pub enum ExpectError {
122    /// A screen-aware operation was called without a screen attached.
123    ///
124    /// Indicates a programmer error rather than a runtime miss: the caller
125    /// invoked `expect_screen_contains`, `wait_screen_not_contains`, or
126    /// `wait_screen_stable` without first calling `Session::attach_screen`.
127    #[error("no screen is attached to this session — call Session::attach_screen() first")]
128    ScreenNotAttached,
129
130    /// Caller-supplied input was rejected before any I/O happened.
131    ///
132    /// Used by APIs that validate their input — for example, `send_paste`
133    /// refuses payloads containing the bracketed-paste end marker because
134    /// they would let the receiver drop out of paste mode mid-stream. The
135    /// `reason` is a short human-readable explanation suitable for surfacing
136    /// in a test failure or log line.
137    #[error("invalid input to {api}: {reason}")]
138    InvalidInput {
139        /// Name of the API that rejected the input (e.g. `"send_paste"`).
140        api: String,
141        /// Human-readable explanation of why the input was rejected.
142        reason: String,
143    },
144    /// Failed to spawn a process.
145    #[error("failed to spawn process: {0}")]
146    Spawn(#[from] SpawnError),
147
148    /// An I/O error occurred.
149    #[error("I/O error: {0}")]
150    Io(#[from] std::io::Error),
151
152    /// An I/O error occurred with additional context.
153    #[error("{context}: {source}")]
154    IoWithContext {
155        /// What operation was being performed.
156        context: String,
157        /// The underlying I/O error.
158        #[source]
159        source: std::io::Error,
160    },
161
162    /// Timeout waiting for pattern match.
163    #[error("{}", format_timeout_error(*duration, pattern, buffer))]
164    Timeout {
165        /// The timeout duration that elapsed.
166        duration: Duration,
167        /// The pattern that was being searched for.
168        pattern: String,
169        /// Buffer contents at the time of timeout.
170        buffer: String,
171    },
172
173    /// Pattern was not found before EOF.
174    #[error("{}", format_pattern_not_found_error(pattern, buffer))]
175    PatternNotFound {
176        /// The pattern that was being searched for.
177        pattern: String,
178        /// Buffer contents when EOF was reached.
179        buffer: String,
180    },
181
182    /// Process exited unexpectedly.
183    #[error("{}", format_process_exited_error(exit_status, buffer))]
184    ProcessExited {
185        /// The exit status of the process.
186        exit_status: ExitStatus,
187        /// Buffer contents when process exited.
188        buffer: String,
189    },
190
191    /// End of file reached.
192    #[error("{}", format_eof_error(buffer))]
193    Eof {
194        /// Buffer contents when EOF was reached.
195        buffer: String,
196    },
197
198    /// Invalid pattern specification.
199    #[error("invalid pattern: {message}")]
200    InvalidPattern {
201        /// Description of what's wrong with the pattern.
202        message: String,
203    },
204
205    /// Invalid regex pattern.
206    #[error("invalid regex pattern: {0}")]
207    Regex(#[from] regex::Error),
208
209    /// Session is closed.
210    #[error("session is closed")]
211    SessionClosed,
212
213    /// Session not found.
214    #[error("session with id {id} not found")]
215    SessionNotFound {
216        /// The session ID that was not found.
217        id: usize,
218    },
219
220    /// No sessions available for operation.
221    #[error("no sessions available for operation")]
222    NoSessions,
223
224    /// Error in multi-session operation.
225    #[error("multi-session error in session {session_id}: {error}")]
226    MultiSessionError {
227        /// The session that encountered the error.
228        session_id: usize,
229        /// The underlying error.
230        error: Box<Self>,
231    },
232
233    /// Session is not in interact mode.
234    #[error("session is not in interact mode")]
235    NotInteracting,
236
237    /// Buffer overflow.
238    #[error("buffer overflow: maximum size of {max_size} bytes exceeded")]
239    BufferOverflow {
240        /// The maximum buffer size that was exceeded.
241        max_size: usize,
242    },
243
244    /// Encoding error.
245    #[error("encoding error: {message}")]
246    Encoding {
247        /// Description of the encoding error.
248        message: String,
249    },
250
251    /// SSH connection error.
252    #[cfg(feature = "ssh")]
253    #[error("SSH error: {0}")]
254    Ssh(#[from] SshError),
255
256    /// Configuration error.
257    #[error("configuration error: {message}")]
258    Config {
259        /// Description of the configuration error.
260        message: String,
261    },
262
263    /// Signal error (Unix only).
264    #[cfg(unix)]
265    #[error("signal error: {message}")]
266    Signal {
267        /// Description of the signal error.
268        message: String,
269    },
270}
271
272/// Errors related to process spawning.
273#[derive(Debug, Error)]
274pub enum SpawnError {
275    /// Command not found.
276    #[error("command not found: {command}")]
277    CommandNotFound {
278        /// The command that was not found.
279        command: String,
280    },
281
282    /// Permission denied.
283    #[error("permission denied: {path}")]
284    PermissionDenied {
285        /// The path that could not be accessed.
286        path: String,
287    },
288
289    /// PTY allocation failed.
290    #[error("failed to allocate PTY: {reason}")]
291    PtyAllocation {
292        /// The reason for the failure.
293        reason: String,
294    },
295
296    /// Failed to set up terminal.
297    #[error("failed to set up terminal: {reason}")]
298    TerminalSetup {
299        /// The reason for the failure.
300        reason: String,
301    },
302
303    /// Environment variable error.
304    #[error("invalid environment variable: {name}")]
305    InvalidEnv {
306        /// The name of the invalid environment variable.
307        name: String,
308    },
309
310    /// Working directory error.
311    #[error("invalid working directory: {path}")]
312    InvalidWorkingDir {
313        /// The invalid working directory path.
314        path: String,
315    },
316
317    /// General I/O error during spawn.
318    #[error("I/O error during spawn: {0}")]
319    Io(#[from] std::io::Error),
320
321    /// Invalid command or argument.
322    #[error("invalid {kind}: {reason}")]
323    InvalidArgument {
324        /// The kind of invalid input (e.g., "command", "argument").
325        kind: String,
326        /// The value that was invalid.
327        value: String,
328        /// The reason it's invalid.
329        reason: String,
330    },
331}
332
333/// Errors related to SSH connections.
334#[cfg(feature = "ssh")]
335#[derive(Debug, Error)]
336pub enum SshError {
337    /// Connection failed.
338    #[error("failed to connect to {host}:{port}: {reason}")]
339    Connection {
340        /// The host that could not be connected to.
341        host: String,
342        /// The port that was used.
343        port: u16,
344        /// The reason for the failure.
345        reason: String,
346    },
347
348    /// Authentication failed.
349    #[error("authentication failed for user '{user}': {reason}")]
350    Authentication {
351        /// The user that failed to authenticate.
352        user: String,
353        /// The reason for the failure.
354        reason: String,
355    },
356
357    /// Host key verification failed.
358    #[error("host key verification failed for {host}: {reason}")]
359    HostKeyVerification {
360        /// The host whose key verification failed.
361        host: String,
362        /// The reason for the failure.
363        reason: String,
364    },
365
366    /// Channel error.
367    #[error("SSH channel error: {reason}")]
368    Channel {
369        /// The reason for the channel error.
370        reason: String,
371    },
372
373    /// Session error.
374    #[error("SSH session error: {reason}")]
375    Session {
376        /// The reason for the session error.
377        reason: String,
378    },
379
380    /// Timeout during SSH operation.
381    #[error("SSH operation timed out after {duration:?}")]
382    Timeout {
383        /// The duration that elapsed.
384        duration: Duration,
385    },
386}
387
388/// Result type alias for rust-expect operations.
389pub type Result<T> = std::result::Result<T, ExpectError>;
390
391impl ExpectError {
392    /// Create a timeout error with the given details.
393    pub fn timeout(
394        duration: Duration,
395        pattern: impl Into<String>,
396        buffer: impl Into<String>,
397    ) -> Self {
398        Self::Timeout {
399            duration,
400            pattern: pattern.into(),
401            buffer: buffer.into(),
402        }
403    }
404
405    /// Create a pattern not found error.
406    pub fn pattern_not_found(pattern: impl Into<String>, buffer: impl Into<String>) -> Self {
407        Self::PatternNotFound {
408            pattern: pattern.into(),
409            buffer: buffer.into(),
410        }
411    }
412
413    /// Create a process exited error.
414    pub fn process_exited(exit_status: ExitStatus, buffer: impl Into<String>) -> Self {
415        Self::ProcessExited {
416            exit_status,
417            buffer: buffer.into(),
418        }
419    }
420
421    /// Create an EOF error.
422    pub fn eof(buffer: impl Into<String>) -> Self {
423        Self::Eof {
424            buffer: buffer.into(),
425        }
426    }
427
428    /// Create an invalid pattern error.
429    pub fn invalid_pattern(message: impl Into<String>) -> Self {
430        Self::InvalidPattern {
431            message: message.into(),
432        }
433    }
434
435    /// Create a buffer overflow error.
436    #[must_use]
437    pub const fn buffer_overflow(max_size: usize) -> Self {
438        Self::BufferOverflow { max_size }
439    }
440
441    /// Create an encoding error.
442    pub fn encoding(message: impl Into<String>) -> Self {
443        Self::Encoding {
444            message: message.into(),
445        }
446    }
447
448    /// Create a configuration error.
449    pub fn config(message: impl Into<String>) -> Self {
450        Self::Config {
451            message: message.into(),
452        }
453    }
454
455    /// Create an I/O error with context.
456    pub fn io_context(context: impl Into<String>, source: std::io::Error) -> Self {
457        Self::IoWithContext {
458            context: context.into(),
459            source,
460        }
461    }
462
463    /// Wrap an I/O result with context.
464    pub fn with_io_context<T>(result: std::io::Result<T>, context: impl Into<String>) -> Result<T> {
465        result.map_err(|e| Self::io_context(context, e))
466    }
467
468    /// Check if this is a timeout error.
469    #[must_use]
470    pub const fn is_timeout(&self) -> bool {
471        matches!(self, Self::Timeout { .. })
472    }
473
474    /// Check if this is an EOF error.
475    #[must_use]
476    pub const fn is_eof(&self) -> bool {
477        matches!(self, Self::Eof { .. } | Self::ProcessExited { .. })
478    }
479
480    /// Get the buffer contents if this error contains them.
481    #[must_use]
482    pub fn buffer(&self) -> Option<&str> {
483        match self {
484            Self::Timeout { buffer, .. }
485            | Self::PatternNotFound { buffer, .. }
486            | Self::ProcessExited { buffer, .. }
487            | Self::Eof { buffer, .. } => Some(buffer),
488            _ => None,
489        }
490    }
491}
492
493impl SpawnError {
494    /// Create a command not found error.
495    pub fn command_not_found(command: impl Into<String>) -> Self {
496        Self::CommandNotFound {
497            command: command.into(),
498        }
499    }
500
501    /// Create a permission denied error.
502    pub fn permission_denied(path: impl Into<String>) -> Self {
503        Self::PermissionDenied { path: path.into() }
504    }
505
506    /// Create a PTY allocation error.
507    pub fn pty_allocation(reason: impl Into<String>) -> Self {
508        Self::PtyAllocation {
509            reason: reason.into(),
510        }
511    }
512
513    /// Create a terminal setup error.
514    pub fn terminal_setup(reason: impl Into<String>) -> Self {
515        Self::TerminalSetup {
516            reason: reason.into(),
517        }
518    }
519
520    /// Create an invalid environment variable error.
521    pub fn invalid_env(name: impl Into<String>) -> Self {
522        Self::InvalidEnv { name: name.into() }
523    }
524
525    /// Create an invalid working directory error.
526    pub fn invalid_working_dir(path: impl Into<String>) -> Self {
527        Self::InvalidWorkingDir { path: path.into() }
528    }
529}
530
531#[cfg(feature = "ssh")]
532impl SshError {
533    /// Create a connection error.
534    pub fn connection(host: impl Into<String>, port: u16, reason: impl Into<String>) -> Self {
535        Self::Connection {
536            host: host.into(),
537            port,
538            reason: reason.into(),
539        }
540    }
541
542    /// Create an authentication error.
543    pub fn authentication(user: impl Into<String>, reason: impl Into<String>) -> Self {
544        Self::Authentication {
545            user: user.into(),
546            reason: reason.into(),
547        }
548    }
549
550    /// Create a host key verification error.
551    pub fn host_key_verification(host: impl Into<String>, reason: impl Into<String>) -> Self {
552        Self::HostKeyVerification {
553            host: host.into(),
554            reason: reason.into(),
555        }
556    }
557
558    /// Create a channel error.
559    pub fn channel(reason: impl Into<String>) -> Self {
560        Self::Channel {
561            reason: reason.into(),
562        }
563    }
564
565    /// Create a session error.
566    pub fn session(reason: impl Into<String>) -> Self {
567        Self::Session {
568            reason: reason.into(),
569        }
570    }
571
572    /// Create a timeout error.
573    #[must_use]
574    pub const fn timeout(duration: Duration) -> Self {
575        Self::Timeout { duration }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn error_display() {
585        let err = ExpectError::timeout(
586            Duration::from_secs(5),
587            "password:",
588            "Enter username: admin\n",
589        );
590        let msg = err.to_string();
591        assert!(msg.contains("timeout"));
592        assert!(msg.contains("password:"));
593        assert!(msg.contains("admin"));
594        // Check for enhanced formatting
595        assert!(msg.contains("Pattern:"));
596        assert!(msg.contains("buffer"));
597    }
598
599    #[test]
600    fn error_display_with_tips() {
601        let err = ExpectError::timeout(Duration::from_secs(5), "password:", "output here\n");
602        let msg = err.to_string();
603        // Check that tips are included
604        assert!(msg.contains("Tip:"));
605    }
606
607    #[test]
608    fn error_display_empty_buffer() {
609        let err = ExpectError::eof("");
610        let msg = err.to_string();
611        assert!(msg.contains("empty buffer"));
612    }
613
614    #[test]
615    fn error_display_large_buffer_truncation() {
616        // Create a large buffer (> 500 bytes, > 6 lines)
617        let large_buffer: String = (0..50).fold(String::new(), |mut acc, i| {
618            use std::fmt::Write;
619            let _ = writeln!(acc, "Line {i}: Some content here");
620            acc
621        });
622
623        let err = ExpectError::timeout(Duration::from_secs(1), "pattern", &large_buffer);
624        let msg = err.to_string();
625
626        // Should contain truncation indicator
627        assert!(msg.contains("lines hidden"));
628        // Should show line count
629        assert!(msg.contains("lines)"));
630    }
631
632    #[test]
633    fn error_is_timeout() {
634        let timeout = ExpectError::timeout(Duration::from_secs(1), "test", "buffer");
635        assert!(timeout.is_timeout());
636
637        let eof = ExpectError::eof("buffer");
638        assert!(!eof.is_timeout());
639    }
640
641    #[test]
642    fn error_buffer() {
643        let err = ExpectError::timeout(Duration::from_secs(1), "test", "the buffer");
644        assert_eq!(err.buffer(), Some("the buffer"));
645
646        let io_err = ExpectError::Io(std::io::Error::other("test"));
647        assert!(io_err.buffer().is_none());
648    }
649
650    #[test]
651    fn spawn_error_display() {
652        let err = SpawnError::command_not_found("/usr/bin/nonexistent");
653        assert!(err.to_string().contains("nonexistent"));
654    }
655
656    #[test]
657    fn format_buffer_snippet_empty() {
658        let result = format_buffer_snippet("");
659        assert_eq!(result, "(empty buffer)");
660    }
661
662    #[test]
663    fn format_buffer_snippet_small() {
664        let result = format_buffer_snippet("hello\nworld");
665        assert!(result.contains("hello"));
666        assert!(result.contains("world"));
667        assert!(result.contains("bytes"));
668    }
669
670    #[test]
671    fn pattern_not_found_error() {
672        let err = ExpectError::pattern_not_found("prompt>", "some output");
673        let msg = err.to_string();
674        assert!(msg.contains("prompt>"));
675        assert!(msg.contains("some output"));
676        assert!(msg.contains("EOF"));
677    }
678
679    #[test]
680    fn eof_error() {
681        let err = ExpectError::eof("final output");
682        let msg = err.to_string();
683        assert!(msg.contains("end of file"));
684        assert!(msg.contains("final output"));
685    }
686
687    #[test]
688    fn io_with_context_error() {
689        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
690        let err = ExpectError::io_context("reading config file", io_err);
691        let msg = err.to_string();
692        assert!(msg.contains("reading config file"));
693        assert!(msg.contains("file not found"));
694    }
695
696    #[test]
697    fn with_io_context_helper() {
698        let result: std::io::Result<()> = Err(std::io::Error::new(
699            std::io::ErrorKind::PermissionDenied,
700            "access denied",
701        ));
702        let err = ExpectError::with_io_context(result, "writing to log file").unwrap_err();
703        let msg = err.to_string();
704        assert!(msg.contains("writing to log file"));
705        assert!(msg.contains("access denied"));
706    }
707
708    #[test]
709    fn with_io_context_success() {
710        let result: std::io::Result<i32> = Ok(42);
711        let value = ExpectError::with_io_context(result, "some operation").unwrap();
712        assert_eq!(value, 42);
713    }
714}