//! 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] -- Options: --format Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --color 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 Launch mode: raw, pwsh, cmd (default: raw) --timeout Kill the command if it exceeds this duration --cwd Working directory used when spawning the command --tail-bytes Bytes of stdout/stderr to keep in the tail fields (default: 4096) --log-dir 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, cwd: Option, tail_bytes: usize, log_dir: Option, command: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ParseOutcome { Help, Version, Run, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct ProbeReport { exit_code: Option, duration_ms: u128, timed_out: bool, termination_reason: String, stdout_tail: String, stderr_tail: String, log_path: Option, command: String, argv: Vec, cwd: Option, ok: bool, next_hint: Option, } /// 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(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { 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 { 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 { 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) -> String { value.map_or_else(|| "none".to_string(), |item| item.to_string()) } fn termination_reason(timed_out: bool, exit_code: Option) -> 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 { 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 { 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); } }