//! Shared runtime helpers for Mercury Toolbox runtime-focused commands. use std::ffi::OsString; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use common::CliError; use lexopt::prelude::Value as ArgValue; const MAX_CAPTURE_STREAM_BYTES: usize = 4 * 1024 * 1024; /// Result details from executing a child process probe. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CommandProbe { /// Child process exit code when available. pub exit_code: Option, /// Whether the process was terminated after crossing the timeout. pub timed_out: bool, /// Total wall-clock runtime observed for the process. pub duration: Duration, } /// Captured output from executing a child process probe. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedCommand { /// Child process exit code when available. pub exit_code: Option, /// Whether the process was terminated after crossing the timeout. pub timed_out: bool, /// Total wall-clock runtime observed for the process. pub duration: Duration, /// Bounded stdout bytes collected from the child process. pub stdout: Vec, /// Bounded stderr bytes collected from the child process. pub stderr: Vec, } /// Supported shell launch modes for runtime-oriented commands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShellMode { /// Launch the program directly without a shell wrapper. Raw, /// Launch the command through a `PowerShell` wrapper. Pwsh, /// Launch the command through a cmd.exe wrapper. Cmd, } /// Reads a UTF-8 flag value from lexopt. /// /// # Errors /// /// Returns [`CliError::Usage`] when a value is missing or not UTF-8 text. pub fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { let value = parser .value() .map_err(|error| CliError::usage(error.to_string()))?; os_to_utf8(value, flag) } /// Converts an [`OsString`] to UTF-8 text for user-facing parsing paths. /// /// # Errors /// /// Returns [`CliError::Usage`] when the value is not UTF-8 text. pub fn os_to_utf8(value: OsString, context: &str) -> Result { value.into_string().map_err(|invalid| { CliError::usage(format!( "{context} expects UTF-8 text, got '{}'", invalid.to_string_lossy() )) }) } /// Parses a duration flag using human-friendly units such as `250ms`, `3s`, or `2m`. /// /// # Errors /// /// Returns [`CliError::Usage`] when the duration string cannot be parsed. pub fn parse_duration_flag(flag: &str, value: &str) -> Result { humantime::parse_duration(value) .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } /// Parses an `i32` flag value. /// /// # Errors /// /// Returns [`CliError::Usage`] when the integer string cannot be parsed. pub fn parse_i32_flag(flag: &str, value: &str) -> Result { value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } /// Parses a `usize` flag value. /// /// # Errors /// /// Returns [`CliError::Usage`] when the integer string cannot be parsed. pub fn parse_usize_flag(flag: &str, value: &str) -> Result { value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } /// Parses a shell mode flag value. /// /// # Errors /// /// Returns [`CliError::Usage`] when the shell name is unsupported. pub fn parse_shell_mode(flag: &str, value: &str) -> Result { match value { "raw" => Ok(ShellMode::Raw), "pwsh" => Ok(ShellMode::Pwsh), "cmd" => Ok(ShellMode::Cmd), other => Err(CliError::usage(format!( "invalid {flag} value '{other}'; expected raw, pwsh, or cmd" ))), } } /// Renders a compact command line for text-mode diagnostics. #[must_use] pub fn render_command(command: &[OsString]) -> String { let mut rendered = String::new(); for item in command { if !rendered.is_empty() { rendered.push(' '); } rendered.push_str("e_for_text(&item.to_string_lossy())); } rendered } /// Renders the trailing bytes from a captured output stream as lossy UTF-8 text. #[must_use] pub fn tail_bytes_to_string(bytes: &[u8], limit: usize) -> String { let lookbehind = 64; let start = bytes.len().saturating_sub(limit.saturating_add(lookbehind)); let stripped = strip_ansi_sequences(&String::from_utf8_lossy(&bytes[start..])); trim_to_tail_bytes(&stripped, limit) } fn strip_ansi_sequences(text: &str) -> String { let mut rendered = String::with_capacity(text.len()); let mut characters = text.chars().peekable(); while let Some(character) = characters.next() { if character != '\u{1b}' { rendered.push(character); continue; } match characters.peek().copied() { Some('[') => { characters.next(); for next in characters.by_ref() { if ('@'..='~').contains(&next) { break; } } } Some(']') => { characters.next(); let mut pending_escape = false; for next in characters.by_ref() { if pending_escape { if next == '\\' { break; } pending_escape = next == '\u{1b}'; continue; } if next == '\u{7}' { break; } pending_escape = next == '\u{1b}'; } } _ => {} } } rendered } fn trim_to_tail_bytes(text: &str, limit: usize) -> String { if text.len() <= limit { return text.to_string(); } let mut start = text.len().saturating_sub(limit); while start < text.len() && !text.is_char_boundary(start) { start += 1; } text[start..].to_string() } /// Reads stdin into trimmed line records. /// /// # Errors /// /// Returns [`CliError::Runtime`] when stdin cannot be read. pub fn read_stdin_lines() -> Result, CliError> { io::stdin() .lock() .lines() .map(|line| { line.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}"))) }) .collect() } /// Executes a command directly and optionally enforces a wall-clock timeout. /// /// # Errors /// /// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn, /// polling, or wait failures. pub fn run_command( command: &[OsString], cwd: Option<&Path>, timeout: Option, ) -> Result { let captured = run_command_capture_with_shell(command, ShellMode::Raw, cwd, timeout)?; Ok(CommandProbe { exit_code: captured.exit_code, timed_out: captured.timed_out, duration: captured.duration, }) } /// Executes a command with an explicit shell mode and captures stdout and stderr. /// /// # Errors /// /// Returns [`CliError::Usage`] when no command is provided and [`CliError::Runtime`] for spawn, /// wrapper creation, polling, or wait failures. pub fn run_command_capture_with_shell( command: &[OsString], shell: ShellMode, cwd: Option<&Path>, timeout: Option, ) -> Result { let prepared = prepare_command(command, shell)?; let cleanup = prepared.cleanup.clone(); let result = run_prepared_command(&prepared, cwd, timeout); cleanup_paths(&cleanup); result } /// Consumes positional values from lexopt after an initial command token is seen. /// /// # Errors /// /// Returns [`CliError::Usage`] when non-positional tokens appear after command collection starts. pub fn collect_command_values( parser: &mut lexopt::Parser, first: OsString, ) -> Result, CliError> { let mut command = vec![first]; while let Some(argument) = parser .next() .map_err(|error| CliError::usage(error.to_string()))? { match argument { ArgValue(value) => command.push(value), _ => { return Err(CliError::usage( "command arguments must appear after -- and cannot include extra flags", )); } } } Ok(command) } #[derive(Debug, Clone)] struct PreparedCommand { program: OsString, args: Vec, cleanup: Vec, } fn run_prepared_command( prepared: &PreparedCommand, cwd: Option<&Path>, timeout: Option, ) -> Result { let mut builder = Command::new(&prepared.program); builder .args(&prepared.args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(path) = cwd { builder.current_dir(path); } let mut child = builder .spawn() .map_err(|error| CliError::runtime(format!("failed to launch command: {error}")))?; let stdout = child .stdout .take() .ok_or_else(|| CliError::runtime("failed to capture child stdout"))?; let stderr = child .stderr .take() .ok_or_else(|| CliError::runtime("failed to capture child stderr"))?; let stdout_handle = thread::spawn(move || read_stream(stdout, "stdout")); let stderr_handle = thread::spawn(move || read_stream(stderr, "stderr")); let started = Instant::now(); let (status, timed_out) = if let Some(limit) = timeout { loop { if let Some(status) = child.try_wait().map_err(|error| { CliError::runtime(format!("failed to poll child process: {error}")) })? { break (status, false); } if started.elapsed() >= limit { let _ = child.kill(); let status = child.wait().map_err(|error| { CliError::runtime(format!("failed to wait for timed-out process: {error}")) })?; break (status, true); } thread::sleep(Duration::from_millis(10)); } } else { ( child.wait().map_err(|error| { CliError::runtime(format!("failed to wait for command: {error}")) })?, false, ) }; let stdout = join_reader(stdout_handle, "stdout")?; let stderr = join_reader(stderr_handle, "stderr")?; Ok(CapturedCommand { exit_code: status.code(), timed_out, duration: started.elapsed(), stdout, stderr, }) } fn prepare_command(command: &[OsString], shell: ShellMode) -> Result { let Some(program) = command.first() else { return Err(CliError::usage("expected a command after --")); }; match shell { ShellMode::Raw => Ok(PreparedCommand { program: program.clone(), args: command[1..].to_vec(), cleanup: Vec::new(), }), ShellMode::Cmd => { let wrapper = unique_temp_path("mercury-shell", "cmd"); let body = "@echo off\r\ncall %*\r\nset MERCURY_EXIT=%ERRORLEVEL%\r\nexit /b %MERCURY_EXIT%\r\n"; write_shell_wrapper_file(&wrapper, body)?; let mut args = vec![ OsString::from("/d"), OsString::from("/s"), OsString::from("/c"), wrapper.as_os_str().to_os_string(), ]; args.extend_from_slice(command); Ok(PreparedCommand { program: OsString::from("cmd"), args, cleanup: vec![wrapper], }) } ShellMode::Pwsh => { let wrapper = unique_temp_path("mercury-shell", "ps1"); let body = "\ $command = @($args)\r\n\ if ($command.Length -eq 0) { exit 0 }\r\n\ $program = [string]$command[0]\r\n\ $childArgs = @()\r\n\ if ($command.Length -gt 1) {\r\n\ foreach ($item in $command[1..($command.Length - 1)]) {\r\n\ $childArgs += [string]$item\r\n\ }\r\n\ }\r\n\ try {\r\n\ & $program @childArgs\r\n\ $success = $?\r\n\ if ($null -ne $LASTEXITCODE) {\r\n\ exit $LASTEXITCODE\r\n\ }\r\n\ if ($success) {\r\n\ exit 0\r\n\ }\r\n\ exit 1\r\n\ } catch {\r\n\ [Console]::Error.WriteLine($_)\r\n\ if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {\r\n\ exit $LASTEXITCODE\r\n\ }\r\n\ exit 1\r\n\ }\r\n"; write_shell_wrapper_file(&wrapper, body)?; let mut args = vec![ OsString::from("-NoProfile"), OsString::from("-File"), wrapper.as_os_str().to_os_string(), ]; args.extend_from_slice(command); Ok(PreparedCommand { program: OsString::from("pwsh"), args, cleanup: vec![wrapper], }) } } } fn cleanup_paths(paths: &[PathBuf]) { for path in paths { let _ = fs::remove_file(path); } } fn write_shell_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> { let mut file = OpenOptions::new() .create_new(true) .write(true) .open(path) .map_err(|error| { CliError::runtime(format!( "refusing to replace existing shell wrapper {}: {error}", path.display() )) })?; file.write_all(body.as_bytes()).map_err(|error| { CliError::runtime(format!("failed to write {}: {error}", path.display())) })?; file.flush().map_err(|error| { CliError::runtime(format!("failed to flush {}: {error}", path.display())) })?; Ok(()) } fn unique_temp_path(prefix: &str, extension: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |value| value.as_nanos()); std::env::temp_dir().join(format!("{prefix}-{unique}.{extension}")) } fn join_reader( handle: thread::JoinHandle, io::Error>>, stream: &str, ) -> Result, CliError> { handle .join() .map_err(|_| CliError::runtime(format!("{stream} capture thread panicked")))? .map_err(|error| CliError::runtime(format!("failed to read child {stream}: {error}"))) } fn read_stream(mut reader: R, stream: &str) -> Result, io::Error> where R: Read, { read_stream_with_limit(&mut reader, stream, MAX_CAPTURE_STREAM_BYTES) } fn read_stream_with_limit( reader: &mut R, stream: &str, max_capture_stream_bytes: usize, ) -> Result, io::Error> where R: Read, { let mut buffer = Vec::new(); let mut chunk = [0_u8; 8192]; let mut truncated = false; loop { let read = reader.read(&mut chunk)?; if read == 0 { break; } let remaining = max_capture_stream_bytes.saturating_sub(buffer.len()); if remaining > 0 { let keep = remaining.min(read); buffer.extend_from_slice(&chunk[..keep]); } if read > remaining { truncated = true; } } if truncated { let marker = format!( "\n[mercury: child {stream} truncated after {max_capture_stream_bytes} bytes]\n" ); buffer.extend_from_slice(marker.as_bytes()); } Ok(buffer) } fn quote_for_text(value: &str) -> String { if value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') { format!("\"{}\"", value.replace('"', "\\\"")) } else { value.to_string() } } #[cfg(test)] mod tests { use super::*; fn success_command() -> Vec { #[cfg(windows)] { vec![ OsString::from("cmd"), OsString::from("/d"), OsString::from("/s"), OsString::from("/c"), OsString::from("exit 0"), ] } #[cfg(not(windows))] { vec![ OsString::from("sh"), OsString::from("-c"), OsString::from("exit 0"), ] } } fn slow_command() -> Vec { #[cfg(windows)] { vec![ OsString::from("pwsh"), OsString::from("-NoProfile"), OsString::from("-Command"), OsString::from("Start-Sleep -Milliseconds 300"), ] } #[cfg(not(windows))] { vec![ OsString::from("sh"), OsString::from("-c"), OsString::from("sleep 1"), ] } } #[test] fn parse_helpers_report_usage_errors() { assert!(matches!( parse_duration_flag("--timeout", "nope"), Err(CliError::Usage(message)) if message.contains("invalid --timeout value") )); assert!(matches!( parse_i32_flag("--expect-exit", "bad"), Err(CliError::Usage(message)) if message.contains("invalid --expect-exit value") )); assert!(matches!( parse_usize_flag("--tail-bytes", "bad"), Err(CliError::Usage(message)) if message.contains("invalid --tail-bytes value") )); assert!(matches!( parse_shell_mode("--shell", "bad"), Err(CliError::Usage(message)) if message.contains("invalid --shell value") )); } #[test] fn render_command_quotes_whitespace_and_empty_values() { assert_eq!( render_command(&[ OsString::from("pwsh"), OsString::from("-Command"), OsString::from("Write-Output hi"), OsString::from(""), ]), "pwsh -Command \"Write-Output hi\" \"\"" ); } #[test] fn tail_bytes_take_only_the_suffix() { assert_eq!(tail_bytes_to_string(b"abcdef", 3), "def"); assert_eq!(tail_bytes_to_string(b"abc", 99), "abc"); } #[test] fn tail_bytes_strip_common_ansi_sequences() { assert_eq!(tail_bytes_to_string(b"\x1b[31;1mboom\x1b[0m", 32), "boom"); assert_eq!(tail_bytes_to_string(b"\x1b]0;title\x07done", 32), "done"); let tail = tail_bytes_to_string(b"prefix\x1b[31;1mboom\x1b[0m", 6); assert!(!tail.contains('\u{1b}')); assert!(tail.ends_with("boom")); } #[test] fn run_command_reports_success_and_timeout() { let success = run_command(&success_command(), None, Some(Duration::from_secs(2))) .expect("successful probe"); assert_eq!(success.exit_code, Some(0)); assert!(!success.timed_out); let timeout = run_command(&slow_command(), None, Some(Duration::from_millis(50))) .expect("timeout probe"); assert!(timeout.timed_out); } #[test] fn capture_command_collects_output() { #[cfg(windows)] let command = vec![ OsString::from("pwsh"), OsString::from("-NoProfile"), OsString::from("-Command"), OsString::from("Write-Output 'out'; Write-Error 'err'"), ]; #[cfg(not(windows))] let command = vec![ OsString::from("sh"), OsString::from("-c"), OsString::from("printf out; printf err >&2"), ]; let captured = run_command_capture_with_shell( &command, ShellMode::Raw, None, Some(Duration::from_secs(2)), ) .expect("captured"); assert!(!captured.stdout.is_empty()); assert!(!captured.stderr.is_empty()); } #[cfg(windows)] #[test] fn pwsh_wrapper_runs_script_files_as_argv() { let script = unique_temp_path("runtimekit-test", "ps1"); fs::write(&script, "Write-Output 'done'\n").expect("script"); let captured = run_command_capture_with_shell( &[script.as_os_str().to_os_string()], ShellMode::Pwsh, None, Some(Duration::from_secs(2)), ) .expect("captured pwsh script file"); let _ = fs::remove_file(script); assert_eq!(captured.exit_code, Some(0)); assert_eq!(String::from_utf8_lossy(&captured.stdout), "done\r\n"); assert!(captured.stderr.is_empty()); } #[test] fn run_command_requires_non_empty_command() { let error = run_command(&[], None, None).expect_err("missing command should fail"); assert!(matches!( error, CliError::Usage(message) if message.contains("expected a command") )); } #[test] fn collect_command_values_accepts_positionals_and_rejects_flags() { let mut parser = lexopt::Parser::from_iter([ OsString::from("runtimekit"), OsString::from("tool"), OsString::from("alpha"), OsString::from("two words"), ]); let first = parser.next().expect("parser item").expect("first value"); let collected = match first { ArgValue(value) => collect_command_values(&mut parser, value).expect("values"), _ => panic!("expected first positional value"), }; assert_eq!( collected, vec![ OsString::from("tool"), OsString::from("alpha"), OsString::from("two words") ] ); let mut parser = lexopt::Parser::from_iter([ OsString::from("runtimekit"), OsString::from("tool"), OsString::from("--bad"), ]); let first = parser.next().expect("parser item").expect("first value"); let error = match first { ArgValue(value) => collect_command_values(&mut parser, value).expect_err("flag error"), _ => panic!("expected first positional value"), }; assert!(matches!( error, CliError::Usage(message) if message.contains("command arguments must appear after --") )); } #[test] fn prepare_command_builds_shell_specific_wrappers() { let command = vec![OsString::from("echo"), OsString::from("hello world")]; let raw = prepare_command(&command, ShellMode::Raw).expect("raw"); assert_eq!(raw.program, OsString::from("echo")); assert_eq!(raw.args, vec![OsString::from("hello world")]); assert!(raw.cleanup.is_empty()); #[cfg(windows)] { let cmd = prepare_command(&command, ShellMode::Cmd).expect("cmd"); assert_eq!(cmd.program, OsString::from("cmd")); assert!(cmd.args.len() >= 5); assert_eq!(cmd.cleanup.len(), 1); assert_eq!( cmd.cleanup[0].extension().and_then(std::ffi::OsStr::to_str), Some("cmd") ); assert!(fs::metadata(&cmd.cleanup[0]).is_ok()); cleanup_paths(&cmd.cleanup); assert!(fs::metadata(&cmd.cleanup[0]).is_err()); let pwsh = prepare_command(&command, ShellMode::Pwsh).expect("pwsh"); assert_eq!(pwsh.program, OsString::from("pwsh")); assert!(pwsh.args.len() >= 4); assert_eq!(pwsh.cleanup.len(), 1); assert_eq!( pwsh.cleanup[0] .extension() .and_then(std::ffi::OsStr::to_str), Some("ps1") ); assert!(fs::metadata(&pwsh.cleanup[0]).is_ok()); cleanup_paths(&pwsh.cleanup); assert!(fs::metadata(&pwsh.cleanup[0]).is_err()); } } #[test] fn shell_wrapper_writer_refuses_preexisting_paths() { let path = unique_temp_path("runtimekit-existing-wrapper", "cmd"); fs::write(&path, "original").expect("preexisting wrapper"); let error = write_shell_wrapper_file(&path, "replacement").expect_err("preexisting path refused"); assert!(matches!( error, CliError::Runtime(message) if message.contains("refusing to replace existing shell wrapper") )); assert_eq!( fs::read_to_string(&path).expect("preserved content"), "original" ); let _ = fs::remove_file(path); } #[test] fn stream_helpers_cover_success_and_failure_paths() { let stdout = join_reader( thread::spawn(|| Ok::, io::Error>(b"hello".to_vec())), "stdout", ) .expect("stdout"); assert_eq!(stdout, b"hello"); let read_error = join_reader( thread::spawn(|| Err::, io::Error>(io::Error::other("boom"))), "stderr", ) .expect_err("stderr read error"); assert!(matches!( read_error, CliError::Runtime(message) if message.contains("failed to read child stderr") )); let panic_error = join_reader( thread::spawn(|| -> Result, io::Error> { panic!("reader panic"); }), "stdout", ) .expect_err("panic error"); assert!(matches!( panic_error, CliError::Runtime(message) if message.contains("stdout capture thread panicked") )); assert_eq!( read_stream(io::Cursor::new(b"abc".to_vec()), "stdout").expect("cursor"), b"abc" ); assert_eq!(quote_for_text("two words"), "\"two words\""); assert_eq!(quote_for_text("plain"), "plain"); } #[test] fn read_stream_caps_memory_and_drains_remaining_bytes() { let max_capture_stream_bytes = 64; let input = vec![b'x'; max_capture_stream_bytes + 128]; let mut reader = io::Cursor::new(input); let captured = read_stream_with_limit(&mut reader, "stdout", max_capture_stream_bytes) .expect("capture"); assert!(captured.len() < max_capture_stream_bytes + 128); assert!(captured.starts_with(&[b'x'; 64])); assert!(String::from_utf8_lossy(&captured).contains("child stdout truncated")); } }