Skip to main content

runmat_runtime/
interaction.rs

1use once_cell::sync::OnceCell;
2use runmat_thread_local::runmat_thread_local;
3use std::cell::RefCell;
4use std::future::Future;
5use std::pin::Pin;
6
7use crate::{build_runtime_error, RuntimeError};
8#[cfg(not(target_arch = "wasm32"))]
9use std::io::IsTerminal;
10#[cfg(not(target_arch = "wasm32"))]
11use std::io::{self, Read, Write};
12#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, RwLock};
15
16pub use runmat_async::InteractionKind;
17
18#[derive(Clone)]
19pub struct InteractionPromptOwned {
20    pub prompt: String,
21    pub kind: InteractionKind,
22}
23
24#[derive(Clone)]
25pub enum InteractionResponse {
26    Line(String),
27    KeyPress,
28}
29
30pub type AsyncInteractionFuture =
31    Pin<Box<dyn Future<Output = Result<InteractionResponse, String>> + 'static>>;
32
33pub type AsyncInteractionHandler =
34    dyn Fn(InteractionPromptOwned) -> AsyncInteractionFuture + Send + Sync;
35
36static ASYNC_HANDLER: OnceCell<RwLock<Option<Arc<AsyncInteractionHandler>>>> = OnceCell::new();
37runmat_thread_local! {
38    static QUEUED_RESPONSE: RefCell<Option<Result<InteractionResponse, String>>> =
39        const { RefCell::new(None) };
40}
41
42#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
43static FORCE_INTERACTIVE_STDIN: AtomicBool = AtomicBool::new(false);
44
45#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
46pub fn force_interactive_stdin_for_tests(enable: bool) {
47    FORCE_INTERACTIVE_STDIN.store(enable, Ordering::Relaxed);
48}
49
50#[cfg(all(not(feature = "interaction-test-hooks"), not(target_arch = "wasm32")))]
51#[inline]
52fn force_interactive_stdin() -> bool {
53    false
54}
55
56#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
57#[inline]
58fn force_interactive_stdin() -> bool {
59    FORCE_INTERACTIVE_STDIN.load(Ordering::Relaxed)
60}
61
62fn async_handler_slot() -> &'static RwLock<Option<Arc<AsyncInteractionHandler>>> {
63    ASYNC_HANDLER.get_or_init(|| RwLock::new(None))
64}
65
66fn interaction_error(identifier: &str, message: impl Into<String>) -> RuntimeError {
67    build_runtime_error(message)
68        .with_identifier(identifier.to_string())
69        .build()
70}
71
72pub struct AsyncHandlerGuard {
73    previous: Option<Arc<AsyncInteractionHandler>>,
74}
75
76impl Drop for AsyncHandlerGuard {
77    fn drop(&mut self) {
78        let mut slot = async_handler_slot()
79            .write()
80            .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
81        *slot = self.previous.take();
82    }
83}
84
85pub fn replace_async_handler(handler: Option<Arc<AsyncInteractionHandler>>) -> AsyncHandlerGuard {
86    let mut slot = async_handler_slot()
87        .write()
88        .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
89    let previous = std::mem::replace(&mut *slot, handler);
90    AsyncHandlerGuard { previous }
91}
92
93pub async fn request_line_async(prompt: &str, echo: bool) -> Result<String, RuntimeError> {
94    if let Some(response) = QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take()) {
95        return match response
96            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
97        {
98            InteractionResponse::Line(value) => Ok(value),
99            InteractionResponse::KeyPress => Err(interaction_error(
100                "RunMat:interaction:UnexpectedQueuedKeypress",
101                "queued keypress response used for line request",
102            )),
103        };
104    }
105
106    if let Some(handler) = async_handler_slot()
107        .read()
108        .ok()
109        .and_then(|slot| slot.clone())
110    {
111        let owned = InteractionPromptOwned {
112            prompt: prompt.to_string(),
113            kind: InteractionKind::Line { echo },
114        };
115        let value = handler(owned)
116            .await
117            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
118        return match value {
119            InteractionResponse::Line(line) => Ok(line),
120            InteractionResponse::KeyPress => Err(interaction_error(
121                "RunMat:interaction:UnexpectedAsyncKeypress",
122                "interaction async handler returned keypress for line request",
123            )),
124        };
125    }
126
127    default_read_line(prompt, echo)
128        .map_err(|err| interaction_error("RunMat:interaction:ReadLineFailed", err))
129}
130
131pub async fn wait_for_key_async(prompt: &str) -> Result<(), RuntimeError> {
132    if let Some(response) = QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take()) {
133        return match response
134            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
135        {
136            InteractionResponse::Line(_) => Err(interaction_error(
137                "RunMat:interaction:UnexpectedQueuedLine",
138                "queued line response used for keypress request",
139            )),
140            InteractionResponse::KeyPress => Ok(()),
141        };
142    }
143
144    if let Some(handler) = async_handler_slot()
145        .read()
146        .ok()
147        .and_then(|slot| slot.clone())
148    {
149        let owned = InteractionPromptOwned {
150            prompt: prompt.to_string(),
151            kind: InteractionKind::KeyPress,
152        };
153        let value = handler(owned)
154            .await
155            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
156        return match value {
157            InteractionResponse::Line(_) => Err(interaction_error(
158                "RunMat:interaction:UnexpectedAsyncLine",
159                "interaction async handler returned line value for keypress request",
160            )),
161            InteractionResponse::KeyPress => Ok(()),
162        };
163    }
164
165    default_wait_for_key(prompt)
166        .map_err(|err| interaction_error("RunMat:interaction:WaitForKeyFailed", err))
167}
168
169pub fn default_read_line(prompt: &str, echo: bool) -> Result<String, String> {
170    #[cfg(target_arch = "wasm32")]
171    {
172        let _ = (prompt, echo);
173        Err("stdin input is not available on wasm targets".to_string())
174    }
175    #[cfg(not(target_arch = "wasm32"))]
176    {
177        if !prompt.is_empty() {
178            let mut stdout = io::stdout();
179            write!(stdout, "{prompt}")
180                .map_err(|err| format!("input: failed to write prompt ({err})"))?;
181            stdout
182                .flush()
183                .map_err(|err| format!("input: failed to flush stdout ({err})"))?;
184        }
185        let mut line = String::new();
186        let stdin = io::stdin();
187        stdin
188            .read_line(&mut line)
189            .map_err(|err| format!("input: failed to read from stdin ({err})"))?;
190        if !echo {
191            // When echo is disabled we still read the full line; no additional handling needed.
192        }
193        Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
194    }
195}
196
197pub fn default_wait_for_key(prompt: &str) -> Result<(), String> {
198    #[cfg(target_arch = "wasm32")]
199    {
200        let _ = prompt;
201        Err("keypress input is not available on wasm targets".to_string())
202    }
203    #[cfg(not(target_arch = "wasm32"))]
204    {
205        if !prompt.is_empty() {
206            let mut stdout = io::stdout();
207            write!(stdout, "{prompt}")
208                .map_err(|err| format!("pause: failed to write prompt ({err})"))?;
209            stdout
210                .flush()
211                .map_err(|err| format!("pause: failed to flush stdout ({err})"))?;
212        }
213        let stdin = io::stdin();
214        if !stdin.is_terminal() && !force_interactive_stdin() {
215            return Ok(());
216        }
217        let mut handle = stdin.lock();
218        let mut buf = [0u8; 1];
219        handle
220            .read(&mut buf)
221            .map_err(|err| format!("pause: failed to read from stdin ({err})"))?;
222        Ok(())
223    }
224}
225
226pub fn push_queued_response(response: Result<InteractionResponse, String>) {
227    QUEUED_RESPONSE.with(|slot| {
228        *slot.borrow_mut() = Some(response);
229    });
230}
231
232// NOTE: The old suspend/resume control flow has been removed.