1use 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#[derive(Debug, Clone)]
44pub enum InteractAction {
45 Continue,
47 Send(Vec<u8>),
49 Stop,
51 Error(String),
53}
54
55impl InteractAction {
56 pub fn send(s: impl Into<String>) -> Self {
58 Self::Send(s.into().into_bytes())
59 }
60
61 pub fn send_bytes(data: impl Into<Vec<u8>>) -> Self {
63 Self::Send(data.into())
64 }
65}
66
67pub struct InteractContext<'a> {
69 pub matched: &'a str,
71 pub before: &'a str,
73 pub after: &'a str,
75 pub buffer: &'a str,
77 pub pattern_index: usize,
79}
80
81impl InteractContext<'_> {
82 pub fn send(&self, data: impl Into<String>) -> InteractAction {
84 InteractAction::send(data)
85 }
86
87 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
101pub type PatternHook = Box<dyn Fn(&InteractContext<'_>) -> InteractAction + Send + Sync>;
103
104#[derive(Debug, Clone, Copy)]
106pub struct ResizeContext {
107 pub size: TerminalSize,
109 pub previous: Option<TerminalSize>,
111}
112
113pub type ResizeHook = Box<dyn Fn(&ResizeContext) -> InteractAction + Send + Sync>;
115
116struct OutputPatternHook {
118 pattern: Pattern,
119 callback: PatternHook,
120}
121
122struct InputPatternHook {
124 pattern: Pattern,
125 callback: PatternHook,
126}
127
128pub struct InteractBuilder<'a, T>
130where
131 T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
132{
133 transport: &'a Arc<Mutex<T>>,
135 output_hooks: Vec<OutputPatternHook>,
137 input_hooks: Vec<InputPatternHook>,
139 resize_hook: Option<ResizeHook>,
141 hook_manager: HookManager,
143 mode: InteractionMode,
145 buffer_size: usize,
147 escape_sequence: Option<Vec<u8>>,
149 timeout: Option<Duration>,
151 output_taps: Vec<crate::session::OutputTap>,
156}
157
158impl<'a, T> InteractBuilder<'a, T>
159where
160 T: AsyncReadExt + AsyncWriteExt + Unpin + Send + 'static,
161{
162 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]), timeout: None,
177 output_taps,
178 }
179 }
180
181 #[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 #[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 #[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 #[must_use]
255 pub const fn with_mode(mut self, mode: InteractionMode) -> Self {
256 self.mode = mode;
257 self
258 }
259
260 #[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 #[must_use]
271 pub fn no_escape(mut self) -> Self {
272 self.escape_sequence = None;
273 self
274 }
275
276 #[must_use]
278 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
279 self.timeout = Some(timeout);
280 self
281 }
282
283 #[must_use]
285 pub const fn with_buffer_size(mut self, size: usize) -> Self {
286 self.buffer_size = size;
287 self
288 }
289
290 #[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 #[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 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#[derive(Debug, Clone)]
343pub struct InteractResult {
344 pub reason: InteractEndReason,
346 pub buffer: String,
348}
349
350#[derive(Debug, Clone)]
352pub enum InteractEndReason {
353 PatternStop {
355 pattern_index: usize,
357 },
358 Escape,
360 Timeout,
362 Eof,
364 Error(String),
366}
367
368struct 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 #[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 output_taps: Vec<crate::session::OutputTap>,
388 timeout: Option<Duration>,
389 #[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 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 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 #[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 let mut sigwinch =
474 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())
475 .map_err(ExpectError::Io)?;
476
477 loop {
478 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 _ = sigwinch.recv() => {
495 drop(transport); if let Some(result) = self.handle_resize().await? {
498 return Ok(result);
499 }
500 }
501
502 result = transport.read(&mut output_buf) => {
504 drop(transport); 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 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 let mut stdout = stdout();
525 let _ = stdout.write_all(&processed).await;
526 let _ = stdout.flush().await;
527
528 if let Ok(s) = std::str::from_utf8(&processed) {
530 self.buffer.push_str(s);
531 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 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 result = tokio::time::timeout(read_timeout, stdin.read(&mut input_buf)) => {
552 drop(transport); if let Ok(Ok(n)) = result {
555 if n == 0 {
556 continue;
557 }
558
559 let data = &input_buf[..n];
560
561 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 if escape_buf.len() > esc.len() {
574 escape_buf = escape_buf[escape_buf.len() - esc.len()..].to_vec();
575 }
576 }
577
578 let processed = self.hook_manager.process_input(data.to_vec());
580
581 self.hook_manager.notify(&InteractionEvent::Input(processed.clone()));
582
583 if let Some(result) = self.check_input_patterns(&processed).await? {
585 return Ok(result);
586 }
587
588 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 #[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 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 result = transport.read(&mut output_buf) => {
631 drop(transport); 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 let mut stdout = stdout();
649 let _ = stdout.write_all(&processed).await;
650 let _ = stdout.flush().await;
651
652 if let Ok(s) = std::str::from_utf8(&processed) {
654 self.buffer.push_str(s);
655 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 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 result = tokio::time::timeout(read_timeout, stdin.read(&mut input_buf)) => {
676 drop(transport); if let Ok(Ok(n)) = result {
679 if n == 0 {
680 continue;
681 }
682
683 let data = &input_buf[..n];
684
685 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 if escape_buf.len() > esc.len() {
698 escape_buf = escape_buf[escape_buf.len() - esc.len()..].to_vec();
699 }
700 }
701
702 let processed = self.hook_manager.process_input(data.to_vec());
704
705 self.hook_manager.notify(&InteractionEvent::Input(processed.clone()));
706
707 if let Some(result) = self.check_input_patterns(&processed).await? {
709 return Ok(result);
710 }
711
712 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 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 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 #[cfg_attr(windows, allow(dead_code))]
822 #[allow(clippy::significant_drop_tightening)]
823 async fn handle_resize(&mut self) -> Result<Option<InteractResult>> {
824 let Ok(new_size) = super::terminal::Terminal::size() else {
826 return Ok(None); };
828
829 let ctx = ResizeContext {
831 size: new_size,
832 previous: self.current_size,
833 };
834
835 self.hook_manager.notify(&InteractionEvent::Resize {
837 cols: new_size.cols,
838 rows: new_size.rows,
839 });
840
841 self.current_size = Some(new_size);
843
844 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}