Skip to main content

harn_vm/stdlib/
io.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, VecDeque};
3#[cfg(not(unix))]
4use std::io::BufRead;
5use std::io::{IsTerminal, Read, Write};
6use std::marker::PhantomData;
7use std::rc::Rc;
8use std::sync::atomic::Ordering;
9use std::sync::Mutex;
10#[cfg(unix)]
11use std::time::{Duration, Instant};
12
13use crate::stdlib::args::Args;
14use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
15use crate::value::{VmError, VmValue};
16use crate::vm::Vm;
17
18use super::logging::{vm_build_log_line, vm_escape_json_str_quoted, VM_MIN_LOG_LEVEL};
19
20#[derive(Clone, Copy, Default)]
21struct TtyMock {
22    stdin: Option<bool>,
23    stdout: Option<bool>,
24    stderr: Option<bool>,
25}
26
27#[derive(Clone, Copy, Default, PartialEq)]
28enum ColorMode {
29    #[default]
30    Auto,
31    Always,
32    Never,
33}
34
35#[derive(Clone, Debug)]
36struct ReadLineOptions {
37    prompt: String,
38    timeout_ms: Option<u64>,
39    trim: bool,
40    echo: bool,
41    raw: bool,
42}
43
44impl Default for ReadLineOptions {
45    fn default() -> Self {
46        Self {
47            prompt: String::new(),
48            timeout_ms: None,
49            trim: true,
50            echo: true,
51            raw: false,
52        }
53    }
54}
55
56#[derive(Debug, PartialEq, Eq)]
57enum ReadLineOutcome {
58    Ok(String),
59    Eof,
60    #[cfg(unix)]
61    Timeout,
62    #[cfg(unix)]
63    Interrupt,
64    Error(String),
65}
66
67enum MockReadLine {
68    Line(String),
69    Eof,
70    Unset,
71}
72
73thread_local! {
74    static STDIN_MOCK: RefCell<Option<String>> = const { RefCell::new(None) };
75    static STDIN_LINES: RefCell<Option<VecDeque<String>>> = const { RefCell::new(None) };
76    static STDIN_ALLOWED: Cell<bool> = const { Cell::new(true) };
77    static STDOUT_ALLOWED: Cell<bool> = const { Cell::new(true) };
78    static STDERR_BUFFER: RefCell<String> = const { RefCell::new(String::new()) };
79    static STDERR_CAPTURING: RefCell<bool> = const { RefCell::new(false) };
80    static STDOUT_PASSTHROUGH: RefCell<bool> = const { RefCell::new(false) };
81    static TTY_MOCK: RefCell<TtyMock> = const { RefCell::new(TtyMock { stdin: None, stdout: None, stderr: None }) };
82    static COLOR_MODE: RefCell<ColorMode> = const { RefCell::new(ColorMode::Auto) };
83}
84
85static STDIN_READ_LOCK: Mutex<()> = Mutex::new(());
86
87/// Restores current-thread stdio ownership when dropped.
88#[must_use]
89pub struct StdioReservationGuard {
90    stdin_previous: bool,
91    stdout_previous: bool,
92    _thread_bound: PhantomData<Rc<()>>,
93}
94
95impl Drop for StdioReservationGuard {
96    fn drop(&mut self) {
97        STDIN_ALLOWED.set(self.stdin_previous);
98        STDOUT_ALLOWED.set(self.stdout_previous);
99    }
100}
101
102/// Reserve ambient stdin and stdout for a control protocol on this thread.
103///
104/// Guest stdin appears closed, explicit stdin mocks still take precedence,
105/// and stdout remains captured by the VM instead of reaching the protocol.
106pub fn reserve_stdio_for_current_thread() -> StdioReservationGuard {
107    StdioReservationGuard {
108        stdin_previous: STDIN_ALLOWED.replace(false),
109        stdout_previous: STDOUT_ALLOWED.replace(false),
110        _thread_bound: PhantomData,
111    }
112}
113
114pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
115    &LOG_BUILTIN_DEF,
116    &COLOR_BUILTIN_DEF,
117    &BOLD_BUILTIN_DEF,
118    &DIM_BUILTIN_DEF,
119    &SET_COLOR_MODE_BUILTIN_DEF,
120    &ANSI_ENABLED_BUILTIN_DEF,
121    &READ_STDIN_BUILTIN_DEF,
122    &IO_READ_LINE_BUILTIN_DEF,
123    &IO_WRITE_STDERR_BUILTIN_DEF,
124    &IO_WRITE_STDOUT_BUILTIN_DEF,
125    &IO_PRINT_BUILTIN_DEF,
126    &IO_PRINTLN_BUILTIN_DEF,
127    &IO_EPRINT_BUILTIN_DEF,
128    &IO_EPRINTLN_BUILTIN_DEF,
129    &IS_STDIN_TTY_BUILTIN_DEF,
130    &IS_STDOUT_TTY_BUILTIN_DEF,
131    &IS_STDERR_TTY_BUILTIN_DEF,
132    &MOCK_STDIN_BUILTIN_DEF,
133    &UNMOCK_STDIN_BUILTIN_DEF,
134    &MOCK_TTY_BUILTIN_DEF,
135    &UNMOCK_TTY_BUILTIN_DEF,
136    &CAPTURE_STDERR_START_BUILTIN_DEF,
137    &CAPTURE_STDERR_TAKE_BUILTIN_DEF,
138    &UUID_BUILTIN_DEF,
139    &UUID_PARSE_BUILTIN_DEF,
140    &UUID_V7_BUILTIN_DEF,
141    &UUID_V5_BUILTIN_DEF,
142    &UUID_NIL_BUILTIN_DEF,
143    &LOG_DEBUG_BUILTIN_DEF,
144    &LOG_INFO_BUILTIN_DEF,
145    &LOG_WARN_BUILTIN_DEF,
146    &LOG_ERROR_BUILTIN_DEF,
147    &LOG_SET_LEVEL_BUILTIN_DEF,
148    &PROGRESS_BUILTIN_DEF,
149    &LOG_JSON_BUILTIN_DEF,
150];
151
152/// Reset all io thread-local state for test isolation.
153pub(crate) fn reset_io_state() {
154    STDIN_MOCK.with(|s| *s.borrow_mut() = None);
155    STDIN_LINES.with(|s| *s.borrow_mut() = None);
156    STDERR_BUFFER.with(|s| s.borrow_mut().clear());
157    STDERR_CAPTURING.with(|s| *s.borrow_mut() = false);
158    STDOUT_PASSTHROUGH.with(|s| *s.borrow_mut() = false);
159    TTY_MOCK.with(|t| *t.borrow_mut() = TtyMock::default());
160    COLOR_MODE.with(|m| *m.borrow_mut() = ColorMode::Auto);
161}
162
163/// Enable or disable direct stdout writes for CLI-style runs.
164///
165/// The VM normally captures stdout in-memory so tests and embedding callers
166/// can inspect it after execution. Interactive CLI programs need prompts to
167/// appear before `read_line()` blocks, so `harn run` enables this mode and
168/// streams `print`/`println`/`log` immediately.
169pub fn set_stdout_passthrough(enabled: bool) -> bool {
170    STDOUT_PASSTHROUGH.with(|state| {
171        let previous = *state.borrow();
172        *state.borrow_mut() = enabled;
173        previous
174    })
175}
176
177/// Drain and return the buffered stderr output. The CLI flushes this to
178/// the real stderr at the end of execution.
179pub fn take_stderr_buffer() -> String {
180    STDERR_BUFFER.with(|s| std::mem::take(&mut *s.borrow_mut()))
181}
182
183pub(crate) fn write_stderr(line: &str) {
184    if crate::run_events::sink_active() {
185        crate::run_events::emit(crate::run_events::RunEvent::Stderr {
186            payload: line.to_string(),
187        });
188        return;
189    }
190    let capturing = STDERR_CAPTURING.with(|c| *c.borrow());
191    if capturing {
192        STDERR_BUFFER.with(|s| s.borrow_mut().push_str(line));
193    } else {
194        let mut stderr = std::io::stderr().lock();
195        let _ = stderr.write_all(line.as_bytes());
196        let _ = stderr.flush();
197    }
198}
199
200pub(crate) fn write_stdout(out: &mut String, text: &str) {
201    if crate::run_events::sink_active() {
202        crate::run_events::emit(crate::run_events::RunEvent::Stdout {
203            payload: text.to_string(),
204        });
205        return;
206    }
207    if STDOUT_ALLOWED.get() && stdout_passthrough_enabled() {
208        let mut stdout = std::io::stdout().lock();
209        let _ = stdout.write_all(text.as_bytes());
210        let _ = stdout.flush();
211    } else {
212        out.push_str(text);
213    }
214}
215
216pub(crate) fn write_ambient_stdout(text: &str) {
217    if !STDOUT_ALLOWED.get() {
218        return;
219    }
220    let mut stdout = std::io::stdout().lock();
221    let _ = stdout.write_all(text.as_bytes());
222    let _ = stdout.flush();
223}
224
225fn stdout_passthrough_enabled() -> bool {
226    STDOUT_PASSTHROUGH.with(|state| *state.borrow())
227}
228
229fn read_stdin_all_real() -> Option<String> {
230    let mut buf = String::new();
231    if std::io::stdin().lock().read_to_string(&mut buf).is_ok() {
232        Some(buf)
233    } else {
234        None
235    }
236}
237
238#[cfg(not(unix))]
239fn read_stdin_line_real() -> Option<String> {
240    let mut buf = String::new();
241    if std::io::stdin().lock().read_line(&mut buf).is_ok() {
242        if buf.is_empty() {
243            None
244        } else {
245            // Trim trailing \n / \r\n but keep internal whitespace.
246            if buf.ends_with('\n') {
247                buf.pop();
248                if buf.ends_with('\r') {
249                    buf.pop();
250                }
251            }
252            Some(buf)
253        }
254    } else {
255        None
256    }
257}
258
259fn pop_mock_line() -> MockReadLine {
260    STDIN_LINES.with(|lines| {
261        let mut borrow = lines.borrow_mut();
262        if let Some(queue) = borrow.as_mut() {
263            return queue
264                .pop_front()
265                .map(MockReadLine::Line)
266                .unwrap_or(MockReadLine::Eof);
267        }
268        MockReadLine::Unset
269    })
270}
271
272fn read_mock_line() -> MockReadLine {
273    match pop_mock_line() {
274        MockReadLine::Unset => {}
275        other => return other,
276    }
277    let bulk = STDIN_MOCK.with(|s| s.borrow_mut().take());
278    let Some(text) = bulk else {
279        return MockReadLine::Unset;
280    };
281    let mut lines: VecDeque<String> = text.split('\n').map(String::from).collect();
282    // Keep legacy read_line semantics: a final newline terminates the last
283    // line rather than producing one more empty line.
284    if matches!(lines.back(), Some(line) if line.is_empty()) {
285        lines.pop_back();
286    }
287    let first = lines.pop_front();
288    STDIN_LINES.with(|q| *q.borrow_mut() = Some(lines));
289    first.map(MockReadLine::Line).unwrap_or(MockReadLine::Eof)
290}
291
292fn normalize_read_line_value(mut line: String, trim: bool) -> String {
293    if line.ends_with('\r') {
294        line.pop();
295    }
296    if trim {
297        line.trim().to_string()
298    } else {
299        line
300    }
301}
302
303fn read_line_result(outcome: ReadLineOutcome) -> VmValue {
304    let mut out = BTreeMap::new();
305    match outcome {
306        ReadLineOutcome::Ok(value) => {
307            out.insert("ok".to_string(), VmValue::Bool(true));
308            out.insert("status".to_string(), VmValue::string("ok"));
309            out.insert("value".to_string(), VmValue::string(value));
310        }
311        ReadLineOutcome::Eof => {
312            out.insert("ok".to_string(), VmValue::Bool(false));
313            out.insert("status".to_string(), VmValue::string("eof"));
314        }
315        #[cfg(unix)]
316        ReadLineOutcome::Timeout => {
317            out.insert("ok".to_string(), VmValue::Bool(false));
318            out.insert("status".to_string(), VmValue::string("timeout"));
319        }
320        #[cfg(unix)]
321        ReadLineOutcome::Interrupt => {
322            out.insert("ok".to_string(), VmValue::Bool(false));
323            out.insert("status".to_string(), VmValue::string("interrupt"));
324        }
325        ReadLineOutcome::Error(error) => {
326            out.insert("ok".to_string(), VmValue::Bool(false));
327            out.insert("status".to_string(), VmValue::string("error"));
328            out.insert("error".to_string(), VmValue::string(error));
329        }
330    }
331    VmValue::dict(out)
332}
333
334const READ_LINE_FN: &str = "std/io.read_line";
335
336fn parse_read_line_options(args: &[VmValue]) -> Result<ReadLineOptions, VmError> {
337    let reader = Args::runtime(READ_LINE_FN, args);
338    reader.arity(0, 1)?;
339    let mut parser = reader.options(0, "options")?;
340    let options = ReadLineOptions {
341        // The prompt is written to the terminal verbatim, so trailing
342        // whitespace in `{ prompt: "name: " }` is part of the contract.
343        prompt: parser.opt_string("prompt")?.unwrap_or_default().to_string(),
344        timeout_ms: parser.opt_millis("timeout_ms")?,
345        trim: parser.bool_or("trim", true)?,
346        echo: parser.bool_or("echo", true)?,
347        raw: parser.bool_or("raw", false)?,
348    };
349    parser.finish(&[])?;
350    Ok(options)
351}
352
353fn read_line_from_mock_or_real(options: &ReadLineOptions) -> ReadLineOutcome {
354    let _lock = match STDIN_READ_LOCK.lock() {
355        Ok(lock) => lock,
356        Err(_) => return ReadLineOutcome::Error("stdin read lock is poisoned".to_string()),
357    };
358    if !options.prompt.is_empty() {
359        write_stderr(&options.prompt);
360    }
361    match read_mock_line() {
362        MockReadLine::Line(line) => {
363            return ReadLineOutcome::Ok(normalize_read_line_value(line, options.trim));
364        }
365        MockReadLine::Eof => return ReadLineOutcome::Eof,
366        MockReadLine::Unset => {}
367    }
368    if !STDIN_ALLOWED.get() {
369        return ReadLineOutcome::Eof;
370    }
371    read_stdin_line_real_with_options(options)
372}
373
374#[cfg(unix)]
375struct TerminalModeGuard {
376    fd: libc::c_int,
377    original: Option<libc::termios>,
378}
379
380#[cfg(unix)]
381impl TerminalModeGuard {
382    fn install(fd: libc::c_int, options: &ReadLineOptions) -> Result<Self, String> {
383        let mut original = std::mem::MaybeUninit::<libc::termios>::uninit();
384        let fd_is_terminal = unsafe { libc::isatty(fd) == 1 };
385        if !fd_is_terminal || (options.echo && !options.raw) {
386            return Ok(Self { fd, original: None });
387        }
388        if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 {
389            return Err(std::io::Error::last_os_error().to_string());
390        }
391        let original = unsafe { original.assume_init() };
392        let mut updated = original;
393        if !options.echo {
394            updated.c_lflag &= !libc::ECHO;
395        }
396        if options.raw {
397            updated.c_lflag &= !libc::ICANON;
398            updated.c_cc[libc::VMIN] = 0;
399            updated.c_cc[libc::VTIME] = 0;
400        }
401        if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw const updated) } != 0 {
402            return Err(std::io::Error::last_os_error().to_string());
403        }
404        Ok(Self {
405            fd,
406            original: Some(original),
407        })
408    }
409}
410
411#[cfg(unix)]
412impl Drop for TerminalModeGuard {
413    fn drop(&mut self) {
414        if let Some(original) = &self.original {
415            let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, original) };
416        }
417    }
418}
419
420#[cfg(unix)]
421const READ_LINE_INTERRUPT_POLL: Duration = Duration::from_millis(20);
422
423#[cfg(unix)]
424fn read_line_elapsed_ms(start: Instant) -> u64 {
425    start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
426}
427
428#[cfg(unix)]
429fn read_line_timeout_remaining_ms(options: &ReadLineOptions, start: Instant) -> Option<u64> {
430    let timeout_ms = options.timeout_ms?;
431    Some(timeout_ms.saturating_sub(read_line_elapsed_ms(start)))
432}
433
434#[cfg(unix)]
435fn read_line_timed_out(options: &ReadLineOptions, start: Instant) -> bool {
436    matches!(read_line_timeout_remaining_ms(options, start), Some(0))
437}
438
439#[cfg(unix)]
440fn read_line_interrupt_poll_ms() -> libc::c_int {
441    READ_LINE_INTERRUPT_POLL
442        .as_millis()
443        .min(libc::c_int::MAX as u128) as libc::c_int
444}
445
446#[cfg(unix)]
447fn poll_timeout(options: &ReadLineOptions, start: Instant) -> libc::c_int {
448    let heartbeat = crate::op_interrupt::installed().then_some(read_line_interrupt_poll_ms());
449    match (read_line_timeout_remaining_ms(options, start), heartbeat) {
450        (Some(remaining), Some(heartbeat)) => {
451            remaining.min(heartbeat as u64).min(libc::c_int::MAX as u64) as libc::c_int
452        }
453        (Some(remaining), None) => remaining.min(libc::c_int::MAX as u64) as libc::c_int,
454        (None, Some(heartbeat)) => heartbeat,
455        (None, None) => -1,
456    }
457}
458
459#[cfg(unix)]
460fn finish_read_line(bytes: Vec<u8>, trim: bool) -> ReadLineOutcome {
461    match String::from_utf8(bytes) {
462        Ok(line) => ReadLineOutcome::Ok(normalize_read_line_value(line, trim)),
463        Err(_) => ReadLineOutcome::Error("stdin line was not valid UTF-8".to_string()),
464    }
465}
466
467#[cfg(unix)]
468fn read_line_from_fd_unix(fd: libc::c_int, options: &ReadLineOptions) -> ReadLineOutcome {
469    let _terminal_mode = match TerminalModeGuard::install(fd, options) {
470        Ok(guard) => guard,
471        Err(error) => return ReadLineOutcome::Error(error),
472    };
473    let start = Instant::now();
474    let mut bytes = Vec::new();
475    loop {
476        if crate::op_interrupt::requested() {
477            return ReadLineOutcome::Interrupt;
478        }
479        let mut pollfd = libc::pollfd {
480            fd,
481            events: libc::POLLIN,
482            revents: 0,
483        };
484        let ready = unsafe { libc::poll(&raw mut pollfd, 1, poll_timeout(options, start)) };
485        if ready == 0 {
486            if read_line_timed_out(options, start) {
487                return ReadLineOutcome::Timeout;
488            }
489            continue;
490        }
491        if ready < 0 {
492            let error = std::io::Error::last_os_error();
493            if error.raw_os_error() == Some(libc::EINTR) {
494                return ReadLineOutcome::Interrupt;
495            }
496            return ReadLineOutcome::Error(error.to_string());
497        }
498        if pollfd.revents & libc::POLLNVAL != 0 {
499            return ReadLineOutcome::Error("stdin fd is invalid".to_string());
500        }
501        if pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) == 0 {
502            continue;
503        }
504        let mut byte = [0u8; 1];
505        let read = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
506        if read == 0 {
507            return if bytes.is_empty() {
508                ReadLineOutcome::Eof
509            } else {
510                finish_read_line(bytes, options.trim)
511            };
512        }
513        if read < 0 {
514            let error = std::io::Error::last_os_error();
515            match error.raw_os_error() {
516                Some(libc::EINTR) => return ReadLineOutcome::Interrupt,
517                Some(libc::EAGAIN) => continue,
518                _ => return ReadLineOutcome::Error(error.to_string()),
519            }
520        }
521        match byte[0] {
522            b'\n' => return finish_read_line(bytes, options.trim),
523            b'\r' if options.raw => return finish_read_line(bytes, options.trim),
524            0x03 if options.raw => return ReadLineOutcome::Interrupt,
525            0x04 if options.raw && bytes.is_empty() => return ReadLineOutcome::Eof,
526            0x04 if options.raw => return finish_read_line(bytes, options.trim),
527            value => bytes.push(value),
528        }
529    }
530}
531
532#[cfg(unix)]
533fn read_stdin_line_real_with_options(options: &ReadLineOptions) -> ReadLineOutcome {
534    read_line_from_fd_unix(libc::STDIN_FILENO, options)
535}
536
537#[cfg(not(unix))]
538fn read_stdin_line_real_with_options(options: &ReadLineOptions) -> ReadLineOutcome {
539    if !options.echo || options.raw {
540        return ReadLineOutcome::Error(
541            "std/io.read_line echo=false/raw=true is only implemented on Unix hosts".to_string(),
542        );
543    }
544    if options.timeout_ms.is_some() {
545        return ReadLineOutcome::Error(
546            "std/io.read_line timeout_ms is only implemented on Unix hosts".to_string(),
547        );
548    }
549    match read_stdin_line_real() {
550        Some(line) => ReadLineOutcome::Ok(normalize_read_line_value(line, options.trim)),
551        None => ReadLineOutcome::Eof,
552    }
553}
554
555pub(crate) fn is_tty_for(stream: &str) -> bool {
556    let mocked = TTY_MOCK.with(|t| {
557        let mock = *t.borrow();
558        match stream {
559            "stdin" => mock.stdin,
560            "stdout" => mock.stdout,
561            "stderr" => mock.stderr,
562            _ => None,
563        }
564    });
565    if let Some(v) = mocked {
566        return v;
567    }
568    match stream {
569        "stdin" => std::io::stdin().is_terminal(),
570        "stdout" => std::io::stdout().is_terminal(),
571        "stderr" => std::io::stderr().is_terminal(),
572        _ => false,
573    }
574}
575
576fn ansi_enabled_for_stream(stream: &str) -> bool {
577    let mode = COLOR_MODE.with(|m| *m.borrow());
578    match mode {
579        ColorMode::Always => true,
580        ColorMode::Never => false,
581        ColorMode::Auto => {
582            if std::env::var_os("FORCE_COLOR").is_some() {
583                return true;
584            }
585            if std::env::var_os("NO_COLOR").is_some() {
586                return false;
587            }
588            is_tty_for(stream)
589        }
590    }
591}
592
593pub(crate) fn register_io_builtins(vm: &mut Vm) {
594    for def in MODULE_BUILTINS {
595        vm.register_builtin_def(def);
596    }
597    use harn_builtin_meta::CapabilityId;
598    vm.register_capability_method(CapabilityId::Term, "set_color_mode", set_color_mode_builtin);
599    vm.register_capability_method(CapabilityId::Stdio, "read_stdin", read_stdin_builtin);
600    vm.register_capability_method(CapabilityId::Stdio, "is_stdin_tty", is_stdin_tty_builtin);
601    vm.register_capability_method(CapabilityId::Stdio, "is_stdout_tty", is_stdout_tty_builtin);
602    vm.register_capability_method(CapabilityId::Stdio, "is_stderr_tty", is_stderr_tty_builtin);
603    vm.register_capability_method(CapabilityId::Testing, "stdin_set", mock_stdin_builtin);
604    vm.register_capability_method(CapabilityId::Testing, "stdin_reset", unmock_stdin_builtin);
605    vm.register_capability_method(CapabilityId::Testing, "tty_set", mock_tty_builtin);
606    vm.register_capability_method(CapabilityId::Testing, "tty_reset", unmock_tty_builtin);
607    vm.register_capability_method(
608        CapabilityId::Testing,
609        "capture_stderr_start",
610        capture_stderr_start_builtin,
611    );
612    vm.register_capability_method(
613        CapabilityId::Testing,
614        "capture_stderr_take",
615        capture_stderr_take_builtin,
616    );
617}
618
619#[harn_builtin(
620    exposure = "harness.stdio.log",
621    effects = ["stdio.write@const=stdout"],
622    sig = "__cap_stdio_log(message: any) -> nil",
623    aliases = ["log"],
624    category = "io",
625    doc = "Write a Harn-prefixed message to stdout."
626)]
627fn log_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
628    let msg = args.first().map(|a| a.display()).unwrap_or_default();
629    write_stdout(out, &format!("[harn] {msg}\n"));
630    Ok(VmValue::Nil)
631}
632
633#[harn_builtin(
634    exposure = "pure",
635    effects = [],
636    sig = "color(text: any, color: string) -> string",
637    category = "io",
638    doc = "Apply an ANSI foreground color when color output is enabled."
639)]
640fn color_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
641    let text = args.first().map(|a| a.display()).unwrap_or_default();
642    let name = args.get(1).map(|a| a.display()).unwrap_or_default();
643    if !ansi_enabled_for_stream("stdout") {
644        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
645    }
646    Ok(VmValue::String(arcstr::ArcStr::from(ansi_colorize(
647        &text, &name,
648    ))))
649}
650
651#[harn_builtin(
652    exposure = "pure",
653    effects = [],
654    sig = "bold(text: any) -> string",
655    category = "io",
656    doc = "Apply ANSI bold styling when color output is enabled."
657)]
658fn bold_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
659    let text = args.first().map(|a| a.display()).unwrap_or_default();
660    if !ansi_enabled_for_stream("stdout") {
661        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
662    }
663    Ok(VmValue::String(arcstr::ArcStr::from(format!(
664        "\u{1b}[1m{text}\u{1b}[0m"
665    ))))
666}
667
668#[harn_builtin(
669    exposure = "pure",
670    effects = [],
671    sig = "dim(text: any) -> string",
672    category = "io",
673    doc = "Apply ANSI dim styling when color output is enabled."
674)]
675fn dim_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
676    let text = args.first().map(|a| a.display()).unwrap_or_default();
677    if !ansi_enabled_for_stream("stdout") {
678        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
679    }
680    Ok(VmValue::String(arcstr::ArcStr::from(format!(
681        "\u{1b}[2m{text}\u{1b}[0m"
682    ))))
683}
684
685#[harn_builtin(
686    exposure = "runtime_internal",
687    effects = [],
688    sig = "set_color_mode(mode: string) -> nil",
689    category = "io",
690    doc = "Set ANSI color handling to auto, always, or never."
691)]
692fn set_color_mode_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
693    let mode = args.first().map(|a| a.display()).unwrap_or_default();
694    let parsed = match mode.as_str() {
695        "auto" => ColorMode::Auto,
696        "always" => ColorMode::Always,
697        "never" => ColorMode::Never,
698        other => {
699            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
700                format!(
701                "set_color_mode: invalid mode '{other}'. Expected 'auto', 'always', or 'never'."
702            ),
703            ))));
704        }
705    };
706    COLOR_MODE.with(|m| *m.borrow_mut() = parsed);
707    Ok(VmValue::Nil)
708}
709
710#[harn_builtin(
711    exposure = "runtime_internal",
712    effects = [],
713    sig = "__ansi_enabled(stream?: string) -> bool",
714    category = "io",
715    doc = "Return whether ANSI styling is enabled for stdin, stdout, or stderr."
716)]
717fn ansi_enabled_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
718    let stream = args
719        .first()
720        .map(|a| a.display())
721        .unwrap_or_else(|| "stdout".to_string());
722    match stream.as_str() {
723        "stdin" | "stdout" | "stderr" => Ok(VmValue::Bool(ansi_enabled_for_stream(&stream))),
724        other => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
725            format!(
726            "__ansi_enabled: invalid stream '{other}'. Expected 'stdin', 'stdout', or 'stderr'."
727        ),
728        )))),
729    }
730}
731
732#[harn_builtin(
733    exposure = "runtime_internal",
734    effects = [],
735    sig = "read_stdin() -> string",
736    category = "io",
737    doc = "Read all remaining stdin as a string."
738)]
739fn read_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
740    // Drain any remaining mocked stdin first.
741    let mocked = STDIN_MOCK.with(|s| s.borrow_mut().take());
742    if let Some(buf) = mocked {
743        // After read_stdin, future read_line calls return nil because stdin is consumed.
744        STDIN_LINES.with(|lines| *lines.borrow_mut() = Some(VecDeque::new()));
745        return Ok(VmValue::String(arcstr::ArcStr::from(buf)));
746    }
747    if !STDIN_ALLOWED.get() {
748        return Ok(VmValue::Nil);
749    }
750    match read_stdin_all_real() {
751        Some(s) => Ok(VmValue::String(arcstr::ArcStr::from(s))),
752        None => Ok(VmValue::Nil),
753    }
754}
755
756pub(crate) fn read_line_legacy_value() -> VmValue {
757    let options = ReadLineOptions {
758        trim: false,
759        ..ReadLineOptions::default()
760    };
761    match read_line_from_mock_or_real(&options) {
762        ReadLineOutcome::Ok(line) => VmValue::String(arcstr::ArcStr::from(line)),
763        ReadLineOutcome::Eof => VmValue::Nil,
764        #[cfg(unix)]
765        ReadLineOutcome::Timeout => VmValue::Nil,
766        #[cfg(unix)]
767        ReadLineOutcome::Interrupt => VmValue::Nil,
768        ReadLineOutcome::Error(_) => VmValue::Nil,
769    }
770}
771
772pub(crate) fn read_line_structured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
773    let options = parse_read_line_options(args)?;
774    Ok(read_line_result(read_line_from_mock_or_real(&options)))
775}
776
777#[harn_builtin(
778    exposure = "runtime_internal",
779    effects = [],
780    sig = "__io_read_line(options?: any) -> dict",
781    category = "io",
782    doc = "Read one line from stdin with structured status metadata."
783)]
784fn io_read_line_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
785    read_line_structured_value(args)
786}
787
788#[harn_builtin(
789    exposure = "runtime_internal",
790    effects = [],
791    sig = "__io_write_stderr(message: any) -> nil",
792    category = "io",
793    doc = "Write text to stderr without appending a newline."
794)]
795fn io_write_stderr_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
796    let msg = args.first().map(|a| a.display()).unwrap_or_default();
797    write_stderr(&msg);
798    Ok(VmValue::Nil)
799}
800
801#[harn_builtin(
802    exposure = "runtime_internal",
803    effects = [],
804    sig = "__io_write_stdout(message: any) -> nil",
805    category = "io",
806    doc = "Write text to stdout without appending a newline."
807)]
808fn io_write_stdout_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
809    let msg = args.first().map(|a| a.display()).unwrap_or_default();
810    write_stdout(out, &msg);
811    Ok(VmValue::Nil)
812}
813
814#[harn_builtin(
815    exposure = "runtime_internal",
816    effects = [],
817    sig = "__io_print(...args: any) -> nil",
818    category = "io",
819    doc = "Internal compatibility bridge for stdout without newline."
820)]
821fn io_print_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
822    let msg = args.first().map(|a| a.display()).unwrap_or_default();
823    write_stdout(out, &msg);
824    Ok(VmValue::Nil)
825}
826
827#[harn_builtin(
828    exposure = "runtime_internal",
829    effects = [],
830    sig = "__io_println(...args: any) -> nil",
831    category = "io",
832    doc = "Internal compatibility bridge for stdout with newline."
833)]
834fn io_println_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
835    let msg = args.first().map(|a| a.display()).unwrap_or_default();
836    write_stdout(out, &format!("{msg}\n"));
837    Ok(VmValue::Nil)
838}
839
840#[harn_builtin(
841    exposure = "runtime_internal",
842    effects = [],
843    sig = "__io_eprint(message: any) -> nil",
844    category = "io",
845    doc = "Internal compatibility bridge for stderr without newline."
846)]
847fn io_eprint_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
848    let msg = args.first().map(|a| a.display()).unwrap_or_default();
849    write_stderr(&msg);
850    Ok(VmValue::Nil)
851}
852
853#[harn_builtin(
854    exposure = "runtime_internal",
855    effects = [],
856    sig = "__io_eprintln(message: any) -> nil",
857    category = "io",
858    doc = "Internal compatibility bridge for stderr with newline."
859)]
860fn io_eprintln_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
861    let msg = args.first().map(|a| a.display()).unwrap_or_default();
862    write_stderr(&format!("{msg}\n"));
863    Ok(VmValue::Nil)
864}
865
866#[harn_builtin(
867    exposure = "runtime_internal",
868    effects = [],
869    sig = "is_stdin_tty() -> bool",
870    category = "io",
871    doc = "Return whether stdin is attached to a terminal."
872)]
873fn is_stdin_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
874    Ok(VmValue::Bool(is_tty_for("stdin")))
875}
876
877#[harn_builtin(
878    exposure = "runtime_internal",
879    effects = [],
880    sig = "is_stdout_tty() -> bool",
881    category = "io",
882    doc = "Return whether stdout is attached to a terminal."
883)]
884fn is_stdout_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
885    Ok(VmValue::Bool(is_tty_for("stdout")))
886}
887
888#[harn_builtin(
889    exposure = "runtime_internal",
890    effects = [],
891    sig = "is_stderr_tty() -> bool",
892    category = "io",
893    doc = "Return whether stderr is attached to a terminal."
894)]
895fn is_stderr_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
896    Ok(VmValue::Bool(is_tty_for("stderr")))
897}
898
899#[harn_builtin(
900    exposure = "runtime_internal",
901    effects = [],
902    sig = "mock_stdin(text: string) -> nil",
903    category = "io",
904    doc = "Install mocked stdin text for tests."
905)]
906fn mock_stdin_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
907    let text = args.first().map(|a| a.display()).unwrap_or_default();
908    STDIN_MOCK.with(|s| *s.borrow_mut() = Some(text));
909    STDIN_LINES.with(|s| *s.borrow_mut() = None);
910    Ok(VmValue::Nil)
911}
912
913#[harn_builtin(
914    exposure = "runtime_internal",
915    effects = [],
916    sig = "unmock_stdin() -> nil",
917    category = "io",
918    doc = "Clear mocked stdin text and line state."
919)]
920fn unmock_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
921    STDIN_MOCK.with(|s| *s.borrow_mut() = None);
922    STDIN_LINES.with(|s| *s.borrow_mut() = None);
923    Ok(VmValue::Nil)
924}
925
926#[harn_builtin(
927    exposure = "runtime_internal",
928    effects = [],
929    sig = "mock_tty(stream: string, is_tty: bool) -> nil",
930    category = "io",
931    doc = "Override terminal detection for stdin, stdout, or stderr."
932)]
933fn mock_tty_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
934    let reader = Args::thrown("mock_tty", args);
935    let stream = reader.enum_string(0, "stream", &["stdin", "stdout", "stderr"])?;
936    let is_tty = reader.bool(1, "is_tty")?;
937    TTY_MOCK.with(|t| {
938        let mut mock = t.borrow_mut();
939        match stream {
940            "stdin" => mock.stdin = Some(is_tty),
941            "stdout" => mock.stdout = Some(is_tty),
942            _ => mock.stderr = Some(is_tty),
943        }
944        Ok(VmValue::Nil)
945    })
946}
947
948#[harn_builtin(
949    exposure = "runtime_internal",
950    effects = [],
951    sig = "unmock_tty() -> nil",
952    category = "io",
953    doc = "Clear terminal detection overrides."
954)]
955fn unmock_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
956    TTY_MOCK.with(|t| *t.borrow_mut() = TtyMock::default());
957    Ok(VmValue::Nil)
958}
959
960#[harn_builtin(
961    exposure = "runtime_internal",
962    effects = [],
963    sig = "capture_stderr_start() -> nil",
964    category = "io",
965    doc = "Start capturing stderr into an in-memory buffer."
966)]
967fn capture_stderr_start_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
968    STDERR_CAPTURING.with(|c| *c.borrow_mut() = true);
969    STDERR_BUFFER.with(|s| s.borrow_mut().clear());
970    Ok(VmValue::Nil)
971}
972
973#[harn_builtin(
974    exposure = "runtime_internal",
975    effects = [],
976    sig = "capture_stderr_take() -> string",
977    category = "io",
978    doc = "Stop stderr capture and return the buffered text."
979)]
980fn capture_stderr_take_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
981    let buf = STDERR_BUFFER.with(|s| std::mem::take(&mut *s.borrow_mut()));
982    STDERR_CAPTURING.with(|c| *c.borrow_mut() = false);
983    Ok(VmValue::String(arcstr::ArcStr::from(buf)))
984}
985
986#[harn_builtin(
987    exposure = "runtime_internal",
988    effects = [],
989    sig = "uuid() -> string",
990    category = "io",
991    doc = "Generate a random version 4 UUID."
992)]
993fn uuid_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
994    Ok(VmValue::String(arcstr::ArcStr::from(
995        uuid::Uuid::new_v4().to_string(),
996    )))
997}
998
999#[harn_builtin(
1000    exposure = "pure",
1001    effects = [],
1002    sig = "uuid_parse(value: any) -> string",
1003    category = "io",
1004    doc = "Parse and normalize a UUID string, or return nil."
1005)]
1006fn uuid_parse_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1007    let raw = args.first().map(|a| a.display()).unwrap_or_default();
1008    match uuid::Uuid::parse_str(&raw) {
1009        Ok(uuid) => Ok(VmValue::String(arcstr::ArcStr::from(uuid.to_string()))),
1010        Err(_) => Ok(VmValue::Nil),
1011    }
1012}
1013
1014#[harn_builtin(
1015    exposure = "runtime_internal",
1016    effects = [],
1017    sig = "uuid_v7() -> string",
1018    category = "io",
1019    doc = "Generate a time-ordered version 7 UUID."
1020)]
1021fn uuid_v7_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1022    Ok(VmValue::String(arcstr::ArcStr::from(
1023        uuid::Uuid::now_v7().to_string(),
1024    )))
1025}
1026
1027#[harn_builtin(
1028    exposure = "pure",
1029    effects = [],
1030    sig = "uuid_v5(namespace: string, name: string) -> string",
1031    category = "io",
1032    doc = "Generate a deterministic version 5 UUID."
1033)]
1034fn uuid_v5_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1035    if args.len() < 2 {
1036        return Err(VmError::Runtime(
1037            "uuid_v5(namespace, name): requires namespace and name".to_string(),
1038        ));
1039    }
1040    let namespace_raw = args[0].display();
1041    let namespace = uuid_v5_namespace(&namespace_raw).ok_or_else(|| {
1042        VmError::Runtime("uuid_v5: namespace must be a UUID or one of dns/url/oid/x500".to_string())
1043    })?;
1044    let name = args[1].display();
1045    Ok(VmValue::String(arcstr::ArcStr::from(
1046        uuid::Uuid::new_v5(&namespace, name.as_bytes()).to_string(),
1047    )))
1048}
1049
1050#[harn_builtin(
1051    exposure = "pure",
1052    effects = [],
1053    sig = "uuid_nil() -> string",
1054    category = "io",
1055    doc = "Return the nil UUID."
1056)]
1057fn uuid_nil_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1058    Ok(VmValue::String(arcstr::ArcStr::from(
1059        uuid::Uuid::nil().to_string(),
1060    )))
1061}
1062
1063pub(crate) fn prompt_user_value(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1064    let msg = args.first().map(|a| a.display()).unwrap_or_default();
1065    write_stdout(out, &msg);
1066    let options = ReadLineOptions {
1067        trim: false,
1068        ..ReadLineOptions::default()
1069    };
1070    match read_line_from_mock_or_real(&options) {
1071        ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(
1072            line.trim_end().to_string(),
1073        ))),
1074        ReadLineOutcome::Eof => Ok(VmValue::Nil),
1075        #[cfg(unix)]
1076        ReadLineOutcome::Timeout => Ok(VmValue::Nil),
1077        #[cfg(unix)]
1078        ReadLineOutcome::Interrupt => Ok(VmValue::Nil),
1079        ReadLineOutcome::Error(_) => Ok(VmValue::Nil),
1080    }
1081}
1082
1083pub(crate) fn read_password_legacy_value(prompt: &str) -> Result<VmValue, VmError> {
1084    let options = ReadLineOptions {
1085        prompt: prompt.to_string(),
1086        trim: false,
1087        echo: false,
1088        ..ReadLineOptions::default()
1089    };
1090    match read_line_from_mock_or_real(&options) {
1091        ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(line))),
1092        ReadLineOutcome::Eof => Err(VmError::Runtime(
1093            "HarnessTerm.read_password: stdin reached EOF".to_string(),
1094        )),
1095        #[cfg(unix)]
1096        ReadLineOutcome::Timeout => Err(VmError::Runtime(
1097            "HarnessTerm.read_password: stdin read timed out".to_string(),
1098        )),
1099        #[cfg(unix)]
1100        ReadLineOutcome::Interrupt => Err(VmError::Runtime(
1101            "HarnessTerm.read_password: stdin read was interrupted".to_string(),
1102        )),
1103        ReadLineOutcome::Error(error) => Err(VmError::Runtime(format!(
1104            "HarnessTerm.read_password: {error}"
1105        ))),
1106    }
1107}
1108
1109#[harn_builtin(
1110    exposure = "harness.obs.log_debug",
1111    effects = ["observability.write@const=log"],
1112    sig = "__cap_obs_log_debug(message: any, fields?: dict) -> nil",
1113    aliases = ["log_debug"],
1114    category = "io",
1115    doc = "Write a structured debug log line."
1116)]
1117fn log_debug_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1118    vm_write_log("debug", 0, args, out);
1119    Ok(VmValue::Nil)
1120}
1121
1122#[harn_builtin(
1123    exposure = "harness.obs.log_info",
1124    effects = ["observability.write@const=log"],
1125    sig = "__cap_obs_log_info(message: any, fields?: dict) -> nil",
1126    aliases = ["log_info"],
1127    category = "io",
1128    doc = "Write a structured info log line."
1129)]
1130fn log_info_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1131    vm_write_log("info", 1, args, out);
1132    Ok(VmValue::Nil)
1133}
1134
1135#[harn_builtin(
1136    exposure = "harness.obs.log_warn",
1137    effects = ["observability.write@const=log"],
1138    sig = "__cap_obs_log_warn(message: any, fields?: dict) -> nil",
1139    aliases = ["log_warn"],
1140    category = "io",
1141    doc = "Write a structured warning log line."
1142)]
1143fn log_warn_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1144    vm_write_log("warn", 2, args, out);
1145    Ok(VmValue::Nil)
1146}
1147
1148#[harn_builtin(
1149    exposure = "harness.obs.log_error",
1150    effects = ["observability.write@const=log"],
1151    sig = "__cap_obs_log_error(message: any, fields?: dict) -> nil",
1152    aliases = ["log_error"],
1153    category = "io",
1154    doc = "Write a structured error log line."
1155)]
1156fn log_error_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1157    vm_write_log("error", 3, args, out);
1158    Ok(VmValue::Nil)
1159}
1160
1161#[harn_builtin(
1162    exposure = "harness.obs.set_level",
1163    effects = ["observability.mutate@const=log-level"],
1164    sig = "__cap_obs_set_level(level: string) -> nil",
1165    aliases = ["log_set_level"],
1166    category = "io",
1167    doc = "Set the minimum structured log level."
1168)]
1169fn log_set_level_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1170    let level_str = args.first().map(|a| a.display()).unwrap_or_default();
1171    match super::logging::vm_level_to_u8(&level_str) {
1172        Some(n) => {
1173            VM_MIN_LOG_LEVEL.store(n, Ordering::Relaxed);
1174            Ok(VmValue::Nil)
1175        }
1176        None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1177            format!(
1178                "log_set_level: invalid level '{level_str}'. Expected debug, info, warn, or error"
1179            ),
1180        )))),
1181    }
1182}
1183
1184#[harn_builtin(
1185    exposure = "harness.stdio.progress",
1186    effects = ["stdio.write@const=stdout"],
1187    sig = "__cap_stdio_progress(phase: string, message: string, progress_or_options?: any, total?: int) -> nil",
1188    aliases = ["progress"],
1189    category = "io",
1190    doc = "Write a human-readable progress log line."
1191)]
1192fn progress_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1193    write_stdout(out, &render_progress_line(args));
1194    Ok(VmValue::Nil)
1195}
1196
1197#[harn_builtin(
1198    exposure = "harness.obs.log_json",
1199    effects = ["observability.write@const=log"],
1200    sig = "__cap_obs_log_json(key: string, value?: any) -> nil",
1201    aliases = ["log_json"],
1202    category = "io",
1203    doc = "Write a structured JSON log line."
1204)]
1205fn log_json_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1206    let key = args.first().map(|a| a.display()).unwrap_or_default();
1207    let value = args.get(1).cloned().unwrap_or(VmValue::Nil);
1208    let json_val = super::logging::vm_value_to_json_fragment(&value);
1209    let ts = super::logging::vm_format_timestamp_utc();
1210    let line = format!(
1211        "{{\"ts\":{},\"key\":{},\"value\":{}}}\n",
1212        vm_escape_json_str_quoted(&ts),
1213        vm_escape_json_str_quoted(&key),
1214        json_val,
1215    );
1216    write_stdout(out, &line);
1217    Ok(VmValue::Nil)
1218}
1219
1220fn uuid_v5_namespace(raw: &str) -> Option<uuid::Uuid> {
1221    match raw.to_ascii_lowercase().as_str() {
1222        "dns" | "namespace_dns" => Some(uuid::Uuid::NAMESPACE_DNS),
1223        "url" | "namespace_url" => Some(uuid::Uuid::NAMESPACE_URL),
1224        "oid" | "namespace_oid" => Some(uuid::Uuid::NAMESPACE_OID),
1225        "x500" | "namespace_x500" => Some(uuid::Uuid::NAMESPACE_X500),
1226        _ => uuid::Uuid::parse_str(raw).ok(),
1227    }
1228}
1229
1230fn render_progress_line(args: &[VmValue]) -> String {
1231    let phase = args.first().map(|a| a.display()).unwrap_or_default();
1232    let message = args.get(1).map(|a| a.display()).unwrap_or_default();
1233
1234    if let Some(options) = args.get(2).and_then(|arg| arg.as_dict()) {
1235        if let Some(mode) = progress_dict_str(options, "mode") {
1236            match mode {
1237                "spinner" => {
1238                    let step = progress_dict_int(options, "step")
1239                        .or_else(|| progress_dict_int(options, "current"))
1240                        .unwrap_or(0);
1241                    let frame = spinner_frame(step);
1242                    return format!("[{phase}] {frame} {message}\n");
1243                }
1244                "bar" => {
1245                    let current = progress_dict_int(options, "current").unwrap_or(0);
1246                    let total = progress_dict_int(options, "total").unwrap_or(0);
1247                    let width = progress_dict_int(options, "width")
1248                        .unwrap_or(10)
1249                        .clamp(3, 40) as usize;
1250                    let bar = render_progress_bar(current, total, width);
1251                    return format!("[{phase}] {bar} {message} ({current}/{total})\n");
1252                }
1253                _ => {}
1254            }
1255        }
1256    }
1257
1258    let progress = args.get(2).and_then(|a| a.as_int());
1259    let total = args.get(3).and_then(|a| a.as_int());
1260    match (progress, total) {
1261        (Some(p), Some(t)) => format!("[{phase}] {message} ({p}/{t})\n"),
1262        (Some(p), None) => format!("[{phase}] {message} ({p}%)\n"),
1263        _ => format!("[{phase}] {message}\n"),
1264    }
1265}
1266
1267fn progress_dict_int(options: &crate::value::DictMap, key: &str) -> Option<i64> {
1268    options.get(key).and_then(|value| value.as_int())
1269}
1270
1271fn progress_dict_str<'a>(options: &'a crate::value::DictMap, key: &str) -> Option<&'a str> {
1272    match options.get(key) {
1273        Some(VmValue::String(value)) => Some(value.as_ref()),
1274        _ => None,
1275    }
1276}
1277
1278fn spinner_frame(step: i64) -> &'static str {
1279    match step.rem_euclid(4) {
1280        0 => "|",
1281        1 => "/",
1282        2 => "-",
1283        _ => "\\",
1284    }
1285}
1286
1287fn render_progress_bar(current: i64, total: i64, width: usize) -> String {
1288    if total <= 0 {
1289        return format!("[{}]", "-".repeat(width));
1290    }
1291
1292    let clamped = current.clamp(0, total);
1293    let filled = ((clamped as f64 / total as f64) * width as f64).round() as usize;
1294    let filled = filled.min(width);
1295    let empty = width.saturating_sub(filled);
1296    format!("[{}{}]", "#".repeat(filled), "-".repeat(empty))
1297}
1298
1299fn vm_write_log(level: &str, level_num: u8, args: &[VmValue], out: &mut String) {
1300    if level_num < VM_MIN_LOG_LEVEL.load(Ordering::Relaxed) {
1301        return;
1302    }
1303    let msg = args.first().map(|a| a.display()).unwrap_or_default();
1304    let fields = args.get(1).and_then(|v| {
1305        if let VmValue::Dict(d) = v {
1306            Some(&**d)
1307        } else {
1308            None
1309        }
1310    });
1311    let line = vm_build_log_line(level, &msg, fields);
1312    write_stdout(out, &line);
1313}
1314
1315fn ansi_colorize(text: &str, name: &str) -> String {
1316    let code = match name {
1317        "black" => "30",
1318        "red" => "31",
1319        "green" => "32",
1320        "yellow" => "33",
1321        "blue" => "34",
1322        "magenta" => "35",
1323        "cyan" => "36",
1324        "white" => "37",
1325        "bright_black" | "gray" | "grey" => "90",
1326        "bright_red" => "91",
1327        "bright_green" => "92",
1328        "bright_yellow" => "93",
1329        "bright_blue" => "94",
1330        "bright_magenta" => "95",
1331        "bright_cyan" => "96",
1332        "bright_white" => "97",
1333        _ => return text.to_string(),
1334    };
1335    format!("\u{1b}[{code}m{text}\u{1b}[0m")
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use crate::value::VmDictExt;
1341    use std::collections::BTreeMap;
1342    #[cfg(unix)]
1343    use std::sync::atomic::{AtomicBool, Ordering};
1344    #[cfg(unix)]
1345    use std::sync::Arc;
1346    #[cfg(unix)]
1347    use std::time::Instant;
1348
1349    use crate::value::VmValue;
1350
1351    use super::{
1352        mock_stdin_builtin, read_line_from_mock_or_real, read_stdin_builtin, render_progress_bar,
1353        render_progress_line, reserve_stdio_for_current_thread, reset_io_state,
1354        set_stdout_passthrough, spinner_frame, stdout_passthrough_enabled, ReadLineOptions,
1355        ReadLineOutcome, STDIN_ALLOWED, STDOUT_ALLOWED,
1356    };
1357
1358    static_assertions::assert_not_impl_any!(super::StdioReservationGuard: Send, Sync);
1359
1360    #[test]
1361    fn stdout_passthrough_state_toggles() {
1362        reset_io_state();
1363
1364        assert!(!stdout_passthrough_enabled());
1365        assert!(!set_stdout_passthrough(true));
1366        assert!(stdout_passthrough_enabled());
1367
1368        assert!(set_stdout_passthrough(false));
1369        assert!(!stdout_passthrough_enabled());
1370    }
1371
1372    #[test]
1373    fn scoped_stdio_reservation_is_repeatable_and_restores_prior_policy() {
1374        reset_io_state();
1375        assert!(STDIN_ALLOWED.get());
1376        assert!(STDOUT_ALLOWED.get());
1377
1378        {
1379            let _outer = reserve_stdio_for_current_thread();
1380            assert_eq!(
1381                read_line_from_mock_or_real(&ReadLineOptions::default()),
1382                ReadLineOutcome::Eof
1383            );
1384            assert!(matches!(
1385                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1386                VmValue::Nil
1387            ));
1388            assert!(matches!(
1389                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1390                VmValue::Nil
1391            ));
1392            mock_stdin_builtin(&[VmValue::string("fixture")], &mut String::new()).unwrap();
1393            assert_eq!(
1394                read_stdin_builtin(&[], &mut String::new())
1395                    .unwrap()
1396                    .display(),
1397                "fixture"
1398            );
1399            assert!(matches!(
1400                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1401                VmValue::Nil
1402            ));
1403            {
1404                let _inner = reserve_stdio_for_current_thread();
1405                assert!(!STDIN_ALLOWED.get());
1406                assert!(!STDOUT_ALLOWED.get());
1407            }
1408            assert!(!STDIN_ALLOWED.get());
1409            assert!(!STDOUT_ALLOWED.get());
1410        }
1411
1412        assert!(STDIN_ALLOWED.get());
1413        assert!(STDOUT_ALLOWED.get());
1414    }
1415
1416    #[test]
1417    fn progress_bar_mode_renders_hash_bar() {
1418        let mut options = BTreeMap::new();
1419        options.put_str("mode", "bar");
1420        options.insert("current".to_string(), VmValue::Int(3));
1421        options.insert("total".to_string(), VmValue::Int(5));
1422        options.insert("width".to_string(), VmValue::Int(10));
1423
1424        let line = render_progress_line(&[
1425            VmValue::String(arcstr::ArcStr::from("build")),
1426            VmValue::String(arcstr::ArcStr::from("Compiling")),
1427            VmValue::dict(options),
1428        ]);
1429
1430        assert_eq!(line, "[build] [######----] Compiling (3/5)\n");
1431    }
1432
1433    #[test]
1434    fn progress_spinner_mode_uses_step_to_pick_frame() {
1435        let mut options = BTreeMap::new();
1436        options.put_str("mode", "spinner");
1437        options.insert("step".to_string(), VmValue::Int(2));
1438
1439        let line = render_progress_line(&[
1440            VmValue::String(arcstr::ArcStr::from("sync")),
1441            VmValue::String(arcstr::ArcStr::from("Waiting")),
1442            VmValue::dict(options),
1443        ]);
1444
1445        assert_eq!(line, "[sync] - Waiting\n");
1446        assert_eq!(spinner_frame(3), "\\");
1447    }
1448
1449    #[test]
1450    fn progress_bar_falls_back_to_empty_bar_for_zero_total() {
1451        assert_eq!(render_progress_bar(2, 0, 5), "[-----]");
1452    }
1453
1454    #[test]
1455    fn read_line_options_preserve_prompt_whitespace() {
1456        let mut options = BTreeMap::new();
1457        options.put_str("prompt", "  > ");
1458        options.insert("trim".to_string(), VmValue::Bool(false));
1459
1460        let parsed = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap();
1461
1462        assert_eq!(parsed.prompt, "  > ");
1463        assert!(!parsed.trim);
1464    }
1465
1466    #[test]
1467    fn read_line_options_reject_unknown_keys() {
1468        let mut options = BTreeMap::new();
1469        options.put_str("promtp", "> ");
1470
1471        let err = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap_err();
1472
1473        match err {
1474            crate::value::VmError::Runtime(message) => assert!(message.contains("promtp")),
1475            other => panic!("expected Runtime error, got {other:?}"),
1476        }
1477    }
1478
1479    #[cfg(unix)]
1480    struct FdGuard(libc::c_int);
1481
1482    #[cfg(unix)]
1483    impl Drop for FdGuard {
1484        fn drop(&mut self) {
1485            let _ = unsafe { libc::close(self.0) };
1486        }
1487    }
1488
1489    #[cfg(unix)]
1490    fn pipe_pair() -> (FdGuard, FdGuard) {
1491        let mut fds = [0; 2];
1492        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
1493        (FdGuard(fds[0]), FdGuard(fds[1]))
1494    }
1495
1496    #[cfg(unix)]
1497    #[test]
1498    fn read_line_from_fd_times_out_without_data() {
1499        let (read_fd, _write_fd) = pipe_pair();
1500        let outcome = super::read_line_from_fd_unix(
1501            read_fd.0,
1502            &ReadLineOptions {
1503                timeout_ms: Some(10),
1504                ..ReadLineOptions::default()
1505            },
1506        );
1507
1508        assert_eq!(outcome, ReadLineOutcome::Timeout);
1509    }
1510
1511    #[cfg(unix)]
1512    #[test]
1513    fn read_line_from_fd_observes_interrupt_without_stdin_activity() {
1514        let (read_fd, _write_fd) = pipe_pair();
1515        let cancel = Arc::new(AtomicBool::new(false));
1516        let cancel_from_thread = Arc::clone(&cancel);
1517        let _guard = crate::op_interrupt::install(Some(cancel), None);
1518        let interrupter = std::thread::spawn(move || {
1519            // No fixed "wait for the reader to park" sleep: the reader re-checks
1520            // the cancel flag every `READ_LINE_INTERRUPT_POLL` heartbeat, so it
1521            // observes this store within one interval regardless of ordering.
1522            // A blind sleep would only add wall time and reintroduce a race.
1523            cancel_from_thread.store(true, Ordering::SeqCst);
1524        });
1525
1526        let started = Instant::now();
1527        let outcome = super::read_line_from_fd_unix(read_fd.0, &ReadLineOptions::default());
1528
1529        interrupter.join().expect("interrupter thread joins");
1530        assert_eq!(outcome, ReadLineOutcome::Interrupt);
1531        assert!(
1532            started.elapsed() < super::READ_LINE_INTERRUPT_POLL * 25,
1533            "interrupt heartbeat should wake idle read_line within a few poll \
1534             intervals, took {:?}",
1535            started.elapsed()
1536        );
1537    }
1538
1539    #[cfg(unix)]
1540    #[test]
1541    fn read_line_from_fd_honors_trim_option() {
1542        let (read_fd, write_fd) = pipe_pair();
1543        let payload = b"  alpha  \n";
1544        assert_eq!(
1545            unsafe { libc::write(write_fd.0, payload.as_ptr().cast(), payload.len()) },
1546            payload.len() as isize
1547        );
1548        let outcome = super::read_line_from_fd_unix(
1549            read_fd.0,
1550            &ReadLineOptions {
1551                timeout_ms: Some(100),
1552                trim: false,
1553                ..ReadLineOptions::default()
1554            },
1555        );
1556
1557        assert_eq!(outcome, ReadLineOutcome::Ok("  alpha  ".to_string()));
1558    }
1559}