chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,752 @@
|
||||
//! The `runprobe` command executes one command and reports runtime outcomes.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as IoWrite;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
|
||||
print_json, print_quick_help_error, print_structured, print_text,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use runtimekit::{
|
||||
ShellMode, collect_command_values, parse_duration_flag, parse_shell_mode, parse_usize_flag,
|
||||
parser_value_string, render_command, run_command_capture_with_shell, tail_bytes_to_string,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
const HELP: &str = "\
|
||||
Probe one command execution with stable JSON and bounded output tails.
|
||||
|
||||
Capture success is separate from child success: when `runprobe` launches, waits, and captures
|
||||
output successfully, it exits 0 and reports child state in `ok`, `exit_code`, and `timed_out`.
|
||||
Use `--fail-on-child-error` when calling scripts want `runprobe` itself to exit nonzero on child
|
||||
failure or timeout.
|
||||
|
||||
Usage:
|
||||
runprobe [OPTIONS] -- <COMMAND...>
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--fail-on-child-error Exit nonzero when the child fails or times out
|
||||
--shell <MODE> Launch mode: raw, pwsh, cmd (default: raw)
|
||||
--timeout <DURATION> Kill the command if it exceeds this duration
|
||||
--cwd <PATH> Working directory used when spawning the command
|
||||
--tail-bytes <COUNT> Bytes of stdout/stderr to keep in the tail fields (default: 4096)
|
||||
--log-dir <PATH> Write a full stdout/stderr log file into this directory
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
runprobe --shell raw -- cmd /d /s /c \"exit 0\"
|
||||
runprobe --shell pwsh -- Write-Output done
|
||||
runprobe --shell pwsh -- '& { Write-Output done }'
|
||||
runprobe --json --shell pwsh -- '& { Write-Error boom; exit 9 }' | ConvertFrom-Json
|
||||
runprobe --fail-on-child-error --shell pwsh -- '& { Write-Error boom; exit 9 }'
|
||||
runprobe --json --shell cmd -- echo hello | ConvertFrom-Json
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
fail_on_child_error: bool,
|
||||
shell: ShellMode,
|
||||
timeout: Option<Duration>,
|
||||
cwd: Option<PathBuf>,
|
||||
tail_bytes: usize,
|
||||
log_dir: Option<PathBuf>,
|
||||
command: Vec<OsString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct ProbeReport {
|
||||
exit_code: Option<i32>,
|
||||
duration_ms: u128,
|
||||
timed_out: bool,
|
||||
termination_reason: String,
|
||||
stdout_tail: String,
|
||||
stderr_tail: String,
|
||||
log_path: Option<String>,
|
||||
command: String,
|
||||
argv: Vec<String>,
|
||||
cwd: Option<String>,
|
||||
ok: bool,
|
||||
next_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{HELP}");
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("runprobe {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: None,
|
||||
cwd: None,
|
||||
tail_bytes: 4096,
|
||||
log_dir: None,
|
||||
command: Vec::new(),
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("fail-on-child-error") => cli.fail_on_child_error = true,
|
||||
Long("color") => {
|
||||
cli.common.color =
|
||||
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
||||
}
|
||||
Long("shell") => {
|
||||
cli.shell =
|
||||
parse_shell_mode("--shell", &parser_value_string(&mut parser, "--shell")?)?;
|
||||
}
|
||||
Long("timeout") => {
|
||||
cli.timeout = Some(parse_duration_flag(
|
||||
"--timeout",
|
||||
&parser_value_string(&mut parser, "--timeout")?,
|
||||
)?);
|
||||
}
|
||||
Long("cwd") => {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
cli.cwd = Some(PathBuf::from(value));
|
||||
}
|
||||
Long("tail-bytes") => {
|
||||
cli.tail_bytes = parse_usize_flag(
|
||||
"--tail-bytes",
|
||||
&parser_value_string(&mut parser, "--tail-bytes")?,
|
||||
)?;
|
||||
}
|
||||
Long("log-dir") => {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
cli.log_dir = Some(PathBuf::from(value));
|
||||
}
|
||||
ArgValue(value) => {
|
||||
cli.command = collect_command_values(&mut parser, value)?;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cli.command.is_empty() {
|
||||
return Err(CliError::usage("runprobe requires a command after --"));
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let captured =
|
||||
run_command_capture_with_shell(&cli.command, cli.shell, cli.cwd.as_deref(), cli.timeout)?;
|
||||
let stdout_tail = tail_bytes_to_string(&captured.stdout, cli.tail_bytes);
|
||||
let stderr_tail = tail_bytes_to_string(&captured.stderr, cli.tail_bytes);
|
||||
let log_path = cli
|
||||
.log_dir
|
||||
.as_deref()
|
||||
.map(|path| write_log_file(path, cli, &captured))
|
||||
.transpose()?;
|
||||
let report = ProbeReport {
|
||||
exit_code: captured.exit_code,
|
||||
duration_ms: captured.duration.as_millis(),
|
||||
timed_out: captured.timed_out,
|
||||
termination_reason: termination_reason(captured.timed_out, captured.exit_code),
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
log_path,
|
||||
command: render_command(&cli.command),
|
||||
argv: cli
|
||||
.command
|
||||
.iter()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
.collect(),
|
||||
cwd: cli.cwd.as_ref().map(|path| path.display().to_string()),
|
||||
ok: !captured.timed_out && captured.exit_code == Some(0),
|
||||
next_hint: child_failure_hint(!captured.timed_out && captured.exit_code == Some(0), cli),
|
||||
};
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&report)?,
|
||||
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
||||
RenderMode::Text => print_text(render_report_text(&report))?,
|
||||
}
|
||||
if cli.fail_on_child_error && !report.ok {
|
||||
Ok(ExitCode::RuntimeError)
|
||||
} else {
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_log_file(
|
||||
log_dir: &Path,
|
||||
cli: &Cli,
|
||||
captured: &runtimekit::CapturedCommand,
|
||||
) -> Result<String, CliError> {
|
||||
fs::create_dir_all(log_dir).map_err(|error| {
|
||||
CliError::runtime(format!("failed to create {}: {error}", log_dir.display()))
|
||||
})?;
|
||||
let log_path = log_dir.join(format!("runprobe-{}.log", unique_suffix()));
|
||||
let mut body = String::new();
|
||||
writeln!(body, "command={}", render_command(&cli.command))
|
||||
.expect("writing to a String cannot fail");
|
||||
writeln!(
|
||||
body,
|
||||
"cwd={}",
|
||||
cli.cwd
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |path| path.display().to_string())
|
||||
)
|
||||
.expect("writing to a String cannot fail");
|
||||
writeln!(
|
||||
body,
|
||||
"exit_code={}",
|
||||
format_optional_i32(captured.exit_code)
|
||||
)
|
||||
.expect("writing to a String cannot fail");
|
||||
writeln!(body, "timed_out={}", captured.timed_out).expect("writing to a String cannot fail");
|
||||
writeln!(body, "duration_ms={}", captured.duration.as_millis())
|
||||
.expect("writing to a String cannot fail");
|
||||
body.push('\n');
|
||||
body.push_str("[stdout]\n");
|
||||
body.push_str(&String::from_utf8_lossy(&captured.stdout));
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
body.push_str("\n[stderr]\n");
|
||||
body.push_str(&String::from_utf8_lossy(&captured.stderr));
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
write_log_file_exclusive(&log_path, &body)?;
|
||||
Ok(log_path.display().to_string())
|
||||
}
|
||||
|
||||
fn write_log_file_exclusive(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 runprobe log {}: {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())))
|
||||
}
|
||||
|
||||
fn unique_suffix() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |value| value.as_nanos());
|
||||
format!("{}-{nanos}", std::process::id())
|
||||
}
|
||||
|
||||
fn render_report_text(report: &ProbeReport) -> String {
|
||||
let mut text = format!(
|
||||
"{} exit_code={} duration_ms={} timed_out={} command={}",
|
||||
status_word(report),
|
||||
format_optional_i32(report.exit_code),
|
||||
report.duration_ms,
|
||||
report.timed_out,
|
||||
report.command
|
||||
);
|
||||
write!(text, " reason={}", report.termination_reason).expect("writing to a String cannot fail");
|
||||
if let Some(cwd) = &report.cwd {
|
||||
write!(text, " cwd={cwd}").expect("writing to a String cannot fail");
|
||||
}
|
||||
if let Some(log_path) = &report.log_path {
|
||||
write!(text, " log_path={log_path}").expect("writing to a String cannot fail");
|
||||
}
|
||||
if report.ok {
|
||||
return text;
|
||||
}
|
||||
if !report.stderr_tail.is_empty() {
|
||||
text.push_str("\nstderr_tail:\n");
|
||||
text.push_str(&report.stderr_tail);
|
||||
}
|
||||
if !report.stdout_tail.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str("stdout_tail:\n");
|
||||
text.push_str(&report.stdout_tail);
|
||||
}
|
||||
if let Some(next_hint) = &report.next_hint {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str("next_hint:\n");
|
||||
text.push_str(next_hint);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
const fn status_word(report: &ProbeReport) -> &'static str {
|
||||
if report.ok {
|
||||
"ok"
|
||||
} else if report.timed_out {
|
||||
"timeout"
|
||||
} else {
|
||||
"fail"
|
||||
}
|
||||
}
|
||||
|
||||
fn format_optional_i32(value: Option<i32>) -> String {
|
||||
value.map_or_else(|| "none".to_string(), |item| item.to_string())
|
||||
}
|
||||
|
||||
fn termination_reason(timed_out: bool, exit_code: Option<i32>) -> String {
|
||||
if timed_out {
|
||||
"timeout".to_string()
|
||||
} else if exit_code.is_some() {
|
||||
"exit".to_string()
|
||||
} else {
|
||||
"terminated".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn child_failure_hint(ok: bool, cli: &Cli) -> Option<String> {
|
||||
if ok || cli.fail_on_child_error {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
"rerun with --fail-on-child-error if the caller should treat child failure or timeout as a nonzero tool exit"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use common::CommonArgs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn command(items: &[&str]) -> Vec<OsString> {
|
||||
items.iter().map(OsString::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_accepts_v3_flags() {
|
||||
let (_, cli) = parse_cli_from([
|
||||
"runprobe",
|
||||
"--json",
|
||||
"--shell",
|
||||
"pwsh",
|
||||
"--timeout",
|
||||
"250ms",
|
||||
"--cwd",
|
||||
".",
|
||||
"--tail-bytes",
|
||||
"99",
|
||||
"--log-dir",
|
||||
"logs",
|
||||
"--",
|
||||
"Write-Output",
|
||||
"done",
|
||||
])
|
||||
.expect("cli");
|
||||
assert!(cli.common.json);
|
||||
assert_eq!(cli.shell, ShellMode::Pwsh);
|
||||
assert_eq!(cli.timeout, Some(Duration::from_millis(250)));
|
||||
assert_eq!(cli.tail_bytes, 99);
|
||||
assert!(cli.cwd.is_some());
|
||||
assert!(cli.log_dir.is_some());
|
||||
assert_eq!(cli.command.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_rejects_missing_command() {
|
||||
let error = parse_cli_from(["runprobe", "--timeout", "1s"]).expect_err("missing command");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message) if message.contains("requires a command")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_text_stays_compact_on_success() {
|
||||
let report = ProbeReport {
|
||||
exit_code: Some(0),
|
||||
duration_ms: 12,
|
||||
timed_out: false,
|
||||
termination_reason: "exit".into(),
|
||||
stdout_tail: "done\n".into(),
|
||||
stderr_tail: String::new(),
|
||||
log_path: None,
|
||||
command: "Write-Output done".into(),
|
||||
argv: vec!["Write-Output".into(), "done".into()],
|
||||
cwd: Some(".".into()),
|
||||
ok: true,
|
||||
next_hint: None,
|
||||
};
|
||||
let text = render_report_text(&report);
|
||||
assert!(text.contains("ok exit_code=0"));
|
||||
assert!(text.contains("duration_ms=12"));
|
||||
assert!(!text.contains("stdout_tail:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_text_includes_tails_on_failure() {
|
||||
let report = ProbeReport {
|
||||
exit_code: Some(9),
|
||||
duration_ms: 12,
|
||||
timed_out: false,
|
||||
termination_reason: "exit".into(),
|
||||
stdout_tail: "out".into(),
|
||||
stderr_tail: "boom".into(),
|
||||
log_path: Some("log.txt".into()),
|
||||
command: render_command(&command(&["Write-Output", "done"])),
|
||||
argv: vec!["Write-Output".into(), "done".into()],
|
||||
cwd: None,
|
||||
ok: false,
|
||||
next_hint: Some("rerun with --fail-on-child-error".into()),
|
||||
};
|
||||
let text = render_report_text(&report);
|
||||
assert!(text.contains("fail exit_code=9"));
|
||||
assert!(text.contains("stderr_tail:"));
|
||||
assert!(text.contains("stdout_tail:"));
|
||||
assert!(text.contains("log_path=log.txt"));
|
||||
assert!(text.contains("next_hint:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_log_file_records_metadata_and_optional_exit_codes() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let cwd = temp.path().join("work");
|
||||
fs::create_dir_all(&cwd).expect("cwd");
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
cwd: Some(cwd.clone()),
|
||||
tail_bytes: 32,
|
||||
log_dir: Some(temp.path().join("logs")),
|
||||
command: command(&["tool.exe", "--flag"]),
|
||||
};
|
||||
let captured = runtimekit::CapturedCommand {
|
||||
exit_code: None,
|
||||
timed_out: true,
|
||||
duration: Duration::from_millis(75),
|
||||
stdout: b"out".to_vec(),
|
||||
stderr: b"boom".to_vec(),
|
||||
};
|
||||
|
||||
let log_path = write_log_file(cli.log_dir.as_deref().expect("log dir"), &cli, &captured)
|
||||
.expect("log path");
|
||||
let body = fs::read_to_string(&log_path).expect("log body");
|
||||
|
||||
assert!(body.contains("command=tool.exe --flag"));
|
||||
assert!(body.contains(&format!("cwd={}", cwd.display())));
|
||||
assert!(body.contains("exit_code=none"));
|
||||
assert!(body.contains("timed_out=true"));
|
||||
assert!(body.contains("[stdout]\nout"));
|
||||
assert!(body.contains("[stderr]\nboom"));
|
||||
assert_eq!(format_optional_i32(Some(-9)), "-9");
|
||||
assert_eq!(format_optional_i32(None), "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_file_writer_refuses_preexisting_paths() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let path = temp.path().join("runprobe-existing.log");
|
||||
fs::write(&path, "original").expect("preexisting log");
|
||||
|
||||
let error =
|
||||
write_log_file_exclusive(&path, "replacement").expect_err("preexisting path refused");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("refusing to replace existing runprobe log")
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("preserved content"),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_word_and_report_text_cover_timeout_and_optional_fields() {
|
||||
let timeout_report = ProbeReport {
|
||||
exit_code: None,
|
||||
duration_ms: 50,
|
||||
timed_out: true,
|
||||
termination_reason: "timeout".into(),
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: "still running".into(),
|
||||
log_path: Some("logs\\probe.log".into()),
|
||||
command: "pwsh -NoProfile".into(),
|
||||
argv: vec!["pwsh".into(), "-NoProfile".into()],
|
||||
cwd: Some("work".into()),
|
||||
ok: false,
|
||||
next_hint: Some("rerun with --fail-on-child-error".into()),
|
||||
};
|
||||
assert_eq!(status_word(&timeout_report), "timeout");
|
||||
let timeout_text = render_report_text(&timeout_report);
|
||||
assert!(timeout_text.contains("timeout exit_code=none"));
|
||||
assert!(timeout_text.contains("reason=timeout"));
|
||||
assert!(timeout_text.contains("cwd=work"));
|
||||
assert!(timeout_text.contains("log_path=logs\\probe.log"));
|
||||
assert!(timeout_text.contains("stderr_tail:"));
|
||||
assert!(!timeout_text.contains("stdout_tail:"));
|
||||
|
||||
let ok_report = ProbeReport {
|
||||
ok: true,
|
||||
timed_out: false,
|
||||
exit_code: Some(0),
|
||||
duration_ms: 1,
|
||||
termination_reason: "exit".into(),
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: String::new(),
|
||||
log_path: None,
|
||||
command: "tool".into(),
|
||||
argv: vec!["tool".into()],
|
||||
cwd: None,
|
||||
next_hint: None,
|
||||
};
|
||||
assert_eq!(status_word(&ok_report), "ok");
|
||||
|
||||
let fail_report = ProbeReport {
|
||||
ok: false,
|
||||
timed_out: false,
|
||||
exit_code: Some(9),
|
||||
duration_ms: 1,
|
||||
termination_reason: "exit".into(),
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: String::new(),
|
||||
log_path: None,
|
||||
command: "tool".into(),
|
||||
argv: vec!["tool".into()],
|
||||
cwd: None,
|
||||
next_hint: Some("rerun with --fail-on-child-error".into()),
|
||||
};
|
||||
assert_eq!(status_word(&fail_report), "fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_failure_hint_is_only_emitted_when_tool_exit_stays_zero() {
|
||||
let ok_cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: None,
|
||||
cwd: None,
|
||||
tail_bytes: 64,
|
||||
log_dir: None,
|
||||
command: command(&["tool"]),
|
||||
};
|
||||
assert!(child_failure_hint(true, &ok_cli).is_none());
|
||||
|
||||
let mut strict_cli = ok_cli.clone();
|
||||
strict_cli.fail_on_child_error = true;
|
||||
assert!(child_failure_hint(false, &strict_cli).is_none());
|
||||
|
||||
assert!(
|
||||
child_failure_hint(false, &ok_cli)
|
||||
.expect("hint")
|
||||
.contains("--fail-on-child-error")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_log_file_surfaces_log_dir_creation_failures() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let not_a_dir = temp.path().join("occupied");
|
||||
fs::write(¬_a_dir, "file").expect("occupied file");
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: None,
|
||||
cwd: None,
|
||||
tail_bytes: 16,
|
||||
log_dir: Some(not_a_dir.clone()),
|
||||
command: command(&["tool.exe"]),
|
||||
};
|
||||
let captured = runtimekit::CapturedCommand {
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
duration: Duration::from_millis(1),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
write_log_file(¬_a_dir, &cli, &captured),
|
||||
Err(CliError::Runtime(message)) if message.contains("failed to create")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn run_maps_success_failure_and_timeout_exit_codes() {
|
||||
let success_cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: Some(Duration::from_secs(1)),
|
||||
cwd: None,
|
||||
tail_bytes: 32,
|
||||
log_dir: None,
|
||||
command: command(&["pwsh", "-NoProfile", "-Command", "Write-Output ok; exit 0"]),
|
||||
};
|
||||
assert_eq!(run(&success_cli).expect("success"), ExitCode::Success);
|
||||
|
||||
let failure_cli = Cli {
|
||||
common: CommonArgs {
|
||||
json: true,
|
||||
format: None,
|
||||
..CommonArgs::default()
|
||||
},
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: Some(Duration::from_secs(1)),
|
||||
cwd: None,
|
||||
tail_bytes: 32,
|
||||
log_dir: None,
|
||||
command: command(&[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Write-Error boom; Write-Output out; exit 9",
|
||||
]),
|
||||
};
|
||||
assert_eq!(run(&failure_cli).expect("failure"), ExitCode::Success);
|
||||
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let log_dir = temp.path().join("logs");
|
||||
let timeout_cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
cwd: None,
|
||||
tail_bytes: 32,
|
||||
log_dir: Some(log_dir.clone()),
|
||||
command: command(&[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Start-Sleep -Milliseconds 200",
|
||||
]),
|
||||
};
|
||||
assert_eq!(run(&timeout_cli).expect("timeout"), ExitCode::Success);
|
||||
let entries = fs::read_dir(&log_dir).expect("log dir");
|
||||
assert!(entries.count() >= 1);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn run_accepts_single_string_pwsh_commands() {
|
||||
let cli = Cli {
|
||||
common: CommonArgs {
|
||||
json: true,
|
||||
format: None,
|
||||
..CommonArgs::default()
|
||||
},
|
||||
fail_on_child_error: false,
|
||||
shell: ShellMode::Pwsh,
|
||||
timeout: Some(Duration::from_secs(1)),
|
||||
cwd: None,
|
||||
tail_bytes: 32,
|
||||
log_dir: None,
|
||||
command: command(&["Write-Output done"]),
|
||||
};
|
||||
assert_eq!(run(&cli).expect("pwsh inline command"), ExitCode::Success);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn fail_on_child_error_switches_exit_code_for_failures_and_timeouts() {
|
||||
let failure_cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
fail_on_child_error: true,
|
||||
shell: ShellMode::Raw,
|
||||
timeout: Some(Duration::from_secs(1)),
|
||||
cwd: None,
|
||||
tail_bytes: 32,
|
||||
log_dir: None,
|
||||
command: command(&["pwsh", "-NoProfile", "-Command", "Write-Error boom; exit 7"]),
|
||||
};
|
||||
assert_eq!(run(&failure_cli).expect("failure"), ExitCode::RuntimeError);
|
||||
|
||||
let timeout_cli = Cli {
|
||||
command: command(&[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Start-Sleep -Milliseconds 200",
|
||||
]),
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
..failure_cli
|
||||
};
|
||||
assert_eq!(run(&timeout_cli).expect("timeout"), ExitCode::RuntimeError);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user