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#[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
102pub 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
152pub(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
163pub 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
177pub 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 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 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, "read_stdin", read_stdin_builtin);
624 vm.register_capability_method(CapabilityId::Stdio, "is_stdin_tty", is_stdin_tty_builtin);
625 vm.register_capability_method(CapabilityId::Stdio, "is_stdout_tty", is_stdout_tty_builtin);
626 vm.register_capability_method(CapabilityId::Stdio, "is_stderr_tty", is_stderr_tty_builtin);
627 vm.register_capability_method(CapabilityId::Testing, "stdin_set", mock_stdin_builtin);
628 vm.register_capability_method(CapabilityId::Testing, "stdin_reset", unmock_stdin_builtin);
629 vm.register_capability_method(CapabilityId::Testing, "tty_set", mock_tty_builtin);
630 vm.register_capability_method(CapabilityId::Testing, "tty_reset", unmock_tty_builtin);
631 vm.register_capability_method(
632 CapabilityId::Testing,
633 "capture_stderr_start",
634 capture_stderr_start_builtin,
635 );
636 vm.register_capability_method(
637 CapabilityId::Testing,
638 "capture_stderr_take",
639 capture_stderr_take_builtin,
640 );
641}
642
643#[harn_builtin(
644 exposure = "harness.stdio.log",
645 effects = ["stdio.write@const=stdout"],
646 sig = "__cap_stdio_log(message: any) -> nil",
647 aliases = ["log"],
648 category = "io",
649 doc = "Write a Harn-prefixed message to stdout."
650)]
651fn log_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
652 let msg = args.first().map(|a| a.display()).unwrap_or_default();
653 write_stdout(out, &format!("[harn] {msg}\n"));
654 Ok(VmValue::Nil)
655}
656
657#[harn_builtin(
658 exposure = "pure",
659 effects = [],
660 sig = "color(text: any, color: string) -> string",
661 category = "io",
662 doc = "Apply an ANSI foreground color when color output is enabled."
663)]
664fn color_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
665 let text = args.first().map(|a| a.display()).unwrap_or_default();
666 let name = args.get(1).map(|a| a.display()).unwrap_or_default();
667 if !ansi_enabled_for_stream("stdout") {
668 return Ok(VmValue::String(arcstr::ArcStr::from(text)));
669 }
670 Ok(VmValue::String(arcstr::ArcStr::from(ansi_colorize(
671 &text, &name,
672 ))))
673}
674
675#[harn_builtin(
676 exposure = "pure",
677 effects = [],
678 sig = "bold(text: any) -> string",
679 category = "io",
680 doc = "Apply ANSI bold styling when color output is enabled."
681)]
682fn bold_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
683 let text = args.first().map(|a| a.display()).unwrap_or_default();
684 if !ansi_enabled_for_stream("stdout") {
685 return Ok(VmValue::String(arcstr::ArcStr::from(text)));
686 }
687 Ok(VmValue::String(arcstr::ArcStr::from(format!(
688 "\u{1b}[1m{text}\u{1b}[0m"
689 ))))
690}
691
692#[harn_builtin(
693 exposure = "pure",
694 effects = [],
695 sig = "dim(text: any) -> string",
696 category = "io",
697 doc = "Apply ANSI dim styling when color output is enabled."
698)]
699fn dim_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
700 let text = args.first().map(|a| a.display()).unwrap_or_default();
701 if !ansi_enabled_for_stream("stdout") {
702 return Ok(VmValue::String(arcstr::ArcStr::from(text)));
703 }
704 Ok(VmValue::String(arcstr::ArcStr::from(format!(
705 "\u{1b}[2m{text}\u{1b}[0m"
706 ))))
707}
708
709#[harn_builtin(
710 exposure = "runtime_internal",
711 effects = [],
712 sig = "set_color_mode(mode: string) -> nil",
713 category = "io",
714 doc = "Set ANSI color handling to auto, always, or never."
715)]
716fn set_color_mode_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
717 let mode = args.first().map(|a| a.display()).unwrap_or_default();
718 let parsed = match mode.as_str() {
719 "auto" => ColorMode::Auto,
720 "always" => ColorMode::Always,
721 "never" => ColorMode::Never,
722 other => {
723 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
724 format!(
725 "set_color_mode: invalid mode '{other}'. Expected 'auto', 'always', or 'never'."
726 ),
727 ))));
728 }
729 };
730 COLOR_MODE.with(|m| *m.borrow_mut() = parsed);
731 Ok(VmValue::Nil)
732}
733
734#[harn_builtin(
735 exposure = "runtime_internal",
736 effects = [],
737 sig = "__ansi_enabled(stream?: string) -> bool",
738 category = "io",
739 doc = "Return whether ANSI styling is enabled for stdin, stdout, or stderr."
740)]
741fn ansi_enabled_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
742 let stream = args
743 .first()
744 .map(|a| a.display())
745 .unwrap_or_else(|| "stdout".to_string());
746 match stream.as_str() {
747 "stdin" | "stdout" | "stderr" => Ok(VmValue::Bool(ansi_enabled_for_stream(&stream))),
748 other => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
749 format!(
750 "__ansi_enabled: invalid stream '{other}'. Expected 'stdin', 'stdout', or 'stderr'."
751 ),
752 )))),
753 }
754}
755
756#[harn_builtin(
757 exposure = "runtime_internal",
758 effects = [],
759 sig = "read_stdin() -> string",
760 category = "io",
761 doc = "Read all remaining stdin as a string."
762)]
763fn read_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
764 let mocked = STDIN_MOCK.with(|s| s.borrow_mut().take());
766 if let Some(buf) = mocked {
767 STDIN_LINES.with(|lines| *lines.borrow_mut() = Some(VecDeque::new()));
769 return Ok(VmValue::String(arcstr::ArcStr::from(buf)));
770 }
771 if !STDIN_ALLOWED.get() {
772 return Ok(VmValue::Nil);
773 }
774 match read_stdin_all_real() {
775 Some(s) => Ok(VmValue::String(arcstr::ArcStr::from(s))),
776 None => Ok(VmValue::Nil),
777 }
778}
779
780pub(crate) fn read_line_legacy_value() -> VmValue {
781 let options = ReadLineOptions {
782 trim: false,
783 ..ReadLineOptions::default()
784 };
785 match read_line_from_mock_or_real(&options) {
786 ReadLineOutcome::Ok(line) => VmValue::String(arcstr::ArcStr::from(line)),
787 ReadLineOutcome::Eof => VmValue::Nil,
788 #[cfg(unix)]
789 ReadLineOutcome::Timeout => VmValue::Nil,
790 #[cfg(unix)]
791 ReadLineOutcome::Interrupt => VmValue::Nil,
792 ReadLineOutcome::Error(_) => VmValue::Nil,
793 }
794}
795
796pub(crate) fn read_line_structured_value(args: &[VmValue]) -> Result<VmValue, VmError> {
797 let options = parse_read_line_options(args)?;
798 Ok(read_line_result(read_line_from_mock_or_real(&options)))
799}
800
801#[harn_builtin(
802 exposure = "runtime_internal",
803 effects = [],
804 sig = "__io_read_line(options?: any) -> dict",
805 category = "io",
806 doc = "Read one line from stdin with structured status metadata."
807)]
808fn io_read_line_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
809 read_line_structured_value(args)
810}
811
812#[harn_builtin(
813 exposure = "runtime_internal",
814 effects = [],
815 sig = "__io_write_stderr(message: any) -> nil",
816 category = "io",
817 doc = "Write text to stderr without appending a newline."
818)]
819fn io_write_stderr_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
820 let msg = args.first().map(|a| a.display()).unwrap_or_default();
821 write_stderr(&msg);
822 Ok(VmValue::Nil)
823}
824
825#[harn_builtin(
826 exposure = "runtime_internal",
827 effects = [],
828 sig = "__io_write_stdout(message: any) -> nil",
829 category = "io",
830 doc = "Write text to stdout without appending a newline."
831)]
832fn io_write_stdout_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
833 let msg = args.first().map(|a| a.display()).unwrap_or_default();
834 write_stdout(out, &msg);
835 Ok(VmValue::Nil)
836}
837
838#[harn_builtin(
839 exposure = "runtime_internal",
840 effects = [],
841 sig = "__io_print(...args: any) -> nil",
842 category = "io",
843 doc = "Internal compatibility bridge for stdout without newline."
844)]
845fn io_print_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
846 let msg = args.first().map(|a| a.display()).unwrap_or_default();
847 write_stdout(out, &msg);
848 Ok(VmValue::Nil)
849}
850
851#[harn_builtin(
852 exposure = "runtime_internal",
853 effects = [],
854 sig = "__io_println(...args: any) -> nil",
855 category = "io",
856 doc = "Internal compatibility bridge for stdout with newline."
857)]
858fn io_println_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
859 let msg = args.first().map(|a| a.display()).unwrap_or_default();
860 write_stdout(out, &format!("{msg}\n"));
861 Ok(VmValue::Nil)
862}
863
864#[harn_builtin(
865 exposure = "runtime_internal",
866 effects = [],
867 sig = "__io_eprint(message: any) -> nil",
868 category = "io",
869 doc = "Internal compatibility bridge for stderr without newline."
870)]
871fn io_eprint_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
872 let msg = args.first().map(|a| a.display()).unwrap_or_default();
873 write_stderr(&msg);
874 Ok(VmValue::Nil)
875}
876
877#[harn_builtin(
878 exposure = "runtime_internal",
879 effects = [],
880 sig = "__io_eprintln(message: any) -> nil",
881 category = "io",
882 doc = "Internal compatibility bridge for stderr with newline."
883)]
884fn io_eprintln_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
885 let msg = args.first().map(|a| a.display()).unwrap_or_default();
886 write_stderr(&format!("{msg}\n"));
887 Ok(VmValue::Nil)
888}
889
890#[harn_builtin(
891 exposure = "runtime_internal",
892 effects = [],
893 sig = "is_stdin_tty() -> bool",
894 category = "io",
895 doc = "Return whether stdin is attached to a terminal."
896)]
897fn is_stdin_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
898 Ok(VmValue::Bool(is_tty_for("stdin")))
899}
900
901#[harn_builtin(
902 exposure = "runtime_internal",
903 effects = [],
904 sig = "is_stdout_tty() -> bool",
905 category = "io",
906 doc = "Return whether stdout is attached to a terminal."
907)]
908fn is_stdout_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
909 Ok(VmValue::Bool(is_tty_for("stdout")))
910}
911
912#[harn_builtin(
913 exposure = "runtime_internal",
914 effects = [],
915 sig = "is_stderr_tty() -> bool",
916 category = "io",
917 doc = "Return whether stderr is attached to a terminal."
918)]
919fn is_stderr_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
920 Ok(VmValue::Bool(is_tty_for("stderr")))
921}
922
923#[harn_builtin(
924 exposure = "runtime_internal",
925 effects = [],
926 sig = "mock_stdin(text: string) -> nil",
927 category = "io",
928 doc = "Install mocked stdin text for tests."
929)]
930fn mock_stdin_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
931 let text = args.first().map(|a| a.display()).unwrap_or_default();
932 STDIN_MOCK.with(|s| *s.borrow_mut() = Some(text));
933 STDIN_LINES.with(|s| *s.borrow_mut() = None);
934 Ok(VmValue::Nil)
935}
936
937#[harn_builtin(
938 exposure = "runtime_internal",
939 effects = [],
940 sig = "unmock_stdin() -> nil",
941 category = "io",
942 doc = "Clear mocked stdin text and line state."
943)]
944fn unmock_stdin_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
945 STDIN_MOCK.with(|s| *s.borrow_mut() = None);
946 STDIN_LINES.with(|s| *s.borrow_mut() = None);
947 Ok(VmValue::Nil)
948}
949
950#[harn_builtin(
951 exposure = "runtime_internal",
952 effects = [],
953 sig = "mock_tty(stream: string, is_tty: bool) -> nil",
954 category = "io",
955 doc = "Override terminal detection for stdin, stdout, or stderr."
956)]
957fn mock_tty_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
958 let stream = args.first().map(|a| a.display()).unwrap_or_default();
959 let is_tty = matches!(args.get(1), Some(VmValue::Bool(true)));
960 TTY_MOCK.with(|t| {
961 let mut mock = t.borrow_mut();
962 match stream.as_str() {
963 "stdin" => mock.stdin = Some(is_tty),
964 "stdout" => mock.stdout = Some(is_tty),
965 "stderr" => mock.stderr = Some(is_tty),
966 other => {
967 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
968 format!(
969 "mock_tty: invalid stream '{other}'. Expected 'stdin', 'stdout', or 'stderr'."
970 ),
971 ))));
972 }
973 }
974 Ok(VmValue::Nil)
975 })
976}
977
978#[harn_builtin(
979 exposure = "runtime_internal",
980 effects = [],
981 sig = "unmock_tty() -> nil",
982 category = "io",
983 doc = "Clear terminal detection overrides."
984)]
985fn unmock_tty_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
986 TTY_MOCK.with(|t| *t.borrow_mut() = TtyMock::default());
987 Ok(VmValue::Nil)
988}
989
990#[harn_builtin(
991 exposure = "runtime_internal",
992 effects = [],
993 sig = "capture_stderr_start() -> nil",
994 category = "io",
995 doc = "Start capturing stderr into an in-memory buffer."
996)]
997fn capture_stderr_start_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
998 STDERR_CAPTURING.with(|c| *c.borrow_mut() = true);
999 STDERR_BUFFER.with(|s| s.borrow_mut().clear());
1000 Ok(VmValue::Nil)
1001}
1002
1003#[harn_builtin(
1004 exposure = "runtime_internal",
1005 effects = [],
1006 sig = "capture_stderr_take() -> string",
1007 category = "io",
1008 doc = "Stop stderr capture and return the buffered text."
1009)]
1010fn capture_stderr_take_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1011 let buf = STDERR_BUFFER.with(|s| std::mem::take(&mut *s.borrow_mut()));
1012 STDERR_CAPTURING.with(|c| *c.borrow_mut() = false);
1013 Ok(VmValue::String(arcstr::ArcStr::from(buf)))
1014}
1015
1016#[harn_builtin(
1017 exposure = "runtime_internal",
1018 effects = [],
1019 sig = "uuid() -> string",
1020 category = "io",
1021 doc = "Generate a random version 4 UUID."
1022)]
1023fn uuid_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1024 Ok(VmValue::String(arcstr::ArcStr::from(
1025 uuid::Uuid::new_v4().to_string(),
1026 )))
1027}
1028
1029#[harn_builtin(
1030 exposure = "pure",
1031 effects = [],
1032 sig = "uuid_parse(value: any) -> string",
1033 category = "io",
1034 doc = "Parse and normalize a UUID string, or return nil."
1035)]
1036fn uuid_parse_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1037 let raw = args.first().map(|a| a.display()).unwrap_or_default();
1038 match uuid::Uuid::parse_str(&raw) {
1039 Ok(uuid) => Ok(VmValue::String(arcstr::ArcStr::from(uuid.to_string()))),
1040 Err(_) => Ok(VmValue::Nil),
1041 }
1042}
1043
1044#[harn_builtin(
1045 exposure = "runtime_internal",
1046 effects = [],
1047 sig = "uuid_v7() -> string",
1048 category = "io",
1049 doc = "Generate a time-ordered version 7 UUID."
1050)]
1051fn uuid_v7_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1052 Ok(VmValue::String(arcstr::ArcStr::from(
1053 uuid::Uuid::now_v7().to_string(),
1054 )))
1055}
1056
1057#[harn_builtin(
1058 exposure = "pure",
1059 effects = [],
1060 sig = "uuid_v5(namespace: string, name: string) -> string",
1061 category = "io",
1062 doc = "Generate a deterministic version 5 UUID."
1063)]
1064fn uuid_v5_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1065 if args.len() < 2 {
1066 return Err(VmError::Runtime(
1067 "uuid_v5(namespace, name): requires namespace and name".to_string(),
1068 ));
1069 }
1070 let namespace_raw = args[0].display();
1071 let namespace = uuid_v5_namespace(&namespace_raw).ok_or_else(|| {
1072 VmError::Runtime("uuid_v5: namespace must be a UUID or one of dns/url/oid/x500".to_string())
1073 })?;
1074 let name = args[1].display();
1075 Ok(VmValue::String(arcstr::ArcStr::from(
1076 uuid::Uuid::new_v5(&namespace, name.as_bytes()).to_string(),
1077 )))
1078}
1079
1080#[harn_builtin(
1081 exposure = "pure",
1082 effects = [],
1083 sig = "uuid_nil() -> string",
1084 category = "io",
1085 doc = "Return the nil UUID."
1086)]
1087fn uuid_nil_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1088 Ok(VmValue::String(arcstr::ArcStr::from(
1089 uuid::Uuid::nil().to_string(),
1090 )))
1091}
1092
1093pub(crate) fn prompt_user_value(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1094 let msg = args.first().map(|a| a.display()).unwrap_or_default();
1095 write_stdout(out, &msg);
1096 let options = ReadLineOptions {
1097 trim: false,
1098 ..ReadLineOptions::default()
1099 };
1100 match read_line_from_mock_or_real(&options) {
1101 ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(
1102 line.trim_end().to_string(),
1103 ))),
1104 ReadLineOutcome::Eof => Ok(VmValue::Nil),
1105 #[cfg(unix)]
1106 ReadLineOutcome::Timeout => Ok(VmValue::Nil),
1107 #[cfg(unix)]
1108 ReadLineOutcome::Interrupt => Ok(VmValue::Nil),
1109 ReadLineOutcome::Error(_) => Ok(VmValue::Nil),
1110 }
1111}
1112
1113pub(crate) fn read_password_legacy_value(prompt: &str) -> Result<VmValue, VmError> {
1114 let options = ReadLineOptions {
1115 prompt: prompt.to_string(),
1116 trim: false,
1117 echo: false,
1118 ..ReadLineOptions::default()
1119 };
1120 match read_line_from_mock_or_real(&options) {
1121 ReadLineOutcome::Ok(line) => Ok(VmValue::String(arcstr::ArcStr::from(line))),
1122 ReadLineOutcome::Eof => Err(VmError::Runtime(
1123 "HarnessTerm.read_password: stdin reached EOF".to_string(),
1124 )),
1125 #[cfg(unix)]
1126 ReadLineOutcome::Timeout => Err(VmError::Runtime(
1127 "HarnessTerm.read_password: stdin read timed out".to_string(),
1128 )),
1129 #[cfg(unix)]
1130 ReadLineOutcome::Interrupt => Err(VmError::Runtime(
1131 "HarnessTerm.read_password: stdin read was interrupted".to_string(),
1132 )),
1133 ReadLineOutcome::Error(error) => Err(VmError::Runtime(format!(
1134 "HarnessTerm.read_password: {error}"
1135 ))),
1136 }
1137}
1138
1139#[harn_builtin(
1140 exposure = "harness.obs.log_debug",
1141 effects = ["observability.write@const=log"],
1142 sig = "__cap_obs_log_debug(message: any, fields?: dict) -> nil",
1143 aliases = ["log_debug"],
1144 category = "io",
1145 doc = "Write a structured debug log line."
1146)]
1147fn log_debug_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1148 vm_write_log("debug", 0, args, out);
1149 Ok(VmValue::Nil)
1150}
1151
1152#[harn_builtin(
1153 exposure = "harness.obs.log_info",
1154 effects = ["observability.write@const=log"],
1155 sig = "__cap_obs_log_info(message: any, fields?: dict) -> nil",
1156 aliases = ["log_info"],
1157 category = "io",
1158 doc = "Write a structured info log line."
1159)]
1160fn log_info_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1161 vm_write_log("info", 1, args, out);
1162 Ok(VmValue::Nil)
1163}
1164
1165#[harn_builtin(
1166 exposure = "harness.obs.log_warn",
1167 effects = ["observability.write@const=log"],
1168 sig = "__cap_obs_log_warn(message: any, fields?: dict) -> nil",
1169 aliases = ["log_warn"],
1170 category = "io",
1171 doc = "Write a structured warning log line."
1172)]
1173fn log_warn_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1174 vm_write_log("warn", 2, args, out);
1175 Ok(VmValue::Nil)
1176}
1177
1178#[harn_builtin(
1179 exposure = "harness.obs.log_error",
1180 effects = ["observability.write@const=log"],
1181 sig = "__cap_obs_log_error(message: any, fields?: dict) -> nil",
1182 aliases = ["log_error"],
1183 category = "io",
1184 doc = "Write a structured error log line."
1185)]
1186fn log_error_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1187 vm_write_log("error", 3, args, out);
1188 Ok(VmValue::Nil)
1189}
1190
1191#[harn_builtin(
1192 exposure = "harness.obs.set_level",
1193 effects = ["observability.mutate@const=log-level"],
1194 sig = "__cap_obs_set_level(level: string) -> nil",
1195 aliases = ["log_set_level"],
1196 category = "io",
1197 doc = "Set the minimum structured log level."
1198)]
1199fn log_set_level_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1200 let level_str = args.first().map(|a| a.display()).unwrap_or_default();
1201 match super::logging::vm_level_to_u8(&level_str) {
1202 Some(n) => {
1203 VM_MIN_LOG_LEVEL.store(n, Ordering::Relaxed);
1204 Ok(VmValue::Nil)
1205 }
1206 None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
1207 format!(
1208 "log_set_level: invalid level '{level_str}'. Expected debug, info, warn, or error"
1209 ),
1210 )))),
1211 }
1212}
1213
1214#[harn_builtin(
1215 exposure = "harness.stdio.progress",
1216 effects = ["stdio.write@const=stdout"],
1217 sig = "__cap_stdio_progress(phase: string, message: string, progress_or_options?: any, total?: int) -> nil",
1218 aliases = ["progress"],
1219 category = "io",
1220 doc = "Write a human-readable progress log line."
1221)]
1222fn progress_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1223 write_stdout(out, &render_progress_line(args));
1224 Ok(VmValue::Nil)
1225}
1226
1227#[harn_builtin(
1228 exposure = "harness.obs.log_json",
1229 effects = ["observability.write@const=log"],
1230 sig = "__cap_obs_log_json(key: string, value?: any) -> nil",
1231 aliases = ["log_json"],
1232 category = "io",
1233 doc = "Write a structured JSON log line."
1234)]
1235fn log_json_builtin(args: &[VmValue], out: &mut String) -> Result<VmValue, VmError> {
1236 let key = args.first().map(|a| a.display()).unwrap_or_default();
1237 let value = args.get(1).cloned().unwrap_or(VmValue::Nil);
1238 let json_val = super::logging::vm_value_to_json_fragment(&value);
1239 let ts = super::logging::vm_format_timestamp_utc();
1240 let line = format!(
1241 "{{\"ts\":{},\"key\":{},\"value\":{}}}\n",
1242 vm_escape_json_str_quoted(&ts),
1243 vm_escape_json_str_quoted(&key),
1244 json_val,
1245 );
1246 write_stdout(out, &line);
1247 Ok(VmValue::Nil)
1248}
1249
1250fn uuid_v5_namespace(raw: &str) -> Option<uuid::Uuid> {
1251 match raw.to_ascii_lowercase().as_str() {
1252 "dns" | "namespace_dns" => Some(uuid::Uuid::NAMESPACE_DNS),
1253 "url" | "namespace_url" => Some(uuid::Uuid::NAMESPACE_URL),
1254 "oid" | "namespace_oid" => Some(uuid::Uuid::NAMESPACE_OID),
1255 "x500" | "namespace_x500" => Some(uuid::Uuid::NAMESPACE_X500),
1256 _ => uuid::Uuid::parse_str(raw).ok(),
1257 }
1258}
1259
1260fn render_progress_line(args: &[VmValue]) -> String {
1261 let phase = args.first().map(|a| a.display()).unwrap_or_default();
1262 let message = args.get(1).map(|a| a.display()).unwrap_or_default();
1263
1264 if let Some(options) = args.get(2).and_then(|arg| arg.as_dict()) {
1265 if let Some(mode) = progress_dict_str(options, "mode") {
1266 match mode {
1267 "spinner" => {
1268 let step = progress_dict_int(options, "step")
1269 .or_else(|| progress_dict_int(options, "current"))
1270 .unwrap_or(0);
1271 let frame = spinner_frame(step);
1272 return format!("[{phase}] {frame} {message}\n");
1273 }
1274 "bar" => {
1275 let current = progress_dict_int(options, "current").unwrap_or(0);
1276 let total = progress_dict_int(options, "total").unwrap_or(0);
1277 let width = progress_dict_int(options, "width")
1278 .unwrap_or(10)
1279 .clamp(3, 40) as usize;
1280 let bar = render_progress_bar(current, total, width);
1281 return format!("[{phase}] {bar} {message} ({current}/{total})\n");
1282 }
1283 _ => {}
1284 }
1285 }
1286 }
1287
1288 let progress = args.get(2).and_then(|a| a.as_int());
1289 let total = args.get(3).and_then(|a| a.as_int());
1290 match (progress, total) {
1291 (Some(p), Some(t)) => format!("[{phase}] {message} ({p}/{t})\n"),
1292 (Some(p), None) => format!("[{phase}] {message} ({p}%)\n"),
1293 _ => format!("[{phase}] {message}\n"),
1294 }
1295}
1296
1297fn progress_dict_int(options: &crate::value::DictMap, key: &str) -> Option<i64> {
1298 options.get(key).and_then(|value| value.as_int())
1299}
1300
1301fn progress_dict_str<'a>(options: &'a crate::value::DictMap, key: &str) -> Option<&'a str> {
1302 match options.get(key) {
1303 Some(VmValue::String(value)) => Some(value.as_ref()),
1304 _ => None,
1305 }
1306}
1307
1308fn spinner_frame(step: i64) -> &'static str {
1309 match step.rem_euclid(4) {
1310 0 => "|",
1311 1 => "/",
1312 2 => "-",
1313 _ => "\\",
1314 }
1315}
1316
1317fn render_progress_bar(current: i64, total: i64, width: usize) -> String {
1318 if total <= 0 {
1319 return format!("[{}]", "-".repeat(width));
1320 }
1321
1322 let clamped = current.clamp(0, total);
1323 let filled = ((clamped as f64 / total as f64) * width as f64).round() as usize;
1324 let filled = filled.min(width);
1325 let empty = width.saturating_sub(filled);
1326 format!("[{}{}]", "#".repeat(filled), "-".repeat(empty))
1327}
1328
1329fn vm_write_log(level: &str, level_num: u8, args: &[VmValue], out: &mut String) {
1330 if level_num < VM_MIN_LOG_LEVEL.load(Ordering::Relaxed) {
1331 return;
1332 }
1333 let msg = args.first().map(|a| a.display()).unwrap_or_default();
1334 let fields = args.get(1).and_then(|v| {
1335 if let VmValue::Dict(d) = v {
1336 Some(&**d)
1337 } else {
1338 None
1339 }
1340 });
1341 let line = vm_build_log_line(level, &msg, fields);
1342 write_stdout(out, &line);
1343}
1344
1345fn ansi_colorize(text: &str, name: &str) -> String {
1346 let code = match name {
1347 "black" => "30",
1348 "red" => "31",
1349 "green" => "32",
1350 "yellow" => "33",
1351 "blue" => "34",
1352 "magenta" => "35",
1353 "cyan" => "36",
1354 "white" => "37",
1355 "bright_black" | "gray" | "grey" => "90",
1356 "bright_red" => "91",
1357 "bright_green" => "92",
1358 "bright_yellow" => "93",
1359 "bright_blue" => "94",
1360 "bright_magenta" => "95",
1361 "bright_cyan" => "96",
1362 "bright_white" => "97",
1363 _ => return text.to_string(),
1364 };
1365 format!("\u{1b}[{code}m{text}\u{1b}[0m")
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use crate::value::VmDictExt;
1371 use std::collections::BTreeMap;
1372 #[cfg(unix)]
1373 use std::sync::atomic::{AtomicBool, Ordering};
1374 #[cfg(unix)]
1375 use std::sync::Arc;
1376 #[cfg(unix)]
1377 use std::time::Instant;
1378
1379 use crate::value::VmValue;
1380
1381 use super::{
1382 mock_stdin_builtin, read_line_from_mock_or_real, read_stdin_builtin, render_progress_bar,
1383 render_progress_line, reserve_stdio_for_current_thread, reset_io_state,
1384 set_stdout_passthrough, spinner_frame, stdout_passthrough_enabled, ReadLineOptions,
1385 ReadLineOutcome, STDIN_ALLOWED, STDOUT_ALLOWED,
1386 };
1387
1388 static_assertions::assert_not_impl_any!(super::StdioReservationGuard: Send, Sync);
1389
1390 #[test]
1391 fn stdout_passthrough_state_toggles() {
1392 reset_io_state();
1393
1394 assert!(!stdout_passthrough_enabled());
1395 assert!(!set_stdout_passthrough(true));
1396 assert!(stdout_passthrough_enabled());
1397
1398 assert!(set_stdout_passthrough(false));
1399 assert!(!stdout_passthrough_enabled());
1400 }
1401
1402 #[test]
1403 fn scoped_stdio_reservation_is_repeatable_and_restores_prior_policy() {
1404 reset_io_state();
1405 assert!(STDIN_ALLOWED.get());
1406 assert!(STDOUT_ALLOWED.get());
1407
1408 {
1409 let _outer = reserve_stdio_for_current_thread();
1410 assert_eq!(
1411 read_line_from_mock_or_real(&ReadLineOptions::default()),
1412 ReadLineOutcome::Eof
1413 );
1414 assert!(matches!(
1415 read_stdin_builtin(&[], &mut String::new()).unwrap(),
1416 VmValue::Nil
1417 ));
1418 assert!(matches!(
1419 read_stdin_builtin(&[], &mut String::new()).unwrap(),
1420 VmValue::Nil
1421 ));
1422 mock_stdin_builtin(&[VmValue::string("fixture")], &mut String::new()).unwrap();
1423 assert_eq!(
1424 read_stdin_builtin(&[], &mut String::new())
1425 .unwrap()
1426 .display(),
1427 "fixture"
1428 );
1429 assert!(matches!(
1430 read_stdin_builtin(&[], &mut String::new()).unwrap(),
1431 VmValue::Nil
1432 ));
1433 {
1434 let _inner = reserve_stdio_for_current_thread();
1435 assert!(!STDIN_ALLOWED.get());
1436 assert!(!STDOUT_ALLOWED.get());
1437 }
1438 assert!(!STDIN_ALLOWED.get());
1439 assert!(!STDOUT_ALLOWED.get());
1440 }
1441
1442 assert!(STDIN_ALLOWED.get());
1443 assert!(STDOUT_ALLOWED.get());
1444 }
1445
1446 #[test]
1447 fn progress_bar_mode_renders_hash_bar() {
1448 let mut options = BTreeMap::new();
1449 options.put_str("mode", "bar");
1450 options.insert("current".to_string(), VmValue::Int(3));
1451 options.insert("total".to_string(), VmValue::Int(5));
1452 options.insert("width".to_string(), VmValue::Int(10));
1453
1454 let line = render_progress_line(&[
1455 VmValue::String(arcstr::ArcStr::from("build")),
1456 VmValue::String(arcstr::ArcStr::from("Compiling")),
1457 VmValue::dict(options),
1458 ]);
1459
1460 assert_eq!(line, "[build] [######----] Compiling (3/5)\n");
1461 }
1462
1463 #[test]
1464 fn progress_spinner_mode_uses_step_to_pick_frame() {
1465 let mut options = BTreeMap::new();
1466 options.put_str("mode", "spinner");
1467 options.insert("step".to_string(), VmValue::Int(2));
1468
1469 let line = render_progress_line(&[
1470 VmValue::String(arcstr::ArcStr::from("sync")),
1471 VmValue::String(arcstr::ArcStr::from("Waiting")),
1472 VmValue::dict(options),
1473 ]);
1474
1475 assert_eq!(line, "[sync] - Waiting\n");
1476 assert_eq!(spinner_frame(3), "\\");
1477 }
1478
1479 #[test]
1480 fn progress_bar_falls_back_to_empty_bar_for_zero_total() {
1481 assert_eq!(render_progress_bar(2, 0, 5), "[-----]");
1482 }
1483
1484 #[test]
1485 fn read_line_options_preserve_prompt_whitespace() {
1486 let mut options = BTreeMap::new();
1487 options.put_str("prompt", " > ");
1488 options.insert("trim".to_string(), VmValue::Bool(false));
1489
1490 let parsed = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap();
1491
1492 assert_eq!(parsed.prompt, " > ");
1493 assert!(!parsed.trim);
1494 }
1495
1496 #[test]
1497 fn read_line_options_reject_unknown_keys() {
1498 let mut options = BTreeMap::new();
1499 options.put_str("promtp", "> ");
1500
1501 let err = super::parse_read_line_options(&[VmValue::dict(options)]).unwrap_err();
1502
1503 match err {
1504 crate::value::VmError::Runtime(message) => assert!(message.contains("promtp")),
1505 other => panic!("expected Runtime error, got {other:?}"),
1506 }
1507 }
1508
1509 #[cfg(unix)]
1510 struct FdGuard(libc::c_int);
1511
1512 #[cfg(unix)]
1513 impl Drop for FdGuard {
1514 fn drop(&mut self) {
1515 let _ = unsafe { libc::close(self.0) };
1516 }
1517 }
1518
1519 #[cfg(unix)]
1520 fn pipe_pair() -> (FdGuard, FdGuard) {
1521 let mut fds = [0; 2];
1522 assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
1523 (FdGuard(fds[0]), FdGuard(fds[1]))
1524 }
1525
1526 #[cfg(unix)]
1527 #[test]
1528 fn read_line_from_fd_times_out_without_data() {
1529 let (read_fd, _write_fd) = pipe_pair();
1530 let outcome = super::read_line_from_fd_unix(
1531 read_fd.0,
1532 &ReadLineOptions {
1533 timeout_ms: Some(10),
1534 ..ReadLineOptions::default()
1535 },
1536 );
1537
1538 assert_eq!(outcome, ReadLineOutcome::Timeout);
1539 }
1540
1541 #[cfg(unix)]
1542 #[test]
1543 fn read_line_from_fd_observes_interrupt_without_stdin_activity() {
1544 let (read_fd, _write_fd) = pipe_pair();
1545 let cancel = Arc::new(AtomicBool::new(false));
1546 let cancel_from_thread = Arc::clone(&cancel);
1547 let _guard = crate::op_interrupt::install(Some(cancel), None);
1548 let interrupter = std::thread::spawn(move || {
1549 cancel_from_thread.store(true, Ordering::SeqCst);
1554 });
1555
1556 let started = Instant::now();
1557 let outcome = super::read_line_from_fd_unix(read_fd.0, &ReadLineOptions::default());
1558
1559 interrupter.join().expect("interrupter thread joins");
1560 assert_eq!(outcome, ReadLineOutcome::Interrupt);
1561 assert!(
1562 started.elapsed() < super::READ_LINE_INTERRUPT_POLL * 25,
1563 "interrupt heartbeat should wake idle read_line within a few poll \
1564 intervals, took {:?}",
1565 started.elapsed()
1566 );
1567 }
1568
1569 #[cfg(unix)]
1570 #[test]
1571 fn read_line_from_fd_honors_trim_option() {
1572 let (read_fd, write_fd) = pipe_pair();
1573 let payload = b" alpha \n";
1574 assert_eq!(
1575 unsafe { libc::write(write_fd.0, payload.as_ptr().cast(), payload.len()) },
1576 payload.len() as isize
1577 );
1578 let outcome = super::read_line_from_fd_unix(
1579 read_fd.0,
1580 &ReadLineOptions {
1581 timeout_ms: Some(100),
1582 trim: false,
1583 ..ReadLineOptions::default()
1584 },
1585 );
1586
1587 assert_eq!(outcome, ReadLineOutcome::Ok(" alpha ".to_string()));
1588 }
1589}