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)]
120pub enum ExpectError {
121    /// Failed to spawn a process.
122    #[error("failed to spawn process: {0}")]
123    Spawn(#[from] SpawnError),
124
125    /// An I/O error occurred.
126    #[error("I/O error: {0}")]
127    Io(#[from] std::io::Error),
128
129    /// An I/O error occurred with additional context.
130    #[error("{context}: {source}")]
131    IoWithContext {
132        /// What operation was being performed.
133        context: String,
134        /// The underlying I/O error.
135        #[source]
136        source: std::io::Error,
137    },
138
139    /// Timeout waiting for pattern match.
140    #[error("{}", format_timeout_error(*duration, pattern, buffer))]
141    Timeout {
142        /// The timeout duration that elapsed.
143        duration: Duration,
144        /// The pattern that was being searched for.
145        pattern: String,
146        /// Buffer contents at the time of timeout.
147        buffer: String,
148    },
149
150    /// Pattern was not found before EOF.
151    #[error("{}", format_pattern_not_found_error(pattern, buffer))]
152    PatternNotFound {
153        /// The pattern that was being searched for.
154        pattern: String,
155        /// Buffer contents when EOF was reached.
156        buffer: String,
157    },
158
159    /// Process exited unexpectedly.
160    #[error("{}", format_process_exited_error(exit_status, buffer))]
161    ProcessExited {
162        /// The exit status of the process.
163        exit_status: ExitStatus,
164        /// Buffer contents when process exited.
165        buffer: String,
166    },
167
168    /// End of file reached.
169    #[error("{}", format_eof_error(buffer))]
170    Eof {
171        /// Buffer contents when EOF was reached.
172        buffer: String,
173    },
174
175    /// Invalid pattern specification.
176    #[error("invalid pattern: {message}")]
177    InvalidPattern {
178        /// Description of what's wrong with the pattern.
179        message: String,
180    },
181
182    /// Invalid regex pattern.
183    #[error("invalid regex pattern: {0}")]
184    Regex(#[from] regex::Error),
185
186    /// Session is closed.
187    #[error("session is closed")]
188    SessionClosed,
189
190    /// Session not found.
191    #[error("session with id {id} not found")]
192    SessionNotFound {
193        /// The session ID that was not found.
194        id: usize,
195    },
196
197    /// No sessions available for operation.
198    #[error("no sessions available for operation")]
199    NoSessions,
200
201    /// Error in multi-session operation.
202    #[error("multi-session error in session {session_id}: {error}")]
203    MultiSessionError {
204        /// The session that encountered the error.
205        session_id: usize,
206        /// The underlying error.
207        error: Box<Self>,
208    },
209
210    /// Session is not in interact mode.
211    #[error("session is not in interact mode")]
212    NotInteracting,
213
214    /// Buffer overflow.
215    #[error("buffer overflow: maximum size of {max_size} bytes exceeded")]
216    BufferOverflow {
217        /// The maximum buffer size that was exceeded.
218        max_size: usize,
219    },
220
221    /// Encoding error.
222    #[error("encoding error: {message}")]
223    Encoding {
224        /// Description of the encoding error.
225        message: String,
226    },
227
228    /// SSH connection error.
229    #[cfg(feature = "ssh")]
230    #[error("SSH error: {0}")]
231    Ssh(#[from] SshError),
232
233    /// Configuration error.
234    #[error("configuration error: {message}")]
235    Config {
236        /// Description of the configuration error.
237        message: String,
238    },
239
240    /// Signal error (Unix only).
241    #[cfg(unix)]
242    #[error("signal error: {message}")]
243    Signal {
244        /// Description of the signal error.
245        message: String,
246    },
247}
248
249/// Errors related to process spawning.
250#[derive(Debug, Error)]
251pub enum SpawnError {
252    /// Command not found.
253    #[error("command not found: {command}")]
254    CommandNotFound {
255        /// The command that was not found.
256        command: String,
257    },
258
259    /// Permission denied.
260    #[error("permission denied: {path}")]
261    PermissionDenied {
262        /// The path that could not be accessed.
263        path: String,
264    },
265
266    /// PTY allocation failed.
267    #[error("failed to allocate PTY: {reason}")]
268    PtyAllocation {
269        /// The reason for the failure.
270        reason: String,
271    },
272
273    /// Failed to set up terminal.
274    #[error("failed to set up terminal: {reason}")]
275    TerminalSetup {
276        /// The reason for the failure.
277        reason: String,
278    },
279
280    /// Environment variable error.
281    #[error("invalid environment variable: {name}")]
282    InvalidEnv {
283        /// The name of the invalid environment variable.
284        name: String,
285    },
286
287    /// Working directory error.
288    #[error("invalid working directory: {path}")]
289    InvalidWorkingDir {
290        /// The invalid working directory path.
291        path: String,
292    },
293
294    /// General I/O error during spawn.
295    #[error("I/O error during spawn: {0}")]
296    Io(#[from] std::io::Error),
297
298    /// Invalid command or argument.
299    #[error("invalid {kind}: {reason}")]
300    InvalidArgument {
301        /// The kind of invalid input (e.g., "command", "argument").
302        kind: String,
303        /// The value that was invalid.
304        value: String,
305        /// The reason it's invalid.
306        reason: String,
307    },
308}
309
310/// Errors related to SSH connections.
311#[cfg(feature = "ssh")]
312#[derive(Debug, Error)]
313pub enum SshError {
314    /// Connection failed.
315    #[error("failed to connect to {host}:{port}: {reason}")]
316    Connection {
317        /// The host that could not be connected to.
318        host: String,
319        /// The port that was used.
320        port: u16,
321        /// The reason for the failure.
322        reason: String,
323    },
324
325    /// Authentication failed.
326    #[error("authentication failed for user '{user}': {reason}")]
327    Authentication {
328        /// The user that failed to authenticate.
329        user: String,
330        /// The reason for the failure.
331        reason: String,
332    },
333
334    /// Host key verification failed.
335    #[error("host key verification failed for {host}: {reason}")]
336    HostKeyVerification {
337        /// The host whose key verification failed.
338        host: String,
339        /// The reason for the failure.
340        reason: String,
341    },
342
343    /// Channel error.
344    #[error("SSH channel error: {reason}")]
345    Channel {
346        /// The reason for the channel error.
347        reason: String,
348    },
349
350    /// Session error.
351    #[error("SSH session error: {reason}")]
352    Session {
353        /// The reason for the session error.
354        reason: String,
355    },
356
357    /// Timeout during SSH operation.
358    #[error("SSH operation timed out after {duration:?}")]
359    Timeout {
360        /// The duration that elapsed.
361        duration: Duration,
362    },
363}
364
365/// Result type alias for rust-expect operations.
366pub type Result<T> = std::result::Result<T, ExpectError>;
367
368impl ExpectError {
369    /// Create a timeout error with the given details.
370    pub fn timeout(
371        duration: Duration,
372        pattern: impl Into<String>,
373        buffer: impl Into<String>,
374    ) -> Self {
375        Self::Timeout {
376            duration,
377            pattern: pattern.into(),
378            buffer: buffer.into(),
379        }
380    }
381
382    /// Create a pattern not found error.
383    pub fn pattern_not_found(pattern: impl Into<String>, buffer: impl Into<String>) -> Self {
384        Self::PatternNotFound {
385            pattern: pattern.into(),
386            buffer: buffer.into(),
387        }
388    }
389
390    /// Create a process exited error.
391    pub fn process_exited(exit_status: ExitStatus, buffer: impl Into<String>) -> Self {
392        Self::ProcessExited {
393            exit_status,
394            buffer: buffer.into(),
395        }
396    }
397
398    /// Create an EOF error.
399    pub fn eof(buffer: impl Into<String>) -> Self {
400        Self::Eof {
401            buffer: buffer.into(),
402        }
403    }
404
405    /// Create an invalid pattern error.
406    pub fn invalid_pattern(message: impl Into<String>) -> Self {
407        Self::InvalidPattern {
408            message: message.into(),
409        }
410    }
411
412    /// Create a buffer overflow error.
413    #[must_use]
414    pub const fn buffer_overflow(max_size: usize) -> Self {
415        Self::BufferOverflow { max_size }
416    }
417
418    /// Create an encoding error.
419    pub fn encoding(message: impl Into<String>) -> Self {
420        Self::Encoding {
421            message: message.into(),
422        }
423    }
424
425    /// Create a configuration error.
426    pub fn config(message: impl Into<String>) -> Self {
427        Self::Config {
428            message: message.into(),
429        }
430    }
431
432    /// Create an I/O error with context.
433    pub fn io_context(context: impl Into<String>, source: std::io::Error) -> Self {
434        Self::IoWithContext {
435            context: context.into(),
436            source,
437        }
438    }
439
440    /// Wrap an I/O result with context.
441    pub fn with_io_context<T>(result: std::io::Result<T>, context: impl Into<String>) -> Result<T> {
442        result.map_err(|e| Self::io_context(context, e))
443    }
444
445    /// Check if this is a timeout error.
446    #[must_use]
447    pub const fn is_timeout(&self) -> bool {
448        matches!(self, Self::Timeout { .. })
449    }
450
451    /// Check if this is an EOF error.
452    #[must_use]
453    pub const fn is_eof(&self) -> bool {
454        matches!(self, Self::Eof { .. } | Self::ProcessExited { .. })
455    }
456
457    /// Get the buffer contents if this error contains them.
458    #[must_use]
459    pub fn buffer(&self) -> Option<&str> {
460        match self {
461            Self::Timeout { buffer, .. }
462            | Self::PatternNotFound { buffer, .. }
463            | Self::ProcessExited { buffer, .. }
464            | Self::Eof { buffer, .. } => Some(buffer),
465            _ => None,
466        }
467    }
468}
469
470impl SpawnError {
471    /// Create a command not found error.
472    pub fn command_not_found(command: impl Into<String>) -> Self {
473        Self::CommandNotFound {
474            command: command.into(),
475        }
476    }
477
478    /// Create a permission denied error.
479    pub fn permission_denied(path: impl Into<String>) -> Self {
480        Self::PermissionDenied { path: path.into() }
481    }
482
483    /// Create a PTY allocation error.
484    pub fn pty_allocation(reason: impl Into<String>) -> Self {
485        Self::PtyAllocation {
486            reason: reason.into(),
487        }
488    }
489
490    /// Create a terminal setup error.
491    pub fn terminal_setup(reason: impl Into<String>) -> Self {
492        Self::TerminalSetup {
493            reason: reason.into(),
494        }
495    }
496
497    /// Create an invalid environment variable error.
498    pub fn invalid_env(name: impl Into<String>) -> Self {
499        Self::InvalidEnv { name: name.into() }
500    }
501
502    /// Create an invalid working directory error.
503    pub fn invalid_working_dir(path: impl Into<String>) -> Self {
504        Self::InvalidWorkingDir { path: path.into() }
505    }
506}
507
508#[cfg(feature = "ssh")]
509impl SshError {
510    /// Create a connection error.
511    pub fn connection(host: impl Into<String>, port: u16, reason: impl Into<String>) -> Self {
512        Self::Connection {
513            host: host.into(),
514            port,
515            reason: reason.into(),
516        }
517    }
518
519    /// Create an authentication error.
520    pub fn authentication(user: impl Into<String>, reason: impl Into<String>) -> Self {
521        Self::Authentication {
522            user: user.into(),
523            reason: reason.into(),
524        }
525    }
526
527    /// Create a host key verification error.
528    pub fn host_key_verification(host: impl Into<String>, reason: impl Into<String>) -> Self {
529        Self::HostKeyVerification {
530            host: host.into(),
531            reason: reason.into(),
532        }
533    }
534
535    /// Create a channel error.
536    pub fn channel(reason: impl Into<String>) -> Self {
537        Self::Channel {
538            reason: reason.into(),
539        }
540    }
541
542    /// Create a session error.
543    pub fn session(reason: impl Into<String>) -> Self {
544        Self::Session {
545            reason: reason.into(),
546        }
547    }
548
549    /// Create a timeout error.
550    #[must_use]
551    pub const fn timeout(duration: Duration) -> Self {
552        Self::Timeout { duration }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn error_display() {
562        let err = ExpectError::timeout(
563            Duration::from_secs(5),
564            "password:",
565            "Enter username: admin\n",
566        );
567        let msg = err.to_string();
568        assert!(msg.contains("timeout"));
569        assert!(msg.contains("password:"));
570        assert!(msg.contains("admin"));
571        // Check for enhanced formatting
572        assert!(msg.contains("Pattern:"));
573        assert!(msg.contains("buffer"));
574    }
575
576    #[test]
577    fn error_display_with_tips() {
578        let err = ExpectError::timeout(Duration::from_secs(5), "password:", "output here\n");
579        let msg = err.to_string();
580        // Check that tips are included
581        assert!(msg.contains("Tip:"));
582    }
583
584    #[test]
585    fn error_display_empty_buffer() {
586        let err = ExpectError::eof("");
587        let msg = err.to_string();
588        assert!(msg.contains("empty buffer"));
589    }
590
591    #[test]
592    fn error_display_large_buffer_truncation() {
593        // Create a large buffer (> 500 bytes, > 6 lines)
594        let large_buffer: String = (0..50).fold(String::new(), |mut acc, i| {
595            use std::fmt::Write;
596            let _ = writeln!(acc, "Line {i}: Some content here");
597            acc
598        });
599
600        let err = ExpectError::timeout(Duration::from_secs(1), "pattern", &large_buffer);
601        let msg = err.to_string();
602
603        // Should contain truncation indicator
604        assert!(msg.contains("lines hidden"));
605        // Should show line count
606        assert!(msg.contains("lines)"));
607    }
608
609    #[test]
610    fn error_is_timeout() {
611        let timeout = ExpectError::timeout(Duration::from_secs(1), "test", "buffer");
612        assert!(timeout.is_timeout());
613
614        let eof = ExpectError::eof("buffer");
615        assert!(!eof.is_timeout());
616    }
617
618    #[test]
619    fn error_buffer() {
620        let err = ExpectError::timeout(Duration::from_secs(1), "test", "the buffer");
621        assert_eq!(err.buffer(), Some("the buffer"));
622
623        let io_err = ExpectError::Io(std::io::Error::other("test"));
624        assert!(io_err.buffer().is_none());
625    }
626
627    #[test]
628    fn spawn_error_display() {
629        let err = SpawnError::command_not_found("/usr/bin/nonexistent");
630        assert!(err.to_string().contains("nonexistent"));
631    }
632
633    #[test]
634    fn format_buffer_snippet_empty() {
635        let result = format_buffer_snippet("");
636        assert_eq!(result, "(empty buffer)");
637    }
638
639    #[test]
640    fn format_buffer_snippet_small() {
641        let result = format_buffer_snippet("hello\nworld");
642        assert!(result.contains("hello"));
643        assert!(result.contains("world"));
644        assert!(result.contains("bytes"));
645    }
646
647    #[test]
648    fn pattern_not_found_error() {
649        let err = ExpectError::pattern_not_found("prompt>", "some output");
650        let msg = err.to_string();
651        assert!(msg.contains("prompt>"));
652        assert!(msg.contains("some output"));
653        assert!(msg.contains("EOF"));
654    }
655
656    #[test]
657    fn eof_error() {
658        let err = ExpectError::eof("final output");
659        let msg = err.to_string();
660        assert!(msg.contains("end of file"));
661        assert!(msg.contains("final output"));
662    }
663
664    #[test]
665    fn io_with_context_error() {
666        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
667        let err = ExpectError::io_context("reading config file", io_err);
668        let msg = err.to_string();
669        assert!(msg.contains("reading config file"));
670        assert!(msg.contains("file not found"));
671    }
672
673    #[test]
674    fn with_io_context_helper() {
675        let result: std::io::Result<()> = Err(std::io::Error::new(
676            std::io::ErrorKind::PermissionDenied,
677            "access denied",
678        ));
679        let err = ExpectError::with_io_context(result, "writing to log file").unwrap_err();
680        let msg = err.to_string();
681        assert!(msg.contains("writing to log file"));
682        assert!(msg.contains("access denied"));
683    }
684
685    #[test]
686    fn with_io_context_success() {
687        let result: std::io::Result<i32> = Ok(42);
688        let value = ExpectError::with_io_context(result, "some operation").unwrap();
689        assert_eq!(value, 42);
690    }
691}