1use once_cell::sync::OnceCell;
2use runmat_thread_local::runmat_thread_local;
3use runmat_value::Value;
4use std::cell::RefCell;
5use std::future::Future;
6use std::pin::Pin;
7
8use crate::{build_runtime_error, RuntimeError};
9#[cfg(not(target_arch = "wasm32"))]
10use std::io::IsTerminal;
11#[cfg(not(target_arch = "wasm32"))]
12use std::io::{self, Read, Write};
13#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, RwLock};
16
17pub use runmat_async::InteractionKind;
18
19#[derive(Clone)]
20pub struct InteractionPromptOwned {
21 pub prompt: String,
22 pub kind: InteractionKind,
23}
24
25#[derive(Clone)]
26pub enum InteractionResponse {
27 Line(String),
28 KeyPress,
29}
30
31pub type AsyncInteractionFuture =
32 Pin<Box<dyn Future<Output = Result<InteractionResponse, String>> + 'static>>;
33
34pub type AsyncInteractionHandler =
35 dyn Fn(InteractionPromptOwned) -> AsyncInteractionFuture + Send + Sync;
36
37static ASYNC_HANDLER: OnceCell<RwLock<Option<Arc<AsyncInteractionHandler>>>> = OnceCell::new();
38runmat_thread_local! {
39 static QUEUED_RESPONSE: RefCell<Option<Result<InteractionResponse, String>>> =
40 const { RefCell::new(None) };
41}
42
43#[derive(Default)]
44pub(crate) struct InteractionState {
45 async_handler: Option<Arc<AsyncInteractionHandler>>,
46 queued_response: Option<Result<InteractionResponse, String>>,
47 eval_hook: Option<Arc<EvalHookFn>>,
48}
49
50impl std::fmt::Debug for InteractionState {
51 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 formatter
53 .debug_struct("InteractionState")
54 .field("async_handler", &self.async_handler.is_some())
55 .field("queued_response", &self.queued_response.is_some())
56 .field("eval_hook", &self.eval_hook.is_some())
57 .finish()
58 }
59}
60
61#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
62static FORCE_INTERACTIVE_STDIN: AtomicBool = AtomicBool::new(false);
63
64#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
65pub fn force_interactive_stdin_for_tests(enable: bool) {
66 FORCE_INTERACTIVE_STDIN.store(enable, Ordering::Relaxed);
67}
68
69#[cfg(all(not(feature = "interaction-test-hooks"), not(target_arch = "wasm32")))]
70#[inline]
71fn force_interactive_stdin() -> bool {
72 false
73}
74
75#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
76#[inline]
77fn force_interactive_stdin() -> bool {
78 FORCE_INTERACTIVE_STDIN.load(Ordering::Relaxed)
79}
80
81fn async_handler_slot() -> &'static RwLock<Option<Arc<AsyncInteractionHandler>>> {
82 ASYNC_HANDLER.get_or_init(|| RwLock::new(None))
83}
84
85fn interaction_error(identifier: &str, message: impl Into<String>) -> RuntimeError {
86 build_runtime_error(message)
87 .with_identifier(identifier.to_string())
88 .build()
89}
90
91pub struct AsyncHandlerGuard {
92 previous: Option<Arc<AsyncInteractionHandler>>,
93 state: Option<std::rc::Rc<crate::context::RuntimeContextState>>,
94}
95
96impl Drop for AsyncHandlerGuard {
97 fn drop(&mut self) {
98 if let Some(state) = &self.state {
99 state.interaction.borrow_mut().async_handler = self.previous.take();
100 } else {
101 let mut slot = async_handler_slot()
102 .write()
103 .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
104 *slot = self.previous.take();
105 }
106 }
107}
108
109pub fn replace_async_handler(handler: Option<Arc<AsyncInteractionHandler>>) -> AsyncHandlerGuard {
110 if let Some(state) = active_state() {
111 let previous =
112 std::mem::replace(&mut state.interaction.borrow_mut().async_handler, handler);
113 return AsyncHandlerGuard {
114 previous,
115 state: Some(state),
116 };
117 }
118 let mut slot = async_handler_slot()
119 .write()
120 .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
121 let previous = std::mem::replace(&mut *slot, handler);
122 AsyncHandlerGuard {
123 previous,
124 state: None,
125 }
126}
127
128pub async fn request_line_async(prompt: &str, echo: bool) -> Result<String, RuntimeError> {
129 if let Some(response) = take_queued_response() {
130 return match response
131 .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
132 {
133 InteractionResponse::Line(value) => Ok(value),
134 InteractionResponse::KeyPress => Err(interaction_error(
135 "RunMat:interaction:UnexpectedQueuedKeypress",
136 "queued keypress response used for line request",
137 )),
138 };
139 }
140
141 if let Some(context) = crate::context::legacy::active() {
142 if let Some(host) = context.service_ports().host() {
143 return match host
144 .interact(crate::context::HostInteraction::Line {
145 prompt: prompt.to_string(),
146 echo,
147 })
148 .await?
149 {
150 InteractionResponse::Line(line) => Ok(line),
151 InteractionResponse::KeyPress => Err(interaction_error(
152 "RunMat:interaction:UnexpectedAsyncKeypress",
153 "runtime host returned keypress for line request",
154 )),
155 };
156 }
157 }
158
159 if let Some(handler) = current_async_handler() {
160 let owned = InteractionPromptOwned {
161 prompt: prompt.to_string(),
162 kind: InteractionKind::Line { echo },
163 };
164 let value = handler(owned)
165 .await
166 .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
167 return match value {
168 InteractionResponse::Line(line) => Ok(line),
169 InteractionResponse::KeyPress => Err(interaction_error(
170 "RunMat:interaction:UnexpectedAsyncKeypress",
171 "interaction async handler returned keypress for line request",
172 )),
173 };
174 }
175
176 default_read_line(prompt, echo)
177 .map_err(|err| interaction_error("RunMat:interaction:ReadLineFailed", err))
178}
179
180pub async fn wait_for_key_async(prompt: &str) -> Result<(), RuntimeError> {
181 if let Some(response) = take_queued_response() {
182 return match response
183 .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
184 {
185 InteractionResponse::Line(_) => Err(interaction_error(
186 "RunMat:interaction:UnexpectedQueuedLine",
187 "queued line response used for keypress request",
188 )),
189 InteractionResponse::KeyPress => Ok(()),
190 };
191 }
192
193 if let Some(context) = crate::context::legacy::active() {
194 if let Some(host) = context.service_ports().host() {
195 return match host
196 .interact(crate::context::HostInteraction::KeyPress {
197 prompt: prompt.to_string(),
198 })
199 .await?
200 {
201 InteractionResponse::Line(_) => Err(interaction_error(
202 "RunMat:interaction:UnexpectedAsyncLine",
203 "runtime host returned line value for keypress request",
204 )),
205 InteractionResponse::KeyPress => Ok(()),
206 };
207 }
208 }
209
210 if let Some(handler) = current_async_handler() {
211 let owned = InteractionPromptOwned {
212 prompt: prompt.to_string(),
213 kind: InteractionKind::KeyPress,
214 };
215 let value = handler(owned)
216 .await
217 .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
218 return match value {
219 InteractionResponse::Line(_) => Err(interaction_error(
220 "RunMat:interaction:UnexpectedAsyncLine",
221 "interaction async handler returned line value for keypress request",
222 )),
223 InteractionResponse::KeyPress => Ok(()),
224 };
225 }
226
227 default_wait_for_key(prompt)
228 .map_err(|err| interaction_error("RunMat:interaction:WaitForKeyFailed", err))
229}
230
231pub fn default_read_line(prompt: &str, echo: bool) -> Result<String, String> {
232 #[cfg(target_arch = "wasm32")]
233 {
234 let _ = (prompt, echo);
235 Err("stdin input is not available on wasm targets".to_string())
236 }
237 #[cfg(not(target_arch = "wasm32"))]
238 {
239 if !prompt.is_empty() {
240 let mut stdout = io::stdout();
241 write!(stdout, "{prompt}")
242 .map_err(|err| format!("input: failed to write prompt ({err})"))?;
243 stdout
244 .flush()
245 .map_err(|err| format!("input: failed to flush stdout ({err})"))?;
246 }
247 let mut line = String::new();
248 let stdin = io::stdin();
249 let bytes_read = stdin
250 .read_line(&mut line)
251 .map_err(|err| format!("input: failed to read from stdin ({err})"))?;
252 normalize_line_read(line, bytes_read, echo)
253 }
254}
255
256#[cfg(not(target_arch = "wasm32"))]
257fn normalize_line_read(line: String, bytes_read: usize, echo: bool) -> Result<String, String> {
258 if bytes_read == 0 {
259 return Err("input: stdin reached EOF before input was available".to_string());
260 }
261 if !echo {
262 }
264 Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
265}
266
267pub fn default_wait_for_key(prompt: &str) -> Result<(), String> {
268 #[cfg(target_arch = "wasm32")]
269 {
270 let _ = prompt;
271 Err("keypress input is not available on wasm targets".to_string())
272 }
273 #[cfg(not(target_arch = "wasm32"))]
274 {
275 if !prompt.is_empty() {
276 let mut stdout = io::stdout();
277 write!(stdout, "{prompt}")
278 .map_err(|err| format!("pause: failed to write prompt ({err})"))?;
279 stdout
280 .flush()
281 .map_err(|err| format!("pause: failed to flush stdout ({err})"))?;
282 }
283 let stdin = io::stdin();
284 if !stdin.is_terminal() && !force_interactive_stdin() {
285 return Ok(());
286 }
287 let mut handle = stdin.lock();
288 let mut buf = [0u8; 1];
289 handle
290 .read(&mut buf)
291 .map_err(|err| format!("pause: failed to read from stdin ({err})"))?;
292 Ok(())
293 }
294}
295
296pub fn push_queued_response(response: Result<InteractionResponse, String>) {
297 if let Some(state) = active_state() {
298 state.interaction.borrow_mut().queued_response = Some(response);
299 return;
300 }
301 QUEUED_RESPONSE.with(|slot| {
302 *slot.borrow_mut() = Some(response);
303 });
304}
305
306pub type EvalHookFuture = Pin<Box<dyn Future<Output = Result<Value, RuntimeError>> + 'static>>;
317
318pub type EvalHookFn = dyn Fn(String) -> EvalHookFuture + Send + Sync;
320
321static EVAL_HOOK: OnceCell<RwLock<Option<Arc<EvalHookFn>>>> = OnceCell::new();
322
323fn eval_hook_slot() -> &'static RwLock<Option<Arc<EvalHookFn>>> {
324 EVAL_HOOK.get_or_init(|| RwLock::new(None))
325}
326
327pub struct EvalHookGuard {
329 previous: Option<Arc<EvalHookFn>>,
330 state: Option<std::rc::Rc<crate::context::RuntimeContextState>>,
331}
332
333impl Drop for EvalHookGuard {
334 fn drop(&mut self) {
335 if let Some(state) = &self.state {
336 state.interaction.borrow_mut().eval_hook = self.previous.take();
337 } else {
338 let mut slot = eval_hook_slot()
339 .write()
340 .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
341 *slot = self.previous.take();
342 }
343 }
344}
345
346pub fn replace_eval_hook(hook: Option<Arc<EvalHookFn>>) -> EvalHookGuard {
349 if let Some(state) = active_state() {
350 let previous = std::mem::replace(&mut state.interaction.borrow_mut().eval_hook, hook);
351 return EvalHookGuard {
352 previous,
353 state: Some(state),
354 };
355 }
356 let mut slot = eval_hook_slot()
357 .write()
358 .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
359 let previous = std::mem::replace(&mut *slot, hook);
360 EvalHookGuard {
361 previous,
362 state: None,
363 }
364}
365
366pub fn current_eval_hook() -> Option<Arc<EvalHookFn>> {
368 if let Some(state) = active_state() {
369 return state.interaction.borrow().eval_hook.clone();
370 }
371 eval_hook_slot().read().ok().and_then(|slot| slot.clone())
372}
373
374fn current_async_handler() -> Option<Arc<AsyncInteractionHandler>> {
375 if let Some(state) = active_state() {
376 return state.interaction.borrow().async_handler.clone();
377 }
378 async_handler_slot()
379 .read()
380 .ok()
381 .and_then(|slot| slot.clone())
382}
383
384fn take_queued_response() -> Option<Result<InteractionResponse, String>> {
385 if let Some(state) = active_state() {
386 return state.interaction.borrow_mut().queued_response.take();
387 }
388 QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take())
389}
390
391fn active_state() -> Option<std::rc::Rc<crate::context::RuntimeContextState>> {
392 crate::context::legacy::active().map(|context| std::rc::Rc::clone(context.state()))
393}
394
395#[cfg(all(test, not(target_arch = "wasm32")))]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn eof_line_read_is_interaction_error() {
401 let err = normalize_line_read(String::new(), 0, true).expect_err("EOF should fail");
402 assert!(err.contains("stdin reached EOF"));
403 }
404
405 #[test]
406 fn blank_line_with_bytes_is_valid_empty_response() {
407 let line = normalize_line_read("\n".to_string(), 1, true).expect("blank line");
408 assert_eq!(line, "");
409 }
410}