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::macros::{harn_builtin, VmBuiltinDef};
14use crate::stdlib::options::{self, ErrorKind, OptionsParser};
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_timeout_ms(value: Option<&VmValue>) -> Result<Option<u64>, VmError> {
337    match value {
338        None | Some(VmValue::Nil) => Ok(None),
339        Some(VmValue::Int(value)) | Some(VmValue::Duration(value)) => {
340            if *value < 0 {
341                return Err(VmError::Runtime(format!(
342                    "{READ_LINE_FN}: `timeout_ms` must be non-negative"
343                )));
344            }
345            Ok(Some(*value as u64))
346        }
347        Some(value) => Err(VmError::Runtime(format!(
348            "{READ_LINE_FN}: `timeout_ms` must be an int, duration, or nil (got {})",
349            value.type_name()
350        ))),
351    }
352}
353
354fn parse_read_line_options(args: &[VmValue]) -> Result<ReadLineOptions, VmError> {
355    if args.len() > 1 {
356        return Err(VmError::Runtime(format!(
357            "{READ_LINE_FN}: expected at most one options dict"
358        )));
359    }
360    let Some(dict) =
361        options::optional_dict_arg(args, 0, READ_LINE_FN, "options", ErrorKind::Runtime)?
362    else {
363        return Ok(ReadLineOptions::default());
364    };
365    let mut parser = OptionsParser::new(READ_LINE_FN, dict, ErrorKind::Runtime);
366    let options = ReadLineOptions {
367        prompt: parser.optional_string_raw("prompt")?.unwrap_or_default(),
368        timeout_ms: parse_read_line_timeout_ms(parser.raw("timeout_ms"))?,
369        trim: parser.bool_or("trim", true)?,
370        echo: parser.bool_or("echo", true)?,
371        raw: parser.bool_or("raw", false)?,
372    };
373    parser.finish_strict(&[])?;
374    Ok(options)
375}
376
377fn read_line_from_mock_or_real(options: &ReadLineOptions) -> ReadLineOutcome {
378    let _lock = match STDIN_READ_LOCK.lock() {
379        Ok(lock) => lock,
380        Err(_) => return ReadLineOutcome::Error("stdin read lock is poisoned".to_string()),
381    };
382    if !options.prompt.is_empty() {
383        write_stderr(&options.prompt);
384    }
385    match read_mock_line() {
386        MockReadLine::Line(line) => {
387            return ReadLineOutcome::Ok(normalize_read_line_value(line, options.trim));
388        }
389        MockReadLine::Eof => return ReadLineOutcome::Eof,
390        MockReadLine::Unset => {}
391    }
392    if !STDIN_ALLOWED.get() {
393        return ReadLineOutcome::Eof;
394    }
395    read_stdin_line_real_with_options(options)
396}
397
398#[cfg(unix)]
399struct TerminalModeGuard {
400    fd: libc::c_int,
401    original: Option<libc::termios>,
402}
403
404#[cfg(unix)]
405impl TerminalModeGuard {
406    fn install(fd: libc::c_int, options: &ReadLineOptions) -> Result<Self, String> {
407        let mut original = std::mem::MaybeUninit::<libc::termios>::uninit();
408        let fd_is_terminal = unsafe { libc::isatty(fd) == 1 };
409        if !fd_is_terminal || (options.echo && !options.raw) {
410            return Ok(Self { fd, original: None });
411        }
412        if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 {
413            return Err(std::io::Error::last_os_error().to_string());
414        }
415        let original = unsafe { original.assume_init() };
416        let mut updated = original;
417        if !options.echo {
418            updated.c_lflag &= !libc::ECHO;
419        }
420        if options.raw {
421            updated.c_lflag &= !libc::ICANON;
422            updated.c_cc[libc::VMIN] = 0;
423            updated.c_cc[libc::VTIME] = 0;
424        }
425        if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw const updated) } != 0 {
426            return Err(std::io::Error::last_os_error().to_string());
427        }
428        Ok(Self {
429            fd,
430            original: Some(original),
431        })
432    }
433}
434
435#[cfg(unix)]
436impl Drop for TerminalModeGuard {
437    fn drop(&mut self) {
438        if let Some(original) = &self.original {
439            let _ = unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, original) };
440        }
441    }
442}
443
444#[cfg(unix)]
445const READ_LINE_INTERRUPT_POLL: Duration = Duration::from_millis(20);
446
447#[cfg(unix)]
448fn read_line_elapsed_ms(start: Instant) -> u64 {
449    start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
450}
451
452#[cfg(unix)]
453fn read_line_timeout_remaining_ms(options: &ReadLineOptions, start: Instant) -> Option<u64> {
454    let timeout_ms = options.timeout_ms?;
455    Some(timeout_ms.saturating_sub(read_line_elapsed_ms(start)))
456}
457
458#[cfg(unix)]
459fn read_line_timed_out(options: &ReadLineOptions, start: Instant) -> bool {
460    matches!(read_line_timeout_remaining_ms(options, start), Some(0))
461}
462
463#[cfg(unix)]
464fn read_line_interrupt_poll_ms() -> libc::c_int {
465    READ_LINE_INTERRUPT_POLL
466        .as_millis()
467        .min(libc::c_int::MAX as u128) as libc::c_int
468}
469
470#[cfg(unix)]
471fn poll_timeout(options: &ReadLineOptions, start: Instant) -> libc::c_int {
472    let heartbeat = crate::op_interrupt::installed().then_some(read_line_interrupt_poll_ms());
473    match (read_line_timeout_remaining_ms(options, start), heartbeat) {
474        (Some(remaining), Some(heartbeat)) => {
475            remaining.min(heartbeat as u64).min(libc::c_int::MAX as u64) as libc::c_int
476        }
477        (Some(remaining), None) => remaining.min(libc::c_int::MAX as u64) as libc::c_int,
478        (None, Some(heartbeat)) => heartbeat,
479        (None, None) => -1,
480    }
481}
482
483#[cfg(unix)]
484fn finish_read_line(bytes: Vec<u8>, trim: bool) -> ReadLineOutcome {
485    match String::from_utf8(bytes) {
486        Ok(line) => ReadLineOutcome::Ok(normalize_read_line_value(line, trim)),
487        Err(_) => ReadLineOutcome::Error("stdin line was not valid UTF-8".to_string()),
488    }
489}
490
491#[cfg(unix)]
492fn read_line_from_fd_unix(fd: libc::c_int, options: &ReadLineOptions) -> ReadLineOutcome {
493    let _terminal_mode = match TerminalModeGuard::install(fd, options) {
494        Ok(guard) => guard,
495        Err(error) => return ReadLineOutcome::Error(error),
496    };
497    let start = Instant::now();
498    let mut bytes = Vec::new();
499    loop {
500        if crate::op_interrupt::requested() {
501            return ReadLineOutcome::Interrupt;
502        }
503        let mut pollfd = libc::pollfd {
504            fd,
505            events: libc::POLLIN,
506            revents: 0,
507        };
508        let ready = unsafe { libc::poll(&raw mut pollfd, 1, poll_timeout(options, start)) };
509        if ready == 0 {
510            if read_line_timed_out(options, start) {
511                return ReadLineOutcome::Timeout;
512            }
513            continue;
514        }
515        if ready < 0 {
516            let error = std::io::Error::last_os_error();
517            if error.raw_os_error() == Some(libc::EINTR) {
518                return ReadLineOutcome::Interrupt;
519            }
520            return ReadLineOutcome::Error(error.to_string());
521        }
522        if pollfd.revents & libc::POLLNVAL != 0 {
523            return ReadLineOutcome::Error("stdin fd is invalid".to_string());
524        }
525        if pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) == 0 {
526            continue;
527        }
528        let mut byte = [0u8; 1];
529        let read = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
530        if read == 0 {
531            return if bytes.is_empty() {
532                ReadLineOutcome::Eof
533            } else {
534                finish_read_line(bytes, options.trim)
535            };
536        }
537        if read < 0 {
538            let error = std::io::Error::last_os_error();
539            match error.raw_os_error() {
540                Some(libc::EINTR) => return ReadLineOutcome::Interrupt,
541                Some(libc::EAGAIN) => continue,
542                _ => return ReadLineOutcome::Error(error.to_string()),
543            }
544        }
545        match byte[0] {
546            b'\n' => return finish_read_line(bytes, options.trim),
547            b'\r' if options.raw => return finish_read_line(bytes, options.trim),
548            0x03 if options.raw => return ReadLineOutcome::Interrupt,
549            0x04 if options.raw && bytes.is_empty() => return ReadLineOutcome::Eof,
550            0x04 if options.raw => return finish_read_line(bytes, options.trim),
551            value => bytes.push(value),
552        }
553    }
554}
555
556#[cfg(unix)]
557fn read_stdin_line_real_with_options(options: &ReadLineOptions) -> ReadLineOutcome {
558    read_line_from_fd_unix(libc::STDIN_FILENO, options)
559}
560
561#[cfg(not(unix))]
562fn read_stdin_line_real_with_options(options: &ReadLineOptions) -> ReadLineOutcome {
563    if !options.echo || options.raw {
564        return ReadLineOutcome::Error(
565            "std/io.read_line echo=false/raw=true is only implemented on Unix hosts".to_string(),
566        );
567    }
568    if options.timeout_ms.is_some() {
569        return ReadLineOutcome::Error(
570            "std/io.read_line timeout_ms is only implemented on Unix hosts".to_string(),
571        );
572    }
573    match read_stdin_line_real() {
574        Some(line) => ReadLineOutcome::Ok(normalize_read_line_value(line, options.trim)),
575        None => ReadLineOutcome::Eof,
576    }
577}
578
579pub(crate) fn is_tty_for(stream: &str) -> bool {
580    let mocked = TTY_MOCK.with(|t| {
581        let mock = *t.borrow();
582        match stream {
583            "stdin" => mock.stdin,
584            "stdout" => mock.stdout,
585            "stderr" => mock.stderr,
586            _ => None,
587        }
588    });
589    if let Some(v) = mocked {
590        return v;
591    }
592    match stream {
593        "stdin" => std::io::stdin().is_terminal(),
594        "stdout" => std::io::stdout().is_terminal(),
595        "stderr" => std::io::stderr().is_terminal(),
596        _ => false,
597    }
598}
599
600fn ansi_enabled_for_stream(stream: &str) -> bool {
601    let mode = COLOR_MODE.with(|m| *m.borrow());
602    match mode {
603        ColorMode::Always => true,
604        ColorMode::Never => false,
605        ColorMode::Auto => {
606            if std::env::var_os("FORCE_COLOR").is_some() {
607                return true;
608            }
609            if std::env::var_os("NO_COLOR").is_some() {
610                return false;
611            }
612            is_tty_for(stream)
613        }
614    }
615}
616
617pub(crate) fn register_io_builtins(vm: &mut Vm) {
618    for def in MODULE_BUILTINS {
619        vm.register_builtin_def(def);
620    }
621    use harn_builtin_meta::CapabilityId;
622    vm.register_capability_method(CapabilityId::Term, "set_color_mode", set_color_mode_builtin);
623    vm.register_capability_method(CapabilityId::Stdio, "log", log_builtin);
624    vm.register_capability_method(CapabilityId::Stdio, "progress", progress_builtin);
625    vm.register_capability_method(CapabilityId::Stdio, "read_stdin", read_stdin_builtin);
626    vm.register_capability_method(CapabilityId::Stdio, "is_stdin_tty", is_stdin_tty_builtin);
627    vm.register_capability_method(CapabilityId::Stdio, "is_stdout_tty", is_stdout_tty_builtin);
628    vm.register_capability_method(CapabilityId::Stdio, "is_stderr_tty", is_stderr_tty_builtin);
629    vm.register_capability_method(CapabilityId::Observability, "log_debug", log_debug_builtin);
630    vm.register_capability_method(CapabilityId::Observability, "log_info", log_info_builtin);
631    vm.register_capability_method(CapabilityId::Observability, "log_warn", log_warn_builtin);
632    vm.register_capability_method(CapabilityId::Observability, "log_error", log_error_builtin);
633    vm.register_capability_method(
634        CapabilityId::Observability,
635        "set_level",
636        log_set_level_builtin,
637    );
638    vm.register_capability_method(CapabilityId::Observability, "log_json", log_json_builtin);
639    vm.register_capability_method(CapabilityId::Testing, "stdin_set", mock_stdin_builtin);
640    vm.register_capability_method(CapabilityId::Testing, "stdin_reset", unmock_stdin_builtin);
641    vm.register_capability_method(CapabilityId::Testing, "tty_set", mock_tty_builtin);
642    vm.register_capability_method(CapabilityId::Testing, "tty_reset", unmock_tty_builtin);
643    vm.register_capability_method(
644        CapabilityId::Testing,
645        "capture_stderr_start",
646        capture_stderr_start_builtin,
647    );
648    vm.register_capability_method(
649        CapabilityId::Testing,
650        "capture_stderr_take",
651        capture_stderr_take_builtin,
652    );
653}
654
655#[harn_builtin(
656    exposure = "runtime_internal",
657    effects = [],
658    sig = "log(message: any) -> nil",
659    category = "io",
660    doc = "Write a Harn-prefixed message to stdout."
661)]
662fn log_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
663    let msg = args.first().map(|a| a.display()).unwrap_or_default();
664    write_stdout(out, &format!("[harn] {msg}\n"));
665    Ok(VmValue::Nil)
666}
667
668#[harn_builtin(
669    exposure = "pure",
670    effects = [],
671    sig = "color(text: any, color: string) -> string",
672    category = "io",
673    doc = "Apply an ANSI foreground color when color output is enabled."
674)]
675fn color_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
676    let text = args.first().map(|a| a.display()).unwrap_or_default();
677    let name = args.get(1).map(|a| a.display()).unwrap_or_default();
678    if !ansi_enabled_for_stream("stdout") {
679        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
680    }
681    Ok(VmValue::String(arcstr::ArcStr::from(ansi_colorize(
682        &text, &name,
683    ))))
684}
685
686#[harn_builtin(
687    exposure = "pure",
688    effects = [],
689    sig = "bold(text: any) -> string",
690    category = "io",
691    doc = "Apply ANSI bold styling when color output is enabled."
692)]
693fn bold_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
694    let text = args.first().map(|a| a.display()).unwrap_or_default();
695    if !ansi_enabled_for_stream("stdout") {
696        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
697    }
698    Ok(VmValue::String(arcstr::ArcStr::from(format!(
699        "\u{1b}[1m{text}\u{1b}[0m"
700    ))))
701}
702
703#[harn_builtin(
704    exposure = "pure",
705    effects = [],
706    sig = "dim(text: any) -> string",
707    category = "io",
708    doc = "Apply ANSI dim styling when color output is enabled."
709)]
710fn dim_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
711    let text = args.first().map(|a| a.display()).unwrap_or_default();
712    if !ansi_enabled_for_stream("stdout") {
713        return Ok(VmValue::String(arcstr::ArcStr::from(text)));
714    }
715    Ok(VmValue::String(arcstr::ArcStr::from(format!(
716        "\u{1b}[2m{text}\u{1b}[0m"
717    ))))
718}
719
720#[harn_builtin(
721    exposure = "runtime_internal",
722    effects = [],
723    sig = "set_color_mode(mode: string) -> nil",
724    category = "io",
725    doc = "Set ANSI color handling to auto, always, or never."
726)]
727fn set_color_mode_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
728    let mode = args.first().map(|a| a.display()).unwrap_or_default();
729    let parsed = match mode.as_str() {
730        "auto" => ColorMode::Auto,
731        "always" => ColorMode::Always,
732        "never" => ColorMode::Never,
733        other => {
734            return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
735                format!(
736                "set_color_mode: invalid mode '{other}'. Expected 'auto', 'always', or 'never'."
737            ),
738            ))));
739        }
740    };
741    COLOR_MODE.with(|m| *m.borrow_mut() = parsed);
742    Ok(VmValue::Nil)
743}
744
745#[harn_builtin(
746    exposure = "runtime_internal",
747    effects = [],
748    sig = "__ansi_enabled(stream?: string) -> bool",
749    category = "io",
750    doc = "Return whether ANSI styling is enabled for stdin, stdout, or stderr."
751)]
752fn ansi_enabled_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
753    let stream = args
754        .first()
755        .map(|a| a.display())
756        .unwrap_or_else(|| "stdout".to_string());
757    match stream.as_str() {
758        "stdin" | "stdout" | "stderr" => Ok(VmValue::Bool(ansi_enabled_for_stream(&stream))),
759        other => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
760            format!(
761            "__ansi_enabled: invalid stream '{other}'. Expected 'stdin', 'stdout', or 'stderr'."
762        ),
763        )))),
764    }
765}
766
767#[harn_builtin(
768    exposure = "runtime_internal",
769    effects = [],
770    sig = "read_stdin() -> string",
771    category = "io",
772    doc = "Read all remaining stdin as a string."
773)]
774fn read_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
775    // Drain any remaining mocked stdin first.
776    let mocked = STDIN_MOCK.with(|s| s.borrow_mut().take());
777    if let Some(buf) = mocked {
778        // After read_stdin, future read_line calls return nil because stdin is consumed.
779        STDIN_LINES.with(|lines| *lines.borrow_mut() = Some(VecDeque::new()));
780        return Ok(VmValue::String(arcstr::ArcStr::from(buf)));
781    }
782    if !STDIN_ALLOWED.get() {
783        return Ok(VmValue::Nil);
784    }
785    match read_stdin_all_real() {
786        Some(s) => Ok(VmValue::String(arcstr::ArcStr::from(s))),
787        None => Ok(VmValue::Nil),
788    }
789}
790
791pub(crate) fn read_line_legacy_value() -> VmValue {
792    let options = ReadLineOptions {
793        trim: false,
794        ..ReadLineOptions::default()
795    };
796    match read_line_from_mock_or_real(&options) {
797        ReadLineOutcome::Ok(line) => VmValue::String(arcstr::ArcStr::from(line)),
798        ReadLineOutcome::Eof => VmValue::Nil,
799        #[cfg(unix)]
800        ReadLineOutcome::Timeout => VmValue::Nil,
801        #[cfg(unix)]
802        ReadLineOutcome::Interrupt => VmValue::Nil,
803        ReadLineOutcome::Error(_) => VmValue::Nil,
804    }
805}
806
807pub(crate) fn read_line_structured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
808    let options = parse_read_line_options(args)?;
809    Ok(read_line_result(read_line_from_mock_or_real(&options)))
810}
811
812#[harn_builtin(
813    exposure = "runtime_internal",
814    effects = [],
815    sig = "__io_read_line(options?: any) -> dict",
816    category = "io",
817    doc = "Read one line from stdin with structured status metadata."
818)]
819fn io_read_line_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
820    read_line_structured_value(args)
821}
822
823#[harn_builtin(
824    exposure = "runtime_internal",
825    effects = [],
826    sig = "__io_write_stderr(message: any) -> nil",
827    category = "io",
828    doc = "Write text to stderr without appending a newline."
829)]
830fn io_write_stderr_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
831    let msg = args.first().map(|a| a.display()).unwrap_or_default();
832    write_stderr(&msg);
833    Ok(VmValue::Nil)
834}
835
836#[harn_builtin(
837    exposure = "runtime_internal",
838    effects = [],
839    sig = "__io_write_stdout(message: any) -> nil",
840    category = "io",
841    doc = "Write text to stdout without appending a newline."
842)]
843fn io_write_stdout_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
844    let msg = args.first().map(|a| a.display()).unwrap_or_default();
845    write_stdout(out, &msg);
846    Ok(VmValue::Nil)
847}
848
849#[harn_builtin(
850    exposure = "runtime_internal",
851    effects = [],
852    sig = "__io_print(...args: any) -> nil",
853    category = "io",
854    doc = "Internal compatibility bridge for stdout without newline."
855)]
856fn io_print_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
857    let msg = args.first().map(|a| a.display()).unwrap_or_default();
858    write_stdout(out, &msg);
859    Ok(VmValue::Nil)
860}
861
862#[harn_builtin(
863    exposure = "runtime_internal",
864    effects = [],
865    sig = "__io_println(...args: any) -> nil",
866    category = "io",
867    doc = "Internal compatibility bridge for stdout with newline."
868)]
869fn io_println_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
870    let msg = args.first().map(|a| a.display()).unwrap_or_default();
871    write_stdout(out, &format!("{msg}\n"));
872    Ok(VmValue::Nil)
873}
874
875#[harn_builtin(
876    exposure = "runtime_internal",
877    effects = [],
878    sig = "__io_eprint(message: any) -> nil",
879    category = "io",
880    doc = "Internal compatibility bridge for stderr without newline."
881)]
882fn io_eprint_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
883    let msg = args.first().map(|a| a.display()).unwrap_or_default();
884    write_stderr(&msg);
885    Ok(VmValue::Nil)
886}
887
888#[harn_builtin(
889    exposure = "runtime_internal",
890    effects = [],
891    sig = "__io_eprintln(message: any) -> nil",
892    category = "io",
893    doc = "Internal compatibility bridge for stderr with newline."
894)]
895fn io_eprintln_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
896    let msg = args.first().map(|a| a.display()).unwrap_or_default();
897    write_stderr(&format!("{msg}\n"));
898    Ok(VmValue::Nil)
899}
900
901#[harn_builtin(
902    exposure = "runtime_internal",
903    effects = [],
904    sig = "is_stdin_tty() -> bool",
905    category = "io",
906    doc = "Return whether stdin is attached to a terminal."
907)]
908fn is_stdin_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
909    Ok(VmValue::Bool(is_tty_for("stdin")))
910}
911
912#[harn_builtin(
913    exposure = "runtime_internal",
914    effects = [],
915    sig = "is_stdout_tty() -> bool",
916    category = "io",
917    doc = "Return whether stdout is attached to a terminal."
918)]
919fn is_stdout_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
920    Ok(VmValue::Bool(is_tty_for("stdout")))
921}
922
923#[harn_builtin(
924    exposure = "runtime_internal",
925    effects = [],
926    sig = "is_stderr_tty() -> bool",
927    category = "io",
928    doc = "Return whether stderr is attached to a terminal."
929)]
930fn is_stderr_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
931    Ok(VmValue::Bool(is_tty_for("stderr")))
932}
933
934#[harn_builtin(
935    exposure = "runtime_internal",
936    effects = [],
937    sig = "mock_stdin(text: string) -> nil",
938    category = "io",
939    doc = "Install mocked stdin text for tests."
940)]
941fn mock_stdin_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
942    let text = args.first().map(|a| a.display()).unwrap_or_default();
943    STDIN_MOCK.with(|s| *s.borrow_mut() = Some(text));
944    STDIN_LINES.with(|s| *s.borrow_mut() = None);
945    Ok(VmValue::Nil)
946}
947
948#[harn_builtin(
949    exposure = "runtime_internal",
950    effects = [],
951    sig = "unmock_stdin() -> nil",
952    category = "io",
953    doc = "Clear mocked stdin text and line state."
954)]
955fn unmock_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
956    STDIN_MOCK.with(|s| *s.borrow_mut() = None);
957    STDIN_LINES.with(|s| *s.borrow_mut() = None);
958    Ok(VmValue::Nil)
959}
960
961#[harn_builtin(
962    exposure = "runtime_internal",
963    effects = [],
964    sig = "mock_tty(stream: string, is_tty: bool) -> nil",
965    category = "io",
966    doc = "Override terminal detection for stdin, stdout, or stderr."
967)]
968fn mock_tty_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
969    let stream = args.first().map(|a| a.display()).unwrap_or_default();
970    let is_tty = matches!(args.get(1), Some(VmValue::Bool(true)));
971    TTY_MOCK.with(|t| {
972        let mut mock = t.borrow_mut();
973        match stream.as_str() {
974            "stdin" => mock.stdin = Some(is_tty),
975            "stdout" => mock.stdout = Some(is_tty),
976            "stderr" => mock.stderr = Some(is_tty),
977            other => {
978                return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
979                    format!(
980                    "mock_tty: invalid stream '{other}'. Expected 'stdin', 'stdout', or 'stderr'."
981                ),
982                ))));
983            }
984        }
985        Ok(VmValue::Nil)
986    })
987}
988
989#[harn_builtin(
990    exposure = "runtime_internal",
991    effects = [],
992    sig = "unmock_tty() -> nil",
993    category = "io",
994    doc = "Clear terminal detection overrides."
995)]
996fn unmock_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
997    TTY_MOCK.with(|t| *t.borrow_mut() = TtyMock::default());
998    Ok(VmValue::Nil)
999}
1000
1001#[harn_builtin(
1002    exposure = "runtime_internal",
1003    effects = [],
1004    sig = "capture_stderr_start() -> nil",
1005    category = "io",
1006    doc = "Start capturing stderr into an in-memory buffer."
1007)]
1008fn capture_stderr_start_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1009    STDERR_CAPTURING.with(|c| *c.borrow_mut() = true);
1010    STDERR_BUFFER.with(|s| s.borrow_mut().clear());
1011    Ok(VmValue::Nil)
1012}
1013
1014#[harn_builtin(
1015    exposure = "runtime_internal",
1016    effects = [],
1017    sig = "capture_stderr_take() -> string",
1018    category = "io",
1019    doc = "Stop stderr capture and return the buffered text."
1020)]
1021fn capture_stderr_take_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1022    let buf = STDERR_BUFFER.with(|s| std::mem::take(&mut *s.borrow_mut()));
1023    STDERR_CAPTURING.with(|c| *c.borrow_mut() = false);
1024    Ok(VmValue::String(arcstr::ArcStr::from(buf)))
1025}
1026
1027#[harn_builtin(
1028    exposure = "runtime_internal",
1029    effects = [],
1030    sig = "uuid() -> string",
1031    category = "io",
1032    doc = "Generate a random version 4 UUID."
1033)]
1034fn uuid_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1035    Ok(VmValue::String(arcstr::ArcStr::from(
1036        uuid::Uuid::new_v4().to_string(),
1037    )))
1038}
1039
1040#[harn_builtin(
1041    exposure = "pure",
1042    effects = [],
1043    sig = "uuid_parse(value: any) -> string",
1044    category = "io",
1045    doc = "Parse and normalize a UUID string, or return nil."
1046)]
1047fn uuid_parse_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1048    let raw = args.first().map(|a| a.display()).unwrap_or_default();
1049    match uuid::Uuid::parse_str(&raw) {
1050        Ok(uuid) => Ok(VmValue::String(arcstr::ArcStr::from(uuid.to_string()))),
1051        Err(_) => Ok(VmValue::Nil),
1052    }
1053}
1054
1055#[harn_builtin(
1056    exposure = "runtime_internal",
1057    effects = [],
1058    sig = "uuid_v7() -> string",
1059    category = "io",
1060    doc = "Generate a time-ordered version 7 UUID."
1061)]
1062fn uuid_v7_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1063    Ok(VmValue::String(arcstr::ArcStr::from(
1064        uuid::Uuid::now_v7().to_string(),
1065    )))
1066}
1067
1068#[harn_builtin(
1069    exposure = "pure",
1070    effects = [],
1071    sig = "uuid_v5(namespace: string, name: string) -> string",
1072    category = "io",
1073    doc = "Generate a deterministic version 5 UUID."
1074)]
1075fn uuid_v5_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1076    if args.len() < 2 {
1077        return Err(VmError::Runtime(
1078            "uuid_v5(namespace, name): requires namespace and name".to_string(),
1079        ));
1080    }
1081    let namespace_raw = args[0].display();
1082    let namespace = uuid_v5_namespace(&namespace_raw).ok_or_else(|| {
1083        VmError::Runtime("uuid_v5: namespace must be a UUID or one of dns/url/oid/x500".to_string())
1084    })?;
1085    let name = args[1].display();
1086    Ok(VmValue::String(arcstr::ArcStr::from(
1087        uuid::Uuid::new_v5(&namespace, name.as_bytes()).to_string(),
1088    )))
1089}
1090
1091#[harn_builtin(
1092    exposure = "pure",
1093    effects = [],
1094    sig = "uuid_nil() -> string",
1095    category = "io",
1096    doc = "Return the nil UUID."
1097)]
1098fn uuid_nil_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1099    Ok(VmValue::String(arcstr::ArcStr::from(
1100        uuid::Uuid::nil().to_string(),
1101    )))
1102}
1103
1104pub(crate) fn prompt_user_value(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1105    let msg = args.first().map(|a| a.display()).unwrap_or_default();
1106    write_stdout(out, &msg);
1107    let options = ReadLineOptions {
1108        trim: false,
1109        ..ReadLineOptions::default()
1110    };
1111    match read_line_from_mock_or_real(&options) {
1112        ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(
1113            line.trim_end().to_string(),
1114        ))),
1115        ReadLineOutcome::Eof => Ok(VmValue::Nil),
1116        #[cfg(unix)]
1117        ReadLineOutcome::Timeout => Ok(VmValue::Nil),
1118        #[cfg(unix)]
1119        ReadLineOutcome::Interrupt => Ok(VmValue::Nil),
1120        ReadLineOutcome::Error(_) => Ok(VmValue::Nil),
1121    }
1122}
1123
1124pub(crate) fn read_password_legacy_value(prompt: &str) -> Result<VmValue, VmError> {
1125    let options = ReadLineOptions {
1126        prompt: prompt.to_string(),
1127        trim: false,
1128        echo: false,
1129        ..ReadLineOptions::default()
1130    };
1131    match read_line_from_mock_or_real(&options) {
1132        ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(line))),
1133        ReadLineOutcome::Eof => Err(VmError::Runtime(
1134            "HarnessTerm.read_password: stdin reached EOF".to_string(),
1135        )),
1136        #[cfg(unix)]
1137        ReadLineOutcome::Timeout => Err(VmError::Runtime(
1138            "HarnessTerm.read_password: stdin read timed out".to_string(),
1139        )),
1140        #[cfg(unix)]
1141        ReadLineOutcome::Interrupt => Err(VmError::Runtime(
1142            "HarnessTerm.read_password: stdin read was interrupted".to_string(),
1143        )),
1144        ReadLineOutcome::Error(error) => Err(VmError::Runtime(format!(
1145            "HarnessTerm.read_password: {error}"
1146        ))),
1147    }
1148}
1149
1150#[harn_builtin(
1151    exposure = "runtime_internal",
1152    effects = [],
1153    sig = "log_debug(message: any, fields?: dict) -> nil",
1154    category = "io",
1155    doc = "Write a structured debug log line."
1156)]
1157fn log_debug_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1158    vm_write_log("debug", 0, args, out);
1159    Ok(VmValue::Nil)
1160}
1161
1162#[harn_builtin(
1163    exposure = "runtime_internal",
1164    effects = [],
1165    sig = "log_info(message: any, fields?: dict) -> nil",
1166    category = "io",
1167    doc = "Write a structured info log line."
1168)]
1169fn log_info_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1170    vm_write_log("info", 1, args, out);
1171    Ok(VmValue::Nil)
1172}
1173
1174#[harn_builtin(
1175    exposure = "runtime_internal",
1176    effects = [],
1177    sig = "log_warn(message: any, fields?: dict) -> nil",
1178    category = "io",
1179    doc = "Write a structured warning log line."
1180)]
1181fn log_warn_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1182    vm_write_log("warn", 2, args, out);
1183    Ok(VmValue::Nil)
1184}
1185
1186#[harn_builtin(
1187    exposure = "runtime_internal",
1188    effects = [],
1189    sig = "log_error(message: any, fields?: dict) -> nil",
1190    category = "io",
1191    doc = "Write a structured error log line."
1192)]
1193fn log_error_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1194    vm_write_log("error", 3, args, out);
1195    Ok(VmValue::Nil)
1196}
1197
1198#[harn_builtin(
1199    exposure = "runtime_internal",
1200    effects = [],
1201    sig = "log_set_level(level: string) -> nil",
1202    category = "io",
1203    doc = "Set the minimum structured log level."
1204)]
1205fn log_set_level_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1206    let level_str = args.first().map(|a| a.display()).unwrap_or_default();
1207    match super::logging::vm_level_to_u8(&level_str) {
1208        Some(n) => {
1209            VM_MIN_LOG_LEVEL.store(n, Ordering::Relaxed);
1210            Ok(VmValue::Nil)
1211        }
1212        None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1213            format!(
1214                "log_set_level: invalid level '{level_str}'. Expected debug, info, warn, or error"
1215            ),
1216        )))),
1217    }
1218}
1219
1220#[harn_builtin(
1221    exposure = "runtime_internal",
1222    effects = [],
1223    sig = "progress(phase: string, message: string, progress_or_options?: any, total?: int) -> nil",
1224    category = "io",
1225    doc = "Write a human-readable progress log line."
1226)]
1227fn progress_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1228    write_stdout(out, &render_progress_line(args));
1229    Ok(VmValue::Nil)
1230}
1231
1232#[harn_builtin(
1233    exposure = "runtime_internal",
1234    effects = [],
1235    sig = "log_json(key: string, value?: any) -> nil",
1236    category = "io",
1237    doc = "Write a structured JSON log line."
1238)]
1239fn log_json_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1240    let key = args.first().map(|a| a.display()).unwrap_or_default();
1241    let value = args.get(1).cloned().unwrap_or(VmValue::Nil);
1242    let json_val = super::logging::vm_value_to_json_fragment(&value);
1243    let ts = super::logging::vm_format_timestamp_utc();
1244    let line = format!(
1245        "{{\"ts\":{},\"key\":{},\"value\":{}}}\n",
1246        vm_escape_json_str_quoted(&ts),
1247        vm_escape_json_str_quoted(&key),
1248        json_val,
1249    );
1250    write_stdout(out, &line);
1251    Ok(VmValue::Nil)
1252}
1253
1254fn uuid_v5_namespace(raw: &str) -> Option<uuid::Uuid> {
1255    match raw.to_ascii_lowercase().as_str() {
1256        "dns" | "namespace_dns" => Some(uuid::Uuid::NAMESPACE_DNS),
1257        "url" | "namespace_url" => Some(uuid::Uuid::NAMESPACE_URL),
1258        "oid" | "namespace_oid" => Some(uuid::Uuid::NAMESPACE_OID),
1259        "x500" | "namespace_x500" => Some(uuid::Uuid::NAMESPACE_X500),
1260        _ => uuid::Uuid::parse_str(raw).ok(),
1261    }
1262}
1263
1264fn render_progress_line(args: &[VmValue]) -> String {
1265    let phase = args.first().map(|a| a.display()).unwrap_or_default();
1266    let message = args.get(1).map(|a| a.display()).unwrap_or_default();
1267
1268    if let Some(options) = args.get(2).and_then(|arg| arg.as_dict()) {
1269        if let Some(mode) = progress_dict_str(options, "mode") {
1270            match mode {
1271                "spinner" => {
1272                    let step = progress_dict_int(options, "step")
1273                        .or_else(|| progress_dict_int(options, "current"))
1274                        .unwrap_or(0);
1275                    let frame = spinner_frame(step);
1276                    return format!("[{phase}] {frame} {message}\n");
1277                }
1278                "bar" => {
1279                    let current = progress_dict_int(options, "current").unwrap_or(0);
1280                    let total = progress_dict_int(options, "total").unwrap_or(0);
1281                    let width = progress_dict_int(options, "width")
1282                        .unwrap_or(10)
1283                        .clamp(3, 40) as usize;
1284                    let bar = render_progress_bar(current, total, width);
1285                    return format!("[{phase}] {bar} {message} ({current}/{total})\n");
1286                }
1287                _ => {}
1288            }
1289        }
1290    }
1291
1292    let progress = args.get(2).and_then(|a| a.as_int());
1293    let total = args.get(3).and_then(|a| a.as_int());
1294    match (progress, total) {
1295        (Some(p), Some(t)) => format!("[{phase}] {message} ({p}/{t})\n"),
1296        (Some(p), None) => format!("[{phase}] {message} ({p}%)\n"),
1297        _ => format!("[{phase}] {message}\n"),
1298    }
1299}
1300
1301fn progress_dict_int(options: &crate::value::DictMap, key: &str) -> Option<i64> {
1302    options.get(key).and_then(|value| value.as_int())
1303}
1304
1305fn progress_dict_str<'a>(options: &'a crate::value::DictMap, key: &str) -> Option<&'a str> {
1306    match options.get(key) {
1307        Some(VmValue::String(value)) => Some(value.as_ref()),
1308        _ => None,
1309    }
1310}
1311
1312fn spinner_frame(step: i64) -> &'static str {
1313    match step.rem_euclid(4) {
1314        0 => "|",
1315        1 => "/",
1316        2 => "-",
1317        _ => "\\",
1318    }
1319}
1320
1321fn render_progress_bar(current: i64, total: i64, width: usize) -> String {
1322    if total <= 0 {
1323        return format!("[{}]", "-".repeat(width));
1324    }
1325
1326    let clamped = current.clamp(0, total);
1327    let filled = ((clamped as f64 / total as f64) * width as f64).round() as usize;
1328    let filled = filled.min(width);
1329    let empty = width.saturating_sub(filled);
1330    format!("[{}{}]", "#".repeat(filled), "-".repeat(empty))
1331}
1332
1333fn vm_write_log(level: &str, level_num: u8, args: &[VmValue], out: &mut String) {
1334    if level_num < VM_MIN_LOG_LEVEL.load(Ordering::Relaxed) {
1335        return;
1336    }
1337    let msg = args.first().map(|a| a.display()).unwrap_or_default();
1338    let fields = args.get(1).and_then(|v| {
1339        if let VmValue::Dict(d) = v {
1340            Some(&**d)
1341        } else {
1342            None
1343        }
1344    });
1345    let line = vm_build_log_line(level, &msg, fields);
1346    write_stdout(out, &line);
1347}
1348
1349fn ansi_colorize(text: &str, name: &str) -> String {
1350    let code = match name {
1351        "black" => "30",
1352        "red" => "31",
1353        "green" => "32",
1354        "yellow" => "33",
1355        "blue" => "34",
1356        "magenta" => "35",
1357        "cyan" => "36",
1358        "white" => "37",
1359        "bright_black" | "gray" | "grey" => "90",
1360        "bright_red" => "91",
1361        "bright_green" => "92",
1362        "bright_yellow" => "93",
1363        "bright_blue" => "94",
1364        "bright_magenta" => "95",
1365        "bright_cyan" => "96",
1366        "bright_white" => "97",
1367        _ => return text.to_string(),
1368    };
1369    format!("\u{1b}[{code}m{text}\u{1b}[0m")
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use crate::value::VmDictExt;
1375    use std::collections::BTreeMap;
1376    #[cfg(unix)]
1377    use std::sync::atomic::{AtomicBool, Ordering};
1378    #[cfg(unix)]
1379    use std::sync::Arc;
1380    #[cfg(unix)]
1381    use std::time::Instant;
1382
1383    use crate::value::VmValue;
1384
1385    use super::{
1386        mock_stdin_builtin, read_line_from_mock_or_real, read_stdin_builtin, render_progress_bar,
1387        render_progress_line, reserve_stdio_for_current_thread, reset_io_state,
1388        set_stdout_passthrough, spinner_frame, stdout_passthrough_enabled, ReadLineOptions,
1389        ReadLineOutcome, STDIN_ALLOWED, STDOUT_ALLOWED,
1390    };
1391
1392    static_assertions::assert_not_impl_any!(super::StdioReservationGuard: Send, Sync);
1393
1394    #[test]
1395    fn stdout_passthrough_state_toggles() {
1396        reset_io_state();
1397
1398        assert!(!stdout_passthrough_enabled());
1399        assert!(!set_stdout_passthrough(true));
1400        assert!(stdout_passthrough_enabled());
1401
1402        assert!(set_stdout_passthrough(false));
1403        assert!(!stdout_passthrough_enabled());
1404    }
1405
1406    #[test]
1407    fn scoped_stdio_reservation_is_repeatable_and_restores_prior_policy() {
1408        reset_io_state();
1409        assert!(STDIN_ALLOWED.get());
1410        assert!(STDOUT_ALLOWED.get());
1411
1412        {
1413            let _outer = reserve_stdio_for_current_thread();
1414            assert_eq!(
1415                read_line_from_mock_or_real(&ReadLineOptions::default()),
1416                ReadLineOutcome::Eof
1417            );
1418            assert!(matches!(
1419                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1420                VmValue::Nil
1421            ));
1422            assert!(matches!(
1423                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1424                VmValue::Nil
1425            ));
1426            mock_stdin_builtin(&[VmValue::string("fixture")], &mut String::new()).unwrap();
1427            assert_eq!(
1428                read_stdin_builtin(&[], &mut String::new())
1429                    .unwrap()
1430                    .display(),
1431                "fixture"
1432            );
1433            assert!(matches!(
1434                read_stdin_builtin(&[], &mut String::new()).unwrap(),
1435                VmValue::Nil
1436            ));
1437            {
1438                let _inner = reserve_stdio_for_current_thread();
1439                assert!(!STDIN_ALLOWED.get());
1440                assert!(!STDOUT_ALLOWED.get());
1441            }
1442            assert!(!STDIN_ALLOWED.get());
1443            assert!(!STDOUT_ALLOWED.get());
1444        }
1445
1446        assert!(STDIN_ALLOWED.get());
1447        assert!(STDOUT_ALLOWED.get());
1448    }
1449
1450    #[test]
1451    fn progress_bar_mode_renders_hash_bar() {
1452        let mut options = BTreeMap::new();
1453        options.put_str("mode", "bar");
1454        options.insert("current".to_string(), VmValue::Int(3));
1455        options.insert("total".to_string(), VmValue::Int(5));
1456        options.insert("width".to_string(), VmValue::Int(10));
1457
1458        let line = render_progress_line(&[
1459            VmValue::String(arcstr::ArcStr::from("build")),
1460            VmValue::String(arcstr::ArcStr::from("Compiling")),
1461            VmValue::dict(options),
1462        ]);
1463
1464        assert_eq!(line, "[build] [######----] Compiling (3/5)\n");
1465    }
1466
1467    #[test]
1468    fn progress_spinner_mode_uses_step_to_pick_frame() {
1469        let mut options = BTreeMap::new();
1470        options.put_str("mode", "spinner");
1471        options.insert("step".to_string(), VmValue::Int(2));
1472
1473        let line = render_progress_line(&[
1474            VmValue::String(arcstr::ArcStr::from("sync")),
1475            VmValue::String(arcstr::ArcStr::from("Waiting")),
1476            VmValue::dict(options),
1477        ]);
1478
1479        assert_eq!(line, "[sync] - Waiting\n");
1480        assert_eq!(spinner_frame(3), "\\");
1481    }
1482
1483    #[test]
1484    fn progress_bar_falls_back_to_empty_bar_for_zero_total() {
1485        assert_eq!(render_progress_bar(2, 0, 5), "[-----]");
1486    }
1487
1488    #[test]
1489    fn read_line_options_preserve_prompt_whitespace() {
1490        let mut options = BTreeMap::new();
1491        options.put_str("prompt", "  > ");
1492        options.insert("trim".to_string(), VmValue::Bool(false));
1493
1494        let parsed = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap();
1495
1496        assert_eq!(parsed.prompt, "  > ");
1497        assert!(!parsed.trim);
1498    }
1499
1500    #[test]
1501    fn read_line_options_reject_unknown_keys() {
1502        let mut options = BTreeMap::new();
1503        options.put_str("promtp", "> ");
1504
1505        let err = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap_err();
1506
1507        match err {
1508            crate::value::VmError::Runtime(message) => assert!(message.contains("promtp")),
1509            other => panic!("expected Runtime error, got {other:?}"),
1510        }
1511    }
1512
1513    #[cfg(unix)]
1514    struct FdGuard(libc::c_int);
1515
1516    #[cfg(unix)]
1517    impl Drop for FdGuard {
1518        fn drop(&mut self) {
1519            let _ = unsafe { libc::close(self.0) };
1520        }
1521    }
1522
1523    #[cfg(unix)]
1524    fn pipe_pair() -> (FdGuard, FdGuard) {
1525        let mut fds = [0; 2];
1526        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
1527        (FdGuard(fds[0]), FdGuard(fds[1]))
1528    }
1529
1530    #[cfg(unix)]
1531    #[test]
1532    fn read_line_from_fd_times_out_without_data() {
1533        let (read_fd, _write_fd) = pipe_pair();
1534        let outcome = super::read_line_from_fd_unix(
1535            read_fd.0,
1536            &ReadLineOptions {
1537                timeout_ms: Some(10),
1538                ..ReadLineOptions::default()
1539            },
1540        );
1541
1542        assert_eq!(outcome, ReadLineOutcome::Timeout);
1543    }
1544
1545    #[cfg(unix)]
1546    #[test]
1547    fn read_line_from_fd_observes_interrupt_without_stdin_activity() {
1548        let (read_fd, _write_fd) = pipe_pair();
1549        let cancel = Arc::new(AtomicBool::new(false));
1550        let cancel_from_thread = Arc::clone(&cancel);
1551        let _guard = crate::op_interrupt::install(Some(cancel), None);
1552        let interrupter = std::thread::spawn(move || {
1553            // No fixed "wait for the reader to park" sleep: the reader re-checks
1554            // the cancel flag every `READ_LINE_INTERRUPT_POLL` heartbeat, so it
1555            // observes this store within one interval regardless of ordering.
1556            // A blind sleep would only add wall time and reintroduce a race.
1557            cancel_from_thread.store(true, Ordering::SeqCst);
1558        });
1559
1560        let started = Instant::now();
1561        let outcome = super::read_line_from_fd_unix(read_fd.0, &ReadLineOptions::default());
1562
1563        interrupter.join().expect("interrupter thread joins");
1564        assert_eq!(outcome, ReadLineOutcome::Interrupt);
1565        assert!(
1566            started.elapsed() < super::READ_LINE_INTERRUPT_POLL * 25,
1567            "interrupt heartbeat should wake idle read_line within a few poll \
1568             intervals, took {:?}",
1569            started.elapsed()
1570        );
1571    }
1572
1573    #[cfg(unix)]
1574    #[test]
1575    fn read_line_from_fd_honors_trim_option() {
1576        let (read_fd, write_fd) = pipe_pair();
1577        let payload = b"  alpha  \n";
1578        assert_eq!(
1579            unsafe { libc::write(write_fd.0, payload.as_ptr().cast(), payload.len()) },
1580            payload.len() as isize
1581        );
1582        let outcome = super::read_line_from_fd_unix(
1583            read_fd.0,
1584            &ReadLineOptions {
1585                timeout_ms: Some(100),
1586                trim: false,
1587                ..ReadLineOptions::default()
1588            },
1589        );
1590
1591        assert_eq!(outcome, ReadLineOutcome::Ok("  alpha  ".to_string()));
1592    }
1593}