chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "argv"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Inspect argv and optional stdin line payloads for shell quoting/debug workflows."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
runtimekit = { path = "../runtimekit" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
@@ -0,0 +1,922 @@
|
||||
//! The `argv` command renders shell-safe argument text and verifies real argv round-trips.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{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_shell_mode, parser_value_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const HELP: &str = "\
|
||||
Quote argument vectors for Windows shells and inspect what a shell really passed to a native process.
|
||||
|
||||
Usage:
|
||||
argv quote --shell <pwsh|cmd|raw> [VALUE...]
|
||||
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
|
||||
|
||||
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
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Notes:
|
||||
Windows-first: quote and inspect focus on native invocation behavior for pwsh, cmd, and raw process launches.
|
||||
cmd inspect is Windows-only.
|
||||
quote accepts positional args or a JSON array of strings from stdin when no positional args are provided.
|
||||
raw bypasses any shell and shows the direct native-process argv path.
|
||||
|
||||
Examples:
|
||||
argv quote --shell pwsh -- git commit -m \"two words\"
|
||||
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
|
||||
argv inspect --json --shell pwsh -- alpha \"two words\"
|
||||
";
|
||||
|
||||
const QUOTE_HELP: &str = "\
|
||||
Render a shell-safe invocation string for pwsh, cmd, or raw native argv.
|
||||
|
||||
Usage:
|
||||
argv quote --shell <pwsh|cmd|raw> [VALUE...]
|
||||
|
||||
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
|
||||
--shell <SHELL> Target shell: pwsh, cmd, raw
|
||||
-h, --help Show this help text
|
||||
|
||||
Examples:
|
||||
argv quote --shell pwsh -- git commit -m \"two words\"
|
||||
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
|
||||
";
|
||||
|
||||
const INSPECT_HELP: &str = "\
|
||||
Launch a native helper and show what argv actually arrived after shell parsing.
|
||||
|
||||
Usage:
|
||||
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
|
||||
|
||||
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
|
||||
--shell <SHELL> Target shell: pwsh, cmd, raw
|
||||
-h, --help Show this help text
|
||||
|
||||
Examples:
|
||||
argv inspect --shell pwsh -- alpha \"two words\"
|
||||
argv inspect --json --shell cmd -- alpha \"--filter=x|y\"
|
||||
";
|
||||
|
||||
const HELPER_SUBCOMMAND: &str = "__native_helper";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
command: CommandMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum CommandMode {
|
||||
Help(&'static str),
|
||||
Quote {
|
||||
shell: ShellMode,
|
||||
values: Vec<OsString>,
|
||||
},
|
||||
Inspect {
|
||||
shell: ShellMode,
|
||||
values: Vec<OsString>,
|
||||
},
|
||||
NativeHelper {
|
||||
values: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct QuoteReport {
|
||||
shell: String,
|
||||
text: String,
|
||||
argv: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct InspectReport {
|
||||
shell: String,
|
||||
invocation: String,
|
||||
probe_invocation: Option<String>,
|
||||
count: usize,
|
||||
argv: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct HelperReport {
|
||||
count: usize,
|
||||
argv: Vec<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!("argv {}", 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(),
|
||||
command: CommandMode::Quote {
|
||||
shell: ShellMode::Raw,
|
||||
values: 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("color") => {
|
||||
cli.common.color =
|
||||
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
||||
}
|
||||
ArgValue(value) => {
|
||||
let token = runtimekit::os_to_utf8(value, "subcommand")?;
|
||||
cli.command = match token.as_str() {
|
||||
"quote" => parse_quote_command(&mut parser, &mut cli.common)?,
|
||||
"inspect" => parse_inspect_command(&mut parser, &mut cli.common)?,
|
||||
HELPER_SUBCOMMAND => parse_native_helper_command(&mut parser)?,
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unknown subcommand; expected quote or inspect",
|
||||
));
|
||||
}
|
||||
};
|
||||
return Ok((ParseOutcome::Run, cli));
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(CliError::usage("provide a subcommand: quote or inspect"))
|
||||
}
|
||||
|
||||
fn parse_quote_command(
|
||||
parser: &mut lexopt::Parser,
|
||||
common: &mut CommonArgs,
|
||||
) -> Result<CommandMode, CliError> {
|
||||
let mut shell = None::<ShellMode>;
|
||||
let mut values = Vec::new();
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok(CommandMode::Help(QUOTE_HELP)),
|
||||
Long("json") => common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(parser, "--format")?;
|
||||
common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("quiet") => common.quiet = true,
|
||||
Long("color") => {
|
||||
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
||||
}
|
||||
Long("shell") => {
|
||||
shell = Some(parse_shell_mode(
|
||||
"--shell",
|
||||
&parser_value_string(parser, "--shell")?,
|
||||
)?);
|
||||
}
|
||||
ArgValue(value) => {
|
||||
values = collect_command_values(parser, value)?;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported quote argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CommandMode::Quote {
|
||||
shell: shell.ok_or_else(|| CliError::usage("quote requires --shell <pwsh|cmd|raw>"))?,
|
||||
values,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_inspect_command(
|
||||
parser: &mut lexopt::Parser,
|
||||
common: &mut CommonArgs,
|
||||
) -> Result<CommandMode, CliError> {
|
||||
let mut shell = None::<ShellMode>;
|
||||
let mut values = Vec::new();
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok(CommandMode::Help(INSPECT_HELP)),
|
||||
Long("json") => common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(parser, "--format")?;
|
||||
common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("quiet") => common.quiet = true,
|
||||
Long("color") => {
|
||||
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
||||
}
|
||||
Long("shell") => {
|
||||
shell = Some(parse_shell_mode(
|
||||
"--shell",
|
||||
&parser_value_string(parser, "--shell")?,
|
||||
)?);
|
||||
}
|
||||
ArgValue(value) => {
|
||||
values = collect_command_values(parser, value)?;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported inspect argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CommandMode::Inspect {
|
||||
shell: shell.ok_or_else(|| CliError::usage("inspect requires --shell <pwsh|cmd|raw>"))?,
|
||||
values,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_native_helper_command(parser: &mut lexopt::Parser) -> Result<CommandMode, CliError> {
|
||||
let mut values = Vec::new();
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
ArgValue(value) => values.push(runtimekit::os_to_utf8(value, "helper arg")?),
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"internal helper expects only positional values",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CommandMode::NativeHelper { values })
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
match &cli.command {
|
||||
CommandMode::Help(text) => {
|
||||
print_text(text)?;
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
CommandMode::Quote { shell, values } => run_quote(cli, *shell, values),
|
||||
CommandMode::Inspect { shell, values } => run_inspect(cli, *shell, values),
|
||||
CommandMode::NativeHelper { values } => run_native_helper(values),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_quote(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
|
||||
let argv = resolve_values(cli, values)?;
|
||||
let report = QuoteReport {
|
||||
shell: shell_label(shell).to_string(),
|
||||
text: render_shell_invocation(shell, &argv),
|
||||
argv,
|
||||
};
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&report)?,
|
||||
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
||||
RenderMode::Text => print_text(&report.text)?,
|
||||
}
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn run_inspect(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
|
||||
let argv = resolve_values(cli, values)?;
|
||||
let inspection = inspect_arguments(shell, &argv)?;
|
||||
let report = InspectReport {
|
||||
shell: shell_label(shell).to_string(),
|
||||
invocation: inspection.invocation,
|
||||
probe_invocation: Some(inspection.probe_invocation),
|
||||
count: inspection.helper.argv.len(),
|
||||
argv: inspection.helper.argv,
|
||||
};
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&report)?,
|
||||
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
||||
RenderMode::Text => print_text(render_inspect_text(&report))?,
|
||||
}
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn run_native_helper(values: &[String]) -> Result<ExitCode, CliError> {
|
||||
let report = HelperReport {
|
||||
count: values.len(),
|
||||
argv: values.to_vec(),
|
||||
};
|
||||
print_json(&report)?;
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn resolve_values(cli: &Cli, values: &[OsString]) -> Result<Vec<String>, CliError> {
|
||||
if !values.is_empty() {
|
||||
return values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|value| runtimekit::os_to_utf8(value, "argv value"))
|
||||
.collect();
|
||||
}
|
||||
if cli.common.stdin_is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"provide positional values or pipe a JSON array of strings into stdin",
|
||||
));
|
||||
}
|
||||
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
let trimmed = buffer.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"stdin was empty; expected a JSON array of strings",
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"expected a JSON array of strings on stdin, got parse error: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct InspectionResult {
|
||||
invocation: String,
|
||||
probe_invocation: String,
|
||||
helper: HelperReport,
|
||||
}
|
||||
|
||||
fn inspect_arguments(shell: ShellMode, values: &[String]) -> Result<InspectionResult, CliError> {
|
||||
let current_exe = std::env::current_exe().map_err(|error| {
|
||||
CliError::runtime(format!("failed to resolve current executable: {error}"))
|
||||
})?;
|
||||
let helper_exe = current_exe.display().to_string();
|
||||
let mut helper_argv = vec![helper_exe, HELPER_SUBCOMMAND.to_string(), "--".to_string()];
|
||||
helper_argv.extend(values.iter().cloned());
|
||||
let invocation = render_shell_invocation(shell, values);
|
||||
let probe_invocation = render_shell_invocation(shell, &helper_argv);
|
||||
|
||||
let output = match shell {
|
||||
ShellMode::Raw => Command::new(¤t_exe)
|
||||
.arg(HELPER_SUBCOMMAND)
|
||||
.arg("--")
|
||||
.args(values)
|
||||
.output()
|
||||
.map_err(|error| CliError::runtime(format!("failed to launch raw helper: {error}")))?,
|
||||
ShellMode::Pwsh => Command::new("pwsh")
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg(&probe_invocation)
|
||||
.output()
|
||||
.map_err(|error| CliError::runtime(format!("failed to launch pwsh helper: {error}")))?,
|
||||
ShellMode::Cmd => {
|
||||
if !cfg!(windows) {
|
||||
return Err(CliError::usage("cmd inspect is Windows-only"));
|
||||
}
|
||||
let wrapper = unique_cmd_wrapper_path();
|
||||
let wrapper_body = format!(
|
||||
"@echo off\r\nsetlocal DisableDelayedExpansion\r\n{}\r\n",
|
||||
probe_invocation.replace('%', "%%")
|
||||
);
|
||||
write_cmd_wrapper_file(&wrapper, &wrapper_body)?;
|
||||
let output = Command::new("cmd")
|
||||
.arg("/d")
|
||||
.arg("/v:off")
|
||||
.arg("/c")
|
||||
.arg(&wrapper)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
CliError::runtime(format!("failed to launch cmd helper: {error}"))
|
||||
});
|
||||
let _ = fs::remove_file(&wrapper);
|
||||
output?
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CliError::runtime(format!(
|
||||
"native helper failed with status {}: {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
let helper = serde_json::from_slice::<HelperReport>(&output.stdout)
|
||||
.map_err(|error| CliError::runtime(format!("failed to parse helper output: {error}")))?;
|
||||
Ok(InspectionResult {
|
||||
invocation,
|
||||
probe_invocation,
|
||||
helper,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_inspect_text(report: &InspectReport) -> String {
|
||||
let mut lines = vec![format!(
|
||||
"shell={} count={} invocation={}",
|
||||
report.shell, report.count, report.invocation
|
||||
)];
|
||||
lines.extend(
|
||||
report
|
||||
.argv
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| format!("argv[{index}]={}", quote_text(value))),
|
||||
);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_shell_invocation(shell: ShellMode, values: &[String]) -> String {
|
||||
match shell {
|
||||
ShellMode::Pwsh => {
|
||||
let mut parts = vec!["&".to_string()];
|
||||
parts.extend(values.iter().map(|value| quote_pwsh(value)));
|
||||
parts.join(" ")
|
||||
}
|
||||
ShellMode::Cmd => values
|
||||
.iter()
|
||||
.map(|value| quote_cmd(value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
ShellMode::Raw => values
|
||||
.iter()
|
||||
.map(|value| quote_raw(value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_pwsh(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn quote_cmd(value: &str) -> String {
|
||||
if value.is_empty()
|
||||
|| value
|
||||
.chars()
|
||||
.any(|ch| ch.is_whitespace() || "\"^&|<>()%!".contains(ch))
|
||||
{
|
||||
quote_windows_arg_inner(value, true)
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_raw(value: &str) -> String {
|
||||
quote_windows_arg(value)
|
||||
}
|
||||
|
||||
fn quote_windows_arg(value: &str) -> String {
|
||||
quote_windows_arg_inner(value, false)
|
||||
}
|
||||
|
||||
fn quote_windows_arg_inner(value: &str, force_quote: bool) -> String {
|
||||
if force_quote || value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') {
|
||||
let mut rendered = String::from("\"");
|
||||
let mut backslashes = 0_usize;
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\\' => backslashes += 1,
|
||||
'"' => {
|
||||
rendered.push_str(&"\\".repeat(backslashes * 2 + 1));
|
||||
rendered.push('"');
|
||||
backslashes = 0;
|
||||
}
|
||||
_ => {
|
||||
rendered.push_str(&"\\".repeat(backslashes));
|
||||
backslashes = 0;
|
||||
rendered.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
rendered.push_str(&"\\".repeat(backslashes * 2));
|
||||
rendered.push('"');
|
||||
rendered
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_text(value: &str) -> String {
|
||||
if value.is_empty() || value.chars().any(char::is_whitespace) {
|
||||
format!("\"{}\"", value.replace('"', "\\\""))
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
const fn shell_label(shell: ShellMode) -> &'static str {
|
||||
match shell {
|
||||
ShellMode::Raw => "raw",
|
||||
ShellMode::Pwsh => "pwsh",
|
||||
ShellMode::Cmd => "cmd",
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_cmd_wrapper_path() -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |value| value.as_nanos());
|
||||
std::env::temp_dir().join(format!(
|
||||
"mercury-argv-helper-{}-{unique}.cmd",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn write_cmd_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 cmd inspect 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())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_quote_and_inspect_commands() {
|
||||
let (_, quote_cli) = parse_cli_from([
|
||||
"argv",
|
||||
"quote",
|
||||
"--shell",
|
||||
"pwsh",
|
||||
"--",
|
||||
"tool",
|
||||
"two words",
|
||||
])
|
||||
.expect("quote cli");
|
||||
assert!(matches!(
|
||||
quote_cli.command,
|
||||
CommandMode::Quote {
|
||||
shell: ShellMode::Pwsh,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let (_, inspect_cli) =
|
||||
parse_cli_from(["argv", "--json", "inspect", "--shell", "raw", "--", "alpha"])
|
||||
.expect("inspect cli");
|
||||
assert!(inspect_cli.common.json);
|
||||
assert!(matches!(
|
||||
inspect_cli.command,
|
||||
CommandMode::Inspect {
|
||||
shell: ShellMode::Raw,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let (_, inspect_help) =
|
||||
parse_cli_from(["argv", "inspect", "--help"]).expect("inspect help");
|
||||
assert!(matches!(
|
||||
inspect_help.command,
|
||||
CommandMode::Help(text) if text.contains("argv inspect")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_covers_global_and_subcommand_common_flags() {
|
||||
let (_, quote_cli) = parse_cli_from([
|
||||
"argv", "--quiet", "--color", "never", "quote", "--json", "--quiet", "--color",
|
||||
"never", "--shell", "cmd", "--", "tool.exe",
|
||||
])
|
||||
.expect("quote cli");
|
||||
assert!(quote_cli.common.json);
|
||||
assert!(quote_cli.common.quiet);
|
||||
assert_eq!(quote_cli.common.color, common::ColorChoice::Never);
|
||||
assert!(matches!(
|
||||
quote_cli.command,
|
||||
CommandMode::Quote {
|
||||
shell: ShellMode::Cmd,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let (_, inspect_cli) = parse_cli_from([
|
||||
"argv", "inspect", "--quiet", "--color", "never", "--shell", "pwsh", "--", "alpha",
|
||||
])
|
||||
.expect("inspect cli");
|
||||
assert!(inspect_cli.common.quiet);
|
||||
assert_eq!(inspect_cli.common.color, common::ColorChoice::Never);
|
||||
assert!(matches!(
|
||||
inspect_cli.command,
|
||||
CommandMode::Inspect {
|
||||
shell: ShellMode::Pwsh,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_rendering_is_shell_aware() {
|
||||
assert_eq!(
|
||||
render_shell_invocation(
|
||||
ShellMode::Pwsh,
|
||||
&["tool".to_string(), "two words".to_string()]
|
||||
),
|
||||
"& 'tool' 'two words'"
|
||||
);
|
||||
assert_eq!(
|
||||
render_shell_invocation(
|
||||
ShellMode::Cmd,
|
||||
&["tool.exe".to_string(), "two words".to_string()]
|
||||
),
|
||||
"tool.exe \"two words\""
|
||||
);
|
||||
assert_eq!(quote_pwsh("a'b"), "'a''b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_text_output_is_compact() {
|
||||
let report = InspectReport {
|
||||
shell: "raw".to_string(),
|
||||
invocation: "alpha".to_string(),
|
||||
probe_invocation: Some("tool.exe __native_helper -- alpha".to_string()),
|
||||
count: 2,
|
||||
argv: vec!["alpha".to_string(), "two words".to_string()],
|
||||
};
|
||||
let text = render_inspect_text(&report);
|
||||
assert!(text.contains("shell=raw count=2"));
|
||||
assert!(text.contains("argv[1]=\"two words\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_helper_parser_rejects_flags_and_quote_rendering_covers_raw() {
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", HELPER_SUBCOMMAND, "--json"]),
|
||||
Err(CliError::Usage(message)) if message.contains("only positional values")
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
render_shell_invocation(
|
||||
ShellMode::Raw,
|
||||
&["tool.exe".to_string(), "two words".to_string()]
|
||||
),
|
||||
"tool.exe \"two words\""
|
||||
);
|
||||
assert_eq!(quote_raw(""), "\"\"");
|
||||
assert_eq!(quote_text("needs quotes"), "\"needs quotes\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_and_helper_paths_reject_invalid_arguments() {
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv"]),
|
||||
Err(CliError::Usage(message)) if message.contains("provide a subcommand")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "--bogus"]),
|
||||
Err(CliError::Usage(message)) if message.contains("unsupported argument")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "quote", "--shell", "pwsh", "--bogus"]),
|
||||
Err(CliError::Usage(message)) if message.contains("unsupported quote argument")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "inspect", "--shell", "raw", "--bogus"]),
|
||||
Err(CliError::Usage(message)) if message.contains("unsupported inspect argument")
|
||||
));
|
||||
|
||||
let (_, helper_cli) =
|
||||
parse_cli_from(["argv", HELPER_SUBCOMMAND, "alpha", "two words"]).expect("helper cli");
|
||||
assert!(matches!(
|
||||
helper_cli.command,
|
||||
CommandMode::NativeHelper { values } if values == vec!["alpha".to_string(), "two words".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_unknown_subcommands_and_missing_shell_flags() {
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "wat"]),
|
||||
Err(CliError::Usage(message)) if message.contains("expected quote or inspect")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "quote", "--", "tool.exe"]),
|
||||
Err(CliError::Usage(message)) if message.contains("quote requires --shell")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["argv", "inspect", "--", "tool.exe"]),
|
||||
Err(CliError::Usage(message)) if message.contains("inspect requires --shell")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_helpers_cover_metacharacters_backslashes_and_empty_values() {
|
||||
assert_eq!(quote_cmd("a&b"), "\"a&b\"");
|
||||
assert_eq!(quote_cmd("two words"), "\"two words\"");
|
||||
assert_eq!(quote_windows_arg("two words\\"), "\"two words\\\\\"");
|
||||
assert_eq!(quote_windows_arg("say \"hi\""), "\"say \\\"hi\\\"\"");
|
||||
assert_eq!(quote_windows_arg("plain"), "plain");
|
||||
assert_eq!(quote_text(""), "\"\"");
|
||||
assert_eq!(shell_label(ShellMode::Cmd), "cmd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_resolve_values_cover_quote_and_helper_dispatch() {
|
||||
let quote_cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
command: CommandMode::Quote {
|
||||
shell: ShellMode::Raw,
|
||||
values: vec![OsString::from("tool.exe"), OsString::from("two words")],
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_values("e_cli, "e_cli_values("e_cli)).expect("values"),
|
||||
vec!["tool.exe".to_string(), "two words".to_string()]
|
||||
);
|
||||
assert_eq!(run("e_cli).expect("quote"), ExitCode::Success);
|
||||
|
||||
let helper_cli = Cli {
|
||||
common: CommonArgs {
|
||||
json: true,
|
||||
format: None,
|
||||
..CommonArgs::default()
|
||||
},
|
||||
command: CommandMode::NativeHelper {
|
||||
values: vec!["alpha".to_string()],
|
||||
},
|
||||
};
|
||||
assert_eq!(run(&helper_cli).expect("helper"), ExitCode::Success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_arguments_attempt_pwsh_and_cmd_launch_paths() {
|
||||
let pwsh_error =
|
||||
inspect_arguments(ShellMode::Pwsh, &["alpha".to_string()]).expect_err("pwsh helper");
|
||||
assert!(matches!(
|
||||
pwsh_error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("failed to parse helper output")
|
||||
|| message.contains("native helper failed with status")
|
||||
|| message.contains("failed to launch pwsh helper")
|
||||
));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd_error =
|
||||
inspect_arguments(ShellMode::Cmd, &["alpha".to_string()]).expect_err("cmd helper");
|
||||
assert!(matches!(
|
||||
cmd_error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("failed to parse helper output")
|
||||
|| message.contains("native helper failed with status")
|
||||
|| message.contains("failed to write")
|
||||
|| message.contains("failed to launch cmd helper")
|
||||
));
|
||||
let wrapper_one = unique_cmd_wrapper_path();
|
||||
let wrapper_two = unique_cmd_wrapper_path();
|
||||
assert_ne!(wrapper_one, wrapper_two);
|
||||
assert_eq!(
|
||||
wrapper_one.extension().and_then(|value| value.to_str()),
|
||||
Some("cmd")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_values_rejects_missing_terminal_input() {
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
command: CommandMode::Quote {
|
||||
shell: ShellMode::Raw,
|
||||
values: Vec::new(),
|
||||
},
|
||||
};
|
||||
let error = resolve_values(&cli, &[]).expect_err("missing values");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("provide positional values or pipe a JSON array")
|
||||
|| message.contains("stdin was empty; expected a JSON array of strings")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_arguments_in_unit_tests_surface_helper_failures() {
|
||||
let error = inspect_arguments(ShellMode::Raw, &["alpha".to_string()])
|
||||
.expect_err("unit test binary is not the helper entrypoint");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("failed to parse helper output")
|
||||
|| message.contains("native helper failed with status")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_wrapper_writer_refuses_preexisting_paths() {
|
||||
let path = unique_cmd_wrapper_path();
|
||||
fs::write(&path, "original").expect("preexisting wrapper");
|
||||
|
||||
let error =
|
||||
write_cmd_wrapper_file(&path, "replacement").expect_err("preexisting path refused");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("refusing to replace existing cmd inspect wrapper")
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("preserved content"),
|
||||
"original"
|
||||
);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_native_helper_returns_success_exit_code() {
|
||||
let code =
|
||||
run_native_helper(&["alpha".to_string(), "two words".to_string()]).expect("helper");
|
||||
assert_eq!(code, ExitCode::Success);
|
||||
}
|
||||
|
||||
fn quote_cli_values(cli: &Cli) -> Vec<OsString> {
|
||||
match &cli.command {
|
||||
CommandMode::Quote { values, .. } => values.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `argv`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(argv::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Integration tests for the `argv` command.
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("argv").expect("binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_prints_quick_help_card() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.assert()
|
||||
.code(2)
|
||||
.stdout(predicate::str::is_empty())
|
||||
.stderr(predicate::str::contains(
|
||||
"error: provide a subcommand: quote or inspect",
|
||||
))
|
||||
.stderr(predicate::str::contains("argv - Mercury Toolbox"))
|
||||
.stderr(predicate::str::contains("Usage:"))
|
||||
.stderr(predicate::str::contains("argv quote"))
|
||||
.stderr(predicate::str::contains("argv inspect"))
|
||||
.stderr(predicate::str::contains(
|
||||
"Type 'argv --help' for the full command reference.",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_quote_inspect_and_platform_boundaries() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("argv quote"))
|
||||
.stdout(predicate::str::contains("argv inspect"))
|
||||
.stdout(predicate::str::contains("Windows-first"))
|
||||
.stdout(predicate::str::contains("cmd inspect is Windows-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_help_mentions_common_output_flags() {
|
||||
for subcommand in ["quote", "inspect"] {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.args([subcommand, "--help"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--format <FORMAT>"))
|
||||
.stdout(predicate::str::contains("--json"))
|
||||
.stdout(predicate::str::contains("--toon"))
|
||||
.stdout(predicate::str::contains("--color <WHEN>"))
|
||||
.stdout(predicate::str::contains("--quiet"))
|
||||
.stdout(predicate::str::contains("--shell <SHELL>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_accepts_json_array_from_stdin() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("quote")
|
||||
.arg("--shell")
|
||||
.arg("pwsh")
|
||||
.write_stdin("[\"tool\",\"two words\"]")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("& 'tool' 'two words'"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn quote_renders_cmd_safe_text_for_positional_args() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("quote")
|
||||
.arg("--shell")
|
||||
.arg("cmd")
|
||||
.arg("--")
|
||||
.arg("tool.exe")
|
||||
.arg("two words")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("tool.exe"))
|
||||
.stdout(predicate::str::contains("\"two words\""));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn quote_cmd_wraps_metacharacters_for_cmd_shell() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("quote")
|
||||
.arg("--shell")
|
||||
.arg("cmd")
|
||||
.arg("--")
|
||||
.arg("tool.exe")
|
||||
.arg("a&b")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("tool.exe"))
|
||||
.stdout(predicate::str::contains("\"a&b\""));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inspect_round_trips_through_powershell_helper() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--json")
|
||||
.arg("--shell")
|
||||
.arg("pwsh")
|
||||
.arg("--")
|
||||
.arg("alpha")
|
||||
.arg("two words")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"shell\":\"pwsh\""))
|
||||
.stdout(predicate::str::contains(
|
||||
"\"argv\":[\"alpha\",\"two words\"]",
|
||||
))
|
||||
.stdout(predicate::str::contains("\"count\":2"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inspect_round_trips_positional_values_that_look_like_options() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--json")
|
||||
.arg("--shell")
|
||||
.arg("pwsh")
|
||||
.arg("--")
|
||||
.arg("alpha")
|
||||
.arg("--filter=x|y")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"\"argv\":[\"alpha\",\"--filter=x|y\"]",
|
||||
))
|
||||
.stdout(predicate::str::contains("\"count\":2"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inspect_round_trips_through_cmd_helper() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--json")
|
||||
.arg("--shell")
|
||||
.arg("cmd")
|
||||
.arg("--")
|
||||
.arg("alpha")
|
||||
.arg("two words")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"shell\":\"cmd\""))
|
||||
.stdout(predicate::str::contains(
|
||||
"\"argv\":[\"alpha\",\"two words\"]",
|
||||
))
|
||||
.stdout(predicate::str::contains("\"count\":2"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inspect_round_trips_cmd_caret_literals() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--json")
|
||||
.arg("--shell")
|
||||
.arg("cmd")
|
||||
.arg("--")
|
||||
.arg("--flag")
|
||||
.arg("literal^caret")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"\"argv\":[\"--flag\",\"literal^caret\"]",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_raw_uses_helper_without_shell() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--json")
|
||||
.arg("--shell")
|
||||
.arg("raw")
|
||||
.arg("--")
|
||||
.arg("alpha")
|
||||
.arg("two words")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"shell\":\"raw\""))
|
||||
.stdout(predicate::str::contains(
|
||||
"\"argv\":[\"alpha\",\"two words\"]",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_text_accepts_json_array_from_stdin() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("inspect")
|
||||
.arg("--shell")
|
||||
.arg("raw")
|
||||
.write_stdin("[\"alpha\",\"two words\"]")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("shell=raw count=2"))
|
||||
.stdout(predicate::str::contains("argv[1]=\"two words\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_rejects_invalid_stdin_json() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("quote")
|
||||
.arg("--shell")
|
||||
.arg("raw")
|
||||
.write_stdin("{\"argv\":[\"alpha\"]}")
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(predicate::str::contains(
|
||||
"expected a JSON array of strings on stdin",
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user