1use std::process::ExitStatus;
8use std::time::Duration;
9
10use thiserror::Error;
11
12const MAX_BUFFER_DISPLAY: usize = 500;
14
15const CONTEXT_LINES: usize = 3;
17
18fn 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 return format!(
29 "┌─ buffer ({} bytes) ──────────────────────\n│ {}\n└────────────────────────────────────────",
30 buffer_len,
31 buffer.lines().collect::<Vec<_>>().join("\n│ ")
32 );
33 }
34
35 let lines: Vec<&str> = buffer.lines().collect();
37 let total_lines = lines.len();
38
39 if total_lines <= CONTEXT_LINES * 2 {
40 return format!(
42 "┌─ buffer ({} bytes, {} lines) ─────────────\n│ {}\n└────────────────────────────────────────",
43 buffer_len,
44 total_lines,
45 lines.join("\n│ ")
46 );
47 }
48
49 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
62fn 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
80fn 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#[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
107fn 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#[derive(Debug, Error)]
120pub enum ExpectError {
121 #[error("failed to spawn process: {0}")]
123 Spawn(#[from] SpawnError),
124
125 #[error("I/O error: {0}")]
127 Io(#[from] std::io::Error),
128
129 #[error("{context}: {source}")]
131 IoWithContext {
132 context: String,
134 #[source]
136 source: std::io::Error,
137 },
138
139 #[error("{}", format_timeout_error(*duration, pattern, buffer))]
141 Timeout {
142 duration: Duration,
144 pattern: String,
146 buffer: String,
148 },
149
150 #[error("{}", format_pattern_not_found_error(pattern, buffer))]
152 PatternNotFound {
153 pattern: String,
155 buffer: String,
157 },
158
159 #[error("{}", format_process_exited_error(exit_status, buffer))]
161 ProcessExited {
162 exit_status: ExitStatus,
164 buffer: String,
166 },
167
168 #[error("{}", format_eof_error(buffer))]
170 Eof {
171 buffer: String,
173 },
174
175 #[error("invalid pattern: {message}")]
177 InvalidPattern {
178 message: String,
180 },
181
182 #[error("invalid regex pattern: {0}")]
184 Regex(#[from] regex::Error),
185
186 #[error("session is closed")]
188 SessionClosed,
189
190 #[error("session with id {id} not found")]
192 SessionNotFound {
193 id: usize,
195 },
196
197 #[error("no sessions available for operation")]
199 NoSessions,
200
201 #[error("multi-session error in session {session_id}: {error}")]
203 MultiSessionError {
204 session_id: usize,
206 error: Box<Self>,
208 },
209
210 #[error("session is not in interact mode")]
212 NotInteracting,
213
214 #[error("buffer overflow: maximum size of {max_size} bytes exceeded")]
216 BufferOverflow {
217 max_size: usize,
219 },
220
221 #[error("encoding error: {message}")]
223 Encoding {
224 message: String,
226 },
227
228 #[cfg(feature = "ssh")]
230 #[error("SSH error: {0}")]
231 Ssh(#[from] SshError),
232
233 #[error("configuration error: {message}")]
235 Config {
236 message: String,
238 },
239
240 #[cfg(unix)]
242 #[error("signal error: {message}")]
243 Signal {
244 message: String,
246 },
247}
248
249#[derive(Debug, Error)]
251pub enum SpawnError {
252 #[error("command not found: {command}")]
254 CommandNotFound {
255 command: String,
257 },
258
259 #[error("permission denied: {path}")]
261 PermissionDenied {
262 path: String,
264 },
265
266 #[error("failed to allocate PTY: {reason}")]
268 PtyAllocation {
269 reason: String,
271 },
272
273 #[error("failed to set up terminal: {reason}")]
275 TerminalSetup {
276 reason: String,
278 },
279
280 #[error("invalid environment variable: {name}")]
282 InvalidEnv {
283 name: String,
285 },
286
287 #[error("invalid working directory: {path}")]
289 InvalidWorkingDir {
290 path: String,
292 },
293
294 #[error("I/O error during spawn: {0}")]
296 Io(#[from] std::io::Error),
297
298 #[error("invalid {kind}: {reason}")]
300 InvalidArgument {
301 kind: String,
303 value: String,
305 reason: String,
307 },
308}
309
310#[cfg(feature = "ssh")]
312#[derive(Debug, Error)]
313pub enum SshError {
314 #[error("failed to connect to {host}:{port}: {reason}")]
316 Connection {
317 host: String,
319 port: u16,
321 reason: String,
323 },
324
325 #[error("authentication failed for user '{user}': {reason}")]
327 Authentication {
328 user: String,
330 reason: String,
332 },
333
334 #[error("host key verification failed for {host}: {reason}")]
336 HostKeyVerification {
337 host: String,
339 reason: String,
341 },
342
343 #[error("SSH channel error: {reason}")]
345 Channel {
346 reason: String,
348 },
349
350 #[error("SSH session error: {reason}")]
352 Session {
353 reason: String,
355 },
356
357 #[error("SSH operation timed out after {duration:?}")]
359 Timeout {
360 duration: Duration,
362 },
363}
364
365pub type Result<T> = std::result::Result<T, ExpectError>;
367
368impl ExpectError {
369 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 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 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 pub fn eof(buffer: impl Into<String>) -> Self {
400 Self::Eof {
401 buffer: buffer.into(),
402 }
403 }
404
405 pub fn invalid_pattern(message: impl Into<String>) -> Self {
407 Self::InvalidPattern {
408 message: message.into(),
409 }
410 }
411
412 #[must_use]
414 pub const fn buffer_overflow(max_size: usize) -> Self {
415 Self::BufferOverflow { max_size }
416 }
417
418 pub fn encoding(message: impl Into<String>) -> Self {
420 Self::Encoding {
421 message: message.into(),
422 }
423 }
424
425 pub fn config(message: impl Into<String>) -> Self {
427 Self::Config {
428 message: message.into(),
429 }
430 }
431
432 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 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 #[must_use]
447 pub const fn is_timeout(&self) -> bool {
448 matches!(self, Self::Timeout { .. })
449 }
450
451 #[must_use]
453 pub const fn is_eof(&self) -> bool {
454 matches!(self, Self::Eof { .. } | Self::ProcessExited { .. })
455 }
456
457 #[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 pub fn command_not_found(command: impl Into<String>) -> Self {
473 Self::CommandNotFound {
474 command: command.into(),
475 }
476 }
477
478 pub fn permission_denied(path: impl Into<String>) -> Self {
480 Self::PermissionDenied { path: path.into() }
481 }
482
483 pub fn pty_allocation(reason: impl Into<String>) -> Self {
485 Self::PtyAllocation {
486 reason: reason.into(),
487 }
488 }
489
490 pub fn terminal_setup(reason: impl Into<String>) -> Self {
492 Self::TerminalSetup {
493 reason: reason.into(),
494 }
495 }
496
497 pub fn invalid_env(name: impl Into<String>) -> Self {
499 Self::InvalidEnv { name: name.into() }
500 }
501
502 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 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 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 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 pub fn channel(reason: impl Into<String>) -> Self {
537 Self::Channel {
538 reason: reason.into(),
539 }
540 }
541
542 pub fn session(reason: impl Into<String>) -> Self {
544 Self::Session {
545 reason: reason.into(),
546 }
547 }
548
549 #[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 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 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 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 assert!(msg.contains("lines hidden"));
605 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}