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)]
120#[non_exhaustive]
121pub enum ExpectError {
122 #[error("no screen is attached to this session — call Session::attach_screen() first")]
128 ScreenNotAttached,
129
130 #[error("invalid input to {api}: {reason}")]
138 InvalidInput {
139 api: String,
141 reason: String,
143 },
144 #[error("failed to spawn process: {0}")]
146 Spawn(#[from] SpawnError),
147
148 #[error("I/O error: {0}")]
150 Io(#[from] std::io::Error),
151
152 #[error("{context}: {source}")]
154 IoWithContext {
155 context: String,
157 #[source]
159 source: std::io::Error,
160 },
161
162 #[error("{}", format_timeout_error(*duration, pattern, buffer))]
164 Timeout {
165 duration: Duration,
167 pattern: String,
169 buffer: String,
171 },
172
173 #[error("{}", format_pattern_not_found_error(pattern, buffer))]
175 PatternNotFound {
176 pattern: String,
178 buffer: String,
180 },
181
182 #[error("{}", format_process_exited_error(exit_status, buffer))]
184 ProcessExited {
185 exit_status: ExitStatus,
187 buffer: String,
189 },
190
191 #[error("{}", format_eof_error(buffer))]
193 Eof {
194 buffer: String,
196 },
197
198 #[error("invalid pattern: {message}")]
200 InvalidPattern {
201 message: String,
203 },
204
205 #[error("invalid regex pattern: {0}")]
207 Regex(#[from] regex::Error),
208
209 #[error("session is closed")]
211 SessionClosed,
212
213 #[error("session with id {id} not found")]
215 SessionNotFound {
216 id: usize,
218 },
219
220 #[error("no sessions available for operation")]
222 NoSessions,
223
224 #[error("multi-session error in session {session_id}: {error}")]
226 MultiSessionError {
227 session_id: usize,
229 error: Box<Self>,
231 },
232
233 #[error("session is not in interact mode")]
235 NotInteracting,
236
237 #[error("buffer overflow: maximum size of {max_size} bytes exceeded")]
239 BufferOverflow {
240 max_size: usize,
242 },
243
244 #[error("encoding error: {message}")]
246 Encoding {
247 message: String,
249 },
250
251 #[cfg(feature = "ssh")]
253 #[error("SSH error: {0}")]
254 Ssh(#[from] SshError),
255
256 #[error("configuration error: {message}")]
258 Config {
259 message: String,
261 },
262
263 #[cfg(unix)]
265 #[error("signal error: {message}")]
266 Signal {
267 message: String,
269 },
270}
271
272#[derive(Debug, Error)]
274pub enum SpawnError {
275 #[error("command not found: {command}")]
277 CommandNotFound {
278 command: String,
280 },
281
282 #[error("permission denied: {path}")]
284 PermissionDenied {
285 path: String,
287 },
288
289 #[error("failed to allocate PTY: {reason}")]
291 PtyAllocation {
292 reason: String,
294 },
295
296 #[error("failed to set up terminal: {reason}")]
298 TerminalSetup {
299 reason: String,
301 },
302
303 #[error("invalid environment variable: {name}")]
305 InvalidEnv {
306 name: String,
308 },
309
310 #[error("invalid working directory: {path}")]
312 InvalidWorkingDir {
313 path: String,
315 },
316
317 #[error("I/O error during spawn: {0}")]
319 Io(#[from] std::io::Error),
320
321 #[error("invalid {kind}: {reason}")]
323 InvalidArgument {
324 kind: String,
326 value: String,
328 reason: String,
330 },
331}
332
333#[cfg(feature = "ssh")]
335#[derive(Debug, Error)]
336pub enum SshError {
337 #[error("failed to connect to {host}:{port}: {reason}")]
339 Connection {
340 host: String,
342 port: u16,
344 reason: String,
346 },
347
348 #[error("authentication failed for user '{user}': {reason}")]
350 Authentication {
351 user: String,
353 reason: String,
355 },
356
357 #[error("host key verification failed for {host}: {reason}")]
359 HostKeyVerification {
360 host: String,
362 reason: String,
364 },
365
366 #[error("SSH channel error: {reason}")]
368 Channel {
369 reason: String,
371 },
372
373 #[error("SSH session error: {reason}")]
375 Session {
376 reason: String,
378 },
379
380 #[error("SSH operation timed out after {duration:?}")]
382 Timeout {
383 duration: Duration,
385 },
386}
387
388pub type Result<T> = std::result::Result<T, ExpectError>;
390
391impl ExpectError {
392 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 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 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 pub fn eof(buffer: impl Into<String>) -> Self {
423 Self::Eof {
424 buffer: buffer.into(),
425 }
426 }
427
428 pub fn invalid_pattern(message: impl Into<String>) -> Self {
430 Self::InvalidPattern {
431 message: message.into(),
432 }
433 }
434
435 #[must_use]
437 pub const fn buffer_overflow(max_size: usize) -> Self {
438 Self::BufferOverflow { max_size }
439 }
440
441 pub fn encoding(message: impl Into<String>) -> Self {
443 Self::Encoding {
444 message: message.into(),
445 }
446 }
447
448 pub fn config(message: impl Into<String>) -> Self {
450 Self::Config {
451 message: message.into(),
452 }
453 }
454
455 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 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 #[must_use]
470 pub const fn is_timeout(&self) -> bool {
471 matches!(self, Self::Timeout { .. })
472 }
473
474 #[must_use]
476 pub const fn is_eof(&self) -> bool {
477 matches!(self, Self::Eof { .. } | Self::ProcessExited { .. })
478 }
479
480 #[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 pub fn command_not_found(command: impl Into<String>) -> Self {
496 Self::CommandNotFound {
497 command: command.into(),
498 }
499 }
500
501 pub fn permission_denied(path: impl Into<String>) -> Self {
503 Self::PermissionDenied { path: path.into() }
504 }
505
506 pub fn pty_allocation(reason: impl Into<String>) -> Self {
508 Self::PtyAllocation {
509 reason: reason.into(),
510 }
511 }
512
513 pub fn terminal_setup(reason: impl Into<String>) -> Self {
515 Self::TerminalSetup {
516 reason: reason.into(),
517 }
518 }
519
520 pub fn invalid_env(name: impl Into<String>) -> Self {
522 Self::InvalidEnv { name: name.into() }
523 }
524
525 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 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 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 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 pub fn channel(reason: impl Into<String>) -> Self {
560 Self::Channel {
561 reason: reason.into(),
562 }
563 }
564
565 pub fn session(reason: impl Into<String>) -> Self {
567 Self::Session {
568 reason: reason.into(),
569 }
570 }
571
572 #[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 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 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 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 assert!(msg.contains("lines hidden"));
628 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}