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