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