Skip to main content

harn_vm/stdlib/
io.rs

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