chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "runtimekit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Shared runtime helpers for Mercury Toolbox runtime commands."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
humantime.workspace = true
|
||||
lexopt.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
loom.workspace = true
|
||||
@@ -0,0 +1,827 @@
|
||||
//! 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<i32>,
|
||||
/// 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<i32>,
|
||||
/// 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<u8>,
|
||||
/// Bounded stderr bytes collected from the child process.
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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<String, CliError> {
|
||||
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<String, CliError> {
|
||||
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<Duration, CliError> {
|
||||
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<i32, CliError> {
|
||||
value
|
||||
.parse::<i32>()
|
||||
.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<usize, CliError> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.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<ShellMode, CliError> {
|
||||
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<Vec<String>, 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<Duration>,
|
||||
) -> Result<CommandProbe, CliError> {
|
||||
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<Duration>,
|
||||
) -> Result<CapturedCommand, CliError> {
|
||||
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<Vec<OsString>, 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<OsString>,
|
||||
cleanup: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn run_prepared_command(
|
||||
prepared: &PreparedCommand,
|
||||
cwd: Option<&Path>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<CapturedCommand, CliError> {
|
||||
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<PreparedCommand, CliError> {
|
||||
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<Result<Vec<u8>, io::Error>>,
|
||||
stream: &str,
|
||||
) -> Result<Vec<u8>, 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<R>(mut reader: R, stream: &str) -> Result<Vec<u8>, io::Error>
|
||||
where
|
||||
R: Read,
|
||||
{
|
||||
read_stream_with_limit(&mut reader, stream, MAX_CAPTURE_STREAM_BYTES)
|
||||
}
|
||||
|
||||
fn read_stream_with_limit<R>(
|
||||
reader: &mut R,
|
||||
stream: &str,
|
||||
max_capture_stream_bytes: usize,
|
||||
) -> Result<Vec<u8>, 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<OsString> {
|
||||
#[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<OsString> {
|
||||
#[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::<Vec<u8>, io::Error>(b"hello".to_vec())),
|
||||
"stdout",
|
||||
)
|
||||
.expect("stdout");
|
||||
assert_eq!(stdout, b"hello");
|
||||
|
||||
let read_error = join_reader(
|
||||
thread::spawn(|| Err::<Vec<u8>, 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<Vec<u8>, 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Loom model for the runtime command capture join pattern.
|
||||
|
||||
use loom::sync::{Arc, Mutex};
|
||||
use loom::thread;
|
||||
|
||||
fn join_capture(
|
||||
handle: loom::thread::JoinHandle<Result<Vec<u8>, &'static str>>,
|
||||
stream: &'static str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| format!("{stream} capture thread panicked"))?
|
||||
.map_err(|error| format!("failed to read child {stream}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_threads_publish_before_join_returns() {
|
||||
loom::model(|| {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let stdout_events = Arc::clone(&events);
|
||||
let stderr_events = Arc::clone(&events);
|
||||
|
||||
let stdout = thread::spawn(move || {
|
||||
let mut guard = stdout_events.lock().expect("stdout lock");
|
||||
guard.push("stdout");
|
||||
});
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut guard = stderr_events.lock().expect("stderr lock");
|
||||
guard.push("stderr");
|
||||
});
|
||||
|
||||
stdout.join().expect("stdout join");
|
||||
stderr.join().expect("stderr join");
|
||||
|
||||
{
|
||||
let guard = events.lock().expect("events lock");
|
||||
assert_eq!(guard.len(), 2);
|
||||
assert!(guard.contains(&"stdout"));
|
||||
assert!(guard.contains(&"stderr"));
|
||||
drop(guard);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_join_models_success_and_read_error_paths() {
|
||||
loom::model(|| {
|
||||
let stdout = thread::spawn(|| Ok(vec![b'o', b'k']));
|
||||
let stderr = thread::spawn(|| Err("broken pipe"));
|
||||
|
||||
assert_eq!(join_capture(stdout, "stdout").expect("stdout"), b"ok");
|
||||
assert_eq!(
|
||||
join_capture(stderr, "stderr").expect_err("stderr"),
|
||||
"failed to read child stderr: broken pipe"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user