Skip to main content

rust_expect/interact/
session.rs

1//! Interactive session with pattern hooks.
2//!
3//! This module provides the interactive session functionality with pattern-based
4//! callbacks. When patterns match in the output, registered callbacks are triggered.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use rust_expect::Session;
10//!
11//! #[tokio::main]
12//! async fn main() -> Result<(), rust_expect::ExpectError> {
13//!     let mut session = Session::spawn("/bin/bash", &[]).await?;
14//!
15//!     session.interact()
16//!         .on_output("password:", |ctx| {
17//!             println!("Password prompt detected!");
18//!             ctx.send("secret\n")
19//!         })
20//!         .on_output("logout", |_| {
21//!             InteractAction::Stop
22//!         })
23//!         .start()
24//!         .await?;
25//!
26//!     Ok(())
27//! }
28//! ```
29
30use std::sync::Arc;
31use std::time::Duration;
32
33use tokio::io::{AsyncReadExt, AsyncWriteExt};
34use tokio::sync::Mutex;
35
36use super::hooks::{HookManager, InteractionEvent};
37use super::mode::InteractionMode;
38use super::terminal::TerminalSize;
39use crate::error::{ExpectError, Result};
40use crate::expect::Pattern;
41
42/// Action to take after a pattern match in interactive mode.
43#[derive(Debug, Clone)]
44pub enum InteractAction {
45    /// Continue interaction.
46    Continue,
47    /// Send data to the session.
48    Send(Vec<u8>),
49    /// Stop the interaction.
50    Stop,
51    /// Stop with an error.
52    Error(String),
53}
54
55impl InteractAction {
56    /// Create a send action from a string.
57    pub fn send(s: impl Into<String>) -> Self {
58        Self::Send(s.into().into_bytes())
59    }
60
61    /// Create a send action from bytes.
62    pub fn send_bytes(data: impl Into<Vec<u8>>) -> Self {
63        Self::Send(data.into())
64    }
65}
66
67/// Context passed to pattern hook callbacks.
68pub struct InteractContext<'a> {
69    /// The matched text.
70    pub matched: &'a str,
71    /// Text before the match.
72    pub before: &'a str,
73    /// Text after the match.
74    pub after: &'a str,
75    /// The full buffer contents.
76    pub buffer: &'a str,
77    /// The pattern index that matched.
78    pub pattern_index: usize,
79}
80
81impl InteractContext<'_> {
82    /// Create a send action for convenience.
83    pub fn send(&self, data: impl Into<String>) -> InteractAction {
84        InteractAction::send(data)
85    }
86
87    /// Create a send action with line ending.
88    pub fn send_line(&self, data: impl Into<String>) -> InteractAction {
89        let mut s = data.into();
90        s.push('\n');
91        InteractAction::send(s)
92    }
93}
94
95/// Type alias for pattern hook callbacks.
96pub type PatternHook = Box<dyn Fn(&InteractContext<'_>) -> InteractAction + Send + Sync>;
97
98/// Context passed to resize hook callbacks.
99#[derive(Debug, Clone, Copy)]
100pub struct ResizeContext {
101    /// New terminal size.
102    pub size: TerminalSize,
103    /// Previous terminal size (if known).
104    pub previous: Option<TerminalSize>,
105}
106
107/// Type alias for resize hook callbacks.
108pub type ResizeHook = Box<dyn Fn(&ResizeContext) -> InteractAction + Send + Sync>;
109
110/// Output pattern hook registration.
111struct OutputPatternHook {
112    pattern: Pattern,
113    callback: PatternHook,
114}
115
116/// Input pattern hook registration.
117struct InputPatternHook {
118    pattern: Pattern,
119    callback: PatternHook,
120}
121
122/// Builder for configuring interactive sessions.
123pub struct InteractBuilder<'a, T>
124where
125    T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
126{
127    /// Reference to the transport.
128    transport: &'a Arc<Mutex<T>>,
129    /// Output pattern hooks.
130    output_hooks: Vec<OutputPatternHook>,
131    /// Input pattern hooks.
132    input_hooks: Vec<InputPatternHook>,
133    /// Resize hook.
134    resize_hook: Option<ResizeHook>,
135    /// Byte-level hook manager.
136    hook_manager: HookManager,
137    /// Interaction mode configuration.
138    mode: InteractionMode,
139    /// Buffer for accumulating output.
140    buffer_size: usize,
141    /// Escape string to exit interact mode.
142    escape_sequence: Option<Vec<u8>>,
143    /// Default timeout for the interaction.
144    timeout: Option<Duration>,
145    /// Session-registered output taps to fire on every chunk read during
146    /// the interact loop, in addition to the expect-driven taps. Required
147    /// so attached screens and transcript recorders don't go stale while
148    /// `interact()` is the active read-driver.
149    output_taps: Vec<crate::session::OutputTap>,
150}
151
152impl<'a, T> InteractBuilder<'a, T>
153where
154    T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
155{
156    /// Create a new interact builder.
157    pub(crate) fn new(
158        transport: &'a Arc<Mutex<T>>,
159        output_taps: Vec<crate::session::OutputTap>,
160    ) -> Self {
161        Self {
162            transport,
163            output_hooks: Vec::new(),
164            input_hooks: Vec::new(),
165            resize_hook: None,
166            hook_manager: HookManager::new(),
167            mode: InteractionMode::default(),
168            buffer_size: 8192,
169            escape_sequence: Some(vec![0x1d]), // Ctrl+] by default
170            timeout: None,
171            output_taps,
172        }
173    }
174
175    /// Register a pattern hook for output.
176    ///
177    /// When the output matches the pattern, the callback is invoked.
178    ///
179    /// # Example
180    ///
181    /// ```ignore
182    /// session.interact()
183    ///     .on_output("password:", |ctx| {
184    ///         ctx.send("my_password\n")
185    ///     })
186    ///     .start()
187    ///     .await?;
188    /// ```
189    #[must_use]
190    pub fn on_output<F>(mut self, pattern: impl Into<Pattern>, callback: F) -> Self
191    where
192        F: Fn(&InteractContext<'_>) -> InteractAction + Send + Sync + 'static,
193    {
194        self.output_hooks.push(OutputPatternHook {
195            pattern: pattern.into(),
196            callback: Box::new(callback),
197        });
198        self
199    }
200
201    /// Register a pattern hook for input.
202    ///
203    /// When the input matches the pattern, the callback is invoked.
204    #[must_use]
205    pub fn on_input<F>(mut self, pattern: impl Into<Pattern>, callback: F) -> Self
206    where
207        F: Fn(&InteractContext<'_>) -> InteractAction + Send + Sync + 'static,
208    {
209        self.input_hooks.push(InputPatternHook {
210            pattern: pattern.into(),
211            callback: Box::new(callback),
212        });
213        self
214    }
215
216    /// Register a hook for terminal resize events.
217    ///
218    /// On Unix systems, this is triggered by SIGWINCH. The callback receives
219    /// the new terminal size and can optionally return an action.
220    ///
221    /// # Example
222    ///
223    /// ```ignore
224    /// session.interact()
225    ///     .on_resize(|ctx| {
226    ///         println!("Terminal resized to {}x{}", ctx.size.cols, ctx.size.rows);
227    ///         InteractAction::Continue
228    ///     })
229    ///     .start()
230    ///     .await?;
231    /// ```
232    ///
233    /// # Platform Support
234    ///
235    /// - **Unix**: Resize events are detected via SIGWINCH signal handling.
236    /// - **Windows**: Resize detection is not currently supported; the callback
237    ///   will not be invoked.
238    #[must_use]
239    pub fn on_resize<F>(mut self, callback: F) -> Self
240    where
241        F: Fn(&ResizeContext) -> InteractAction + Send + Sync + 'static,
242    {
243        self.resize_hook = Some(Box::new(callback));
244        self
245    }
246
247    /// Set the interaction mode.
248    #[must_use]
249    pub const fn with_mode(mut self, mode: InteractionMode) -> Self {
250        self.mode = mode;
251        self
252    }
253
254    /// Set the escape sequence to exit interact mode.
255    ///
256    /// Default is Ctrl+] (0x1d).
257    #[must_use]
258    pub fn with_escape(mut self, escape: impl Into<Vec<u8>>) -> Self {
259        self.escape_sequence = Some(escape.into());
260        self
261    }
262
263    /// Disable the escape sequence (interact runs until pattern stops it).
264    #[must_use]
265    pub fn no_escape(mut self) -> Self {
266        self.escape_sequence = None;
267        self
268    }
269
270    /// Set a timeout for the interaction.
271    #[must_use]
272    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
273        self.timeout = Some(timeout);
274        self
275    }
276
277    /// Set the output buffer size.
278    #[must_use]
279    pub const fn with_buffer_size(mut self, size: usize) -> Self {
280        self.buffer_size = size;
281        self
282    }
283
284    /// Add a byte-level input hook.
285    #[must_use]
286    pub fn with_input_hook<F>(mut self, hook: F) -> Self
287    where
288        F: Fn(&[u8]) -> Vec<u8> + Send + Sync + 'static,
289    {
290        self.hook_manager.add_input_hook(hook);
291        self
292    }
293
294    /// Add a byte-level output hook.
295    #[must_use]
296    pub fn with_output_hook<F>(mut self, hook: F) -> Self
297    where
298        F: Fn(&[u8]) -> Vec<u8> + Send + Sync + 'static,
299    {
300        self.hook_manager.add_output_hook(hook);
301        self
302    }
303
304    /// Start the interactive session.
305    ///
306    /// This runs the interaction loop, reading from stdin and the session,
307    /// checking patterns, and invoking callbacks when matches occur.
308    ///
309    /// The interaction continues until:
310    /// - A pattern callback returns `InteractAction::Stop`
311    /// - The escape sequence is detected
312    /// - A timeout occurs (if configured)
313    /// - EOF is reached on the session
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if I/O fails or a pattern callback returns an error.
318    pub async fn start(self) -> Result<InteractResult> {
319        let mut runner = InteractRunner::new(
320            Arc::clone(self.transport),
321            self.output_hooks,
322            self.input_hooks,
323            self.resize_hook,
324            self.hook_manager,
325            self.mode,
326            self.buffer_size,
327            self.escape_sequence,
328            self.timeout,
329            self.output_taps,
330        );
331        runner.run().await
332    }
333}
334
335/// Result of an interactive session.
336#[derive(Debug, Clone)]
337pub struct InteractResult {
338    /// How the interaction ended.
339    pub reason: InteractEndReason,
340    /// Final buffer contents.
341    pub buffer: String,
342}
343
344/// Reason the interaction ended.
345#[derive(Debug, Clone)]
346pub enum InteractEndReason {
347    /// A pattern callback returned Stop.
348    PatternStop {
349        /// Index of the pattern that stopped interaction.
350        pattern_index: usize,
351    },
352    /// Escape sequence was detected.
353    Escape,
354    /// Timeout occurred.
355    Timeout,
356    /// EOF was reached on the session.
357    Eof,
358    /// An error occurred in a pattern callback.
359    Error(String),
360}
361
362/// Internal runner for the interaction loop.
363struct InteractRunner<T>
364where
365    T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
366{
367    transport: Arc<Mutex<T>>,
368    output_hooks: Vec<OutputPatternHook>,
369    input_hooks: Vec<InputPatternHook>,
370    /// Resize hook - used on Unix via SIGWINCH signal handling.
371    /// On Windows, terminal resize events aren't currently supported.
372    #[cfg_attr(windows, allow(dead_code))]
373    resize_hook: Option<ResizeHook>,
374    hook_manager: HookManager,
375    mode: InteractionMode,
376    buffer: String,
377    buffer_size: usize,
378    escape_sequence: Option<Vec<u8>>,
379    /// Session-registered output taps fired on every chunk so attached
380    /// screens and transcript recorders keep updating during `interact()`.
381    output_taps: Vec<crate::session::OutputTap>,
382    timeout: Option<Duration>,
383    /// Current terminal size - tracked for resize delta detection on Unix.
384    /// On Windows, terminal resize events aren't currently supported.
385    #[cfg_attr(windows, allow(dead_code))]
386    current_size: Option<TerminalSize>,
387}
388
389impl<T> InteractRunner<T>
390where
391    T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
392{
393    #[allow(clippy::too_many_arguments)]
394    fn new(
395        transport: Arc<Mutex<T>>,
396        output_hooks: Vec<OutputPatternHook>,
397        input_hooks: Vec<InputPatternHook>,
398        resize_hook: Option<ResizeHook>,
399        hook_manager: HookManager,
400        mode: InteractionMode,
401        buffer_size: usize,
402        escape_sequence: Option<Vec<u8>>,
403        timeout: Option<Duration>,
404        output_taps: Vec<crate::session::OutputTap>,
405    ) -> Self {
406        // Get initial terminal size
407        let current_size = super::terminal::Terminal::size().ok();
408
409        Self {
410            transport,
411            output_hooks,
412            input_hooks,
413            resize_hook,
414            hook_manager,
415            mode,
416            buffer: String::with_capacity(buffer_size),
417            buffer_size,
418            escape_sequence,
419            timeout,
420            current_size,
421            output_taps,
422        }
423    }
424
425    /// Fire every registered session output tap on a chunk, wrapping each in
426    /// `catch_unwind` so a panicking observer can't take down the runner.
427    /// Matches the contract of `Session::read_with_timeout`.
428    fn fire_taps(&self, chunk: &[u8]) {
429        for tap in &self.output_taps {
430            let tap_clone = tap.clone();
431            let chunk_ref = chunk;
432            let result =
433                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tap_clone(chunk_ref)));
434            if result.is_err() {
435                tracing::warn!("output tap panicked during interact; caught and continuing");
436            }
437        }
438    }
439
440    async fn run(&mut self) -> Result<InteractResult> {
441        #[cfg(unix)]
442        {
443            self.run_with_signals().await
444        }
445        #[cfg(not(unix))]
446        {
447            self.run_without_signals().await
448        }
449    }
450
451    /// Run the interaction loop with Unix signal handling (SIGWINCH).
452    #[cfg(unix)]
453    #[allow(clippy::significant_drop_tightening)]
454    async fn run_with_signals(&mut self) -> Result<InteractResult> {
455        use tokio::io::{BufReader, stdin, stdout};
456
457        self.hook_manager.notify(&InteractionEvent::Started);
458
459        let mut stdin = BufReader::new(stdin());
460        let mut input_buf = [0u8; 1024];
461        let mut output_buf = [0u8; 4096];
462        let mut escape_buf: Vec<u8> = Vec::new();
463
464        let deadline = self.timeout.map(|t| std::time::Instant::now() + t);
465
466        // Set up SIGWINCH signal handler
467        let mut sigwinch =
468            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())
469                .map_err(ExpectError::Io)?;
470
471        loop {
472            // Check timeout
473            if let Some(deadline) = deadline
474                && std::time::Instant::now() >= deadline
475            {
476                self.hook_manager.notify(&InteractionEvent::Ended);
477                return Ok(InteractResult {
478                    reason: InteractEndReason::Timeout,
479                    buffer: self.buffer.clone(),
480                });
481            }
482
483            let read_timeout = self.mode.read_timeout;
484            let mut transport = self.transport.lock().await;
485
486            tokio::select! {
487                // Handle SIGWINCH (window resize)
488                _ = sigwinch.recv() => {
489                    drop(transport); // Release lock before processing
490
491                    if let Some(result) = self.handle_resize().await? {
492                        return Ok(result);
493                    }
494                }
495
496                // Read from session output
497                result = transport.read(&mut output_buf) => {
498                    drop(transport); // Release lock before processing
499                    match result {
500                        Ok(0) => {
501                            self.hook_manager.notify(&InteractionEvent::Ended);
502                            return Ok(InteractResult {
503                                reason: InteractEndReason::Eof,
504                                buffer: self.buffer.clone(),
505                            });
506                        }
507                        Ok(n) => {
508                            let data = &output_buf[..n];
509                            // Fire session-registered output taps on the raw
510                            // chunk before any hook-manager rewriting, so
511                            // taps see exactly what the PTY emitted.
512                            self.fire_taps(data);
513                            let processed = self.hook_manager.process_output(data.to_vec());
514
515                            self.hook_manager.notify(&InteractionEvent::Output(processed.clone()));
516
517                            // Write to stdout
518                            let mut stdout = stdout();
519                            let _ = stdout.write_all(&processed).await;
520                            let _ = stdout.flush().await;
521
522                            // Append to buffer for pattern matching
523                            if let Ok(s) = std::str::from_utf8(&processed) {
524                                self.buffer.push_str(s);
525                                // Trim buffer if too large
526                                if self.buffer.len() > self.buffer_size {
527                                    let start = self.buffer.len() - self.buffer_size;
528                                    self.buffer = self.buffer[start..].to_string();
529                                }
530                            }
531
532                            // Check output patterns
533                            if let Some(result) = self.check_output_patterns().await? {
534                                return Ok(result);
535                            }
536                        }
537                        Err(e) => {
538                            self.hook_manager.notify(&InteractionEvent::Ended);
539                            return Err(ExpectError::Io(e));
540                        }
541                    }
542                }
543
544                // Read from stdin (user input)
545                result = tokio::time::timeout(read_timeout, stdin.read(&mut input_buf)) => {
546                    drop(transport); // Release lock
547
548                    if let Ok(Ok(n)) = result {
549                        if n == 0 {
550                            continue;
551                        }
552
553                        let data = &input_buf[..n];
554
555                        // Check for escape sequence
556                        if let Some(ref esc) = self.escape_sequence {
557                            escape_buf.extend_from_slice(data);
558                            if escape_buf.ends_with(esc) {
559                                self.hook_manager.notify(&InteractionEvent::ExitRequested);
560                                self.hook_manager.notify(&InteractionEvent::Ended);
561                                return Ok(InteractResult {
562                                    reason: InteractEndReason::Escape,
563                                    buffer: self.buffer.clone(),
564                                });
565                            }
566                            // Keep only last N bytes where N is escape length
567                            if escape_buf.len() > esc.len() {
568                                escape_buf = escape_buf[escape_buf.len() - esc.len()..].to_vec();
569                            }
570                        }
571
572                        // Process through input hooks
573                        let processed = self.hook_manager.process_input(data.to_vec());
574
575                        self.hook_manager.notify(&InteractionEvent::Input(processed.clone()));
576
577                        // Check input patterns
578                        if let Some(result) = self.check_input_patterns(&processed).await? {
579                            return Ok(result);
580                        }
581
582                        // Send to session
583                        let mut transport = self.transport.lock().await;
584                        transport.write_all(&processed).await.map_err(ExpectError::Io)?;
585                        transport.flush().await.map_err(ExpectError::Io)?;
586                    }
587                }
588            }
589        }
590    }
591
592    /// Run the interaction loop without signal handling (non-Unix platforms).
593    #[cfg(not(unix))]
594    #[allow(clippy::significant_drop_tightening)]
595    async fn run_without_signals(&mut self) -> Result<InteractResult> {
596        use tokio::io::{BufReader, stdin, stdout};
597
598        self.hook_manager.notify(&InteractionEvent::Started);
599
600        let mut stdin = BufReader::new(stdin());
601        let mut input_buf = [0u8; 1024];
602        let mut output_buf = [0u8; 4096];
603        let mut escape_buf: Vec<u8> = Vec::new();
604
605        let deadline = self.timeout.map(|t| std::time::Instant::now() + t);
606
607        loop {
608            // Check timeout
609            if let Some(deadline) = deadline {
610                if std::time::Instant::now() >= deadline {
611                    self.hook_manager.notify(&InteractionEvent::Ended);
612                    return Ok(InteractResult {
613                        reason: InteractEndReason::Timeout,
614                        buffer: self.buffer.clone(),
615                    });
616                }
617            }
618
619            let read_timeout = self.mode.read_timeout;
620            let mut transport = self.transport.lock().await;
621
622            tokio::select! {
623                // Read from session output
624                result = transport.read(&mut output_buf) => {
625                    drop(transport); // Release lock before processing
626                    match result {
627                        Ok(0) => {
628                            self.hook_manager.notify(&InteractionEvent::Ended);
629                            return Ok(InteractResult {
630                                reason: InteractEndReason::Eof,
631                                buffer: self.buffer.clone(),
632                            });
633                        }
634                        Ok(n) => {
635                            let data = &output_buf[..n];
636                            self.fire_taps(data);
637                            let processed = self.hook_manager.process_output(data.to_vec());
638
639                            self.hook_manager.notify(&InteractionEvent::Output(processed.clone()));
640
641                            // Write to stdout
642                            let mut stdout = stdout();
643                            let _ = stdout.write_all(&processed).await;
644                            let _ = stdout.flush().await;
645
646                            // Append to buffer for pattern matching
647                            if let Ok(s) = std::str::from_utf8(&processed) {
648                                self.buffer.push_str(s);
649                                // Trim buffer if too large
650                                if self.buffer.len() > self.buffer_size {
651                                    let start = self.buffer.len() - self.buffer_size;
652                                    self.buffer = self.buffer[start..].to_string();
653                                }
654                            }
655
656                            // Check output patterns
657                            if let Some(result) = self.check_output_patterns().await? {
658                                return Ok(result);
659                            }
660                        }
661                        Err(e) => {
662                            self.hook_manager.notify(&InteractionEvent::Ended);
663                            return Err(ExpectError::Io(e));
664                        }
665                    }
666                }
667
668                // Read from stdin (user input)
669                result = tokio::time::timeout(read_timeout, stdin.read(&mut input_buf)) => {
670                    drop(transport); // Release lock
671
672                    if let Ok(Ok(n)) = result {
673                        if n == 0 {
674                            continue;
675                        }
676
677                        let data = &input_buf[..n];
678
679                        // Check for escape sequence
680                        if let Some(ref esc) = self.escape_sequence {
681                            escape_buf.extend_from_slice(data);
682                            if escape_buf.ends_with(esc) {
683                                self.hook_manager.notify(&InteractionEvent::ExitRequested);
684                                self.hook_manager.notify(&InteractionEvent::Ended);
685                                return Ok(InteractResult {
686                                    reason: InteractEndReason::Escape,
687                                    buffer: self.buffer.clone(),
688                                });
689                            }
690                            // Keep only last N bytes where N is escape length
691                            if escape_buf.len() > esc.len() {
692                                escape_buf = escape_buf[escape_buf.len() - esc.len()..].to_vec();
693                            }
694                        }
695
696                        // Process through input hooks
697                        let processed = self.hook_manager.process_input(data.to_vec());
698
699                        self.hook_manager.notify(&InteractionEvent::Input(processed.clone()));
700
701                        // Check input patterns
702                        if let Some(result) = self.check_input_patterns(&processed).await? {
703                            return Ok(result);
704                        }
705
706                        // Send to session
707                        let mut transport = self.transport.lock().await;
708                        transport.write_all(&processed).await.map_err(ExpectError::Io)?;
709                        transport.flush().await.map_err(ExpectError::Io)?;
710                    }
711                }
712            }
713        }
714    }
715
716    #[allow(clippy::significant_drop_tightening)]
717    async fn check_output_patterns(&mut self) -> Result<Option<InteractResult>> {
718        for (index, hook) in self.output_hooks.iter().enumerate() {
719            if let Some(m) = hook.pattern.matches(&self.buffer) {
720                let matched = &self.buffer[m.start..m.end];
721                let before = &self.buffer[..m.start];
722                let after = &self.buffer[m.end..];
723
724                let ctx = InteractContext {
725                    matched,
726                    before,
727                    after,
728                    buffer: &self.buffer,
729                    pattern_index: index,
730                };
731
732                match (hook.callback)(&ctx) {
733                    InteractAction::Continue => {
734                        // Clear the matched portion to avoid re-triggering
735                        self.buffer = after.to_string();
736                    }
737                    InteractAction::Send(data) => {
738                        let mut transport = self.transport.lock().await;
739                        transport.write_all(&data).await.map_err(ExpectError::Io)?;
740                        transport.flush().await.map_err(ExpectError::Io)?;
741                        // Clear matched portion
742                        self.buffer = after.to_string();
743                    }
744                    InteractAction::Stop => {
745                        self.hook_manager.notify(&InteractionEvent::Ended);
746                        return Ok(Some(InteractResult {
747                            reason: InteractEndReason::PatternStop {
748                                pattern_index: index,
749                            },
750                            buffer: self.buffer.clone(),
751                        }));
752                    }
753                    InteractAction::Error(msg) => {
754                        self.hook_manager.notify(&InteractionEvent::Ended);
755                        return Ok(Some(InteractResult {
756                            reason: InteractEndReason::Error(msg),
757                            buffer: self.buffer.clone(),
758                        }));
759                    }
760                }
761            }
762        }
763        Ok(None)
764    }
765
766    #[allow(clippy::significant_drop_tightening)]
767    async fn check_input_patterns(&self, input: &[u8]) -> Result<Option<InteractResult>> {
768        let input_str = String::from_utf8_lossy(input);
769
770        for (index, hook) in self.input_hooks.iter().enumerate() {
771            if let Some(m) = hook.pattern.matches(&input_str) {
772                let matched = &input_str[m.start..m.end];
773                let before = &input_str[..m.start];
774                let after = &input_str[m.end..];
775
776                let ctx = InteractContext {
777                    matched,
778                    before,
779                    after,
780                    buffer: &input_str,
781                    pattern_index: index,
782                };
783
784                match (hook.callback)(&ctx) {
785                    InteractAction::Continue => {}
786                    InteractAction::Send(data) => {
787                        let mut transport = self.transport.lock().await;
788                        transport.write_all(&data).await.map_err(ExpectError::Io)?;
789                        transport.flush().await.map_err(ExpectError::Io)?;
790                    }
791                    InteractAction::Stop => {
792                        return Ok(Some(InteractResult {
793                            reason: InteractEndReason::PatternStop {
794                                pattern_index: index,
795                            },
796                            buffer: self.buffer.clone(),
797                        }));
798                    }
799                    InteractAction::Error(msg) => {
800                        return Ok(Some(InteractResult {
801                            reason: InteractEndReason::Error(msg),
802                            buffer: self.buffer.clone(),
803                        }));
804                    }
805                }
806            }
807        }
808        Ok(None)
809    }
810
811    /// Handle a window resize event.
812    ///
813    /// This is called on Unix when SIGWINCH is received. On Windows, terminal
814    /// resize events aren't currently supported via signals.
815    #[cfg_attr(windows, allow(dead_code))]
816    #[allow(clippy::significant_drop_tightening)]
817    async fn handle_resize(&mut self) -> Result<Option<InteractResult>> {
818        // Get the new terminal size
819        let Ok(new_size) = super::terminal::Terminal::size() else {
820            return Ok(None); // Ignore if we can't get size
821        };
822
823        // Build the context with previous size
824        let ctx = ResizeContext {
825            size: new_size,
826            previous: self.current_size,
827        };
828
829        // Notify via hook manager
830        self.hook_manager.notify(&InteractionEvent::Resize {
831            cols: new_size.cols,
832            rows: new_size.rows,
833        });
834
835        // Update our tracked size
836        self.current_size = Some(new_size);
837
838        // Call the user's resize hook if registered
839        if let Some(ref hook) = self.resize_hook {
840            match hook(&ctx) {
841                InteractAction::Continue => {}
842                InteractAction::Send(data) => {
843                    let mut transport = self.transport.lock().await;
844                    transport.write_all(&data).await.map_err(ExpectError::Io)?;
845                    transport.flush().await.map_err(ExpectError::Io)?;
846                }
847                InteractAction::Stop => {
848                    self.hook_manager.notify(&InteractionEvent::Ended);
849                    return Ok(Some(InteractResult {
850                        reason: InteractEndReason::PatternStop { pattern_index: 0 },
851                        buffer: self.buffer.clone(),
852                    }));
853                }
854                InteractAction::Error(msg) => {
855                    self.hook_manager.notify(&InteractionEvent::Ended);
856                    return Ok(Some(InteractResult {
857                        reason: InteractEndReason::Error(msg),
858                        buffer: self.buffer.clone(),
859                    }));
860                }
861            }
862        }
863
864        Ok(None)
865    }
866}