use std::{fmt, iter};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[must_use]
pub(crate) fn quote_windows_arg(arg: &str) -> String {
if arg.is_empty() {
return "\"\"".to_string();
}
let needs_quotes = arg
.bytes()
.any(|byte| matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'"'));
if !needs_quotes {
return arg.to_string();
}
let mut quoted = String::from("\"");
let mut backslashes = 0_usize;
for ch in arg.chars() {
if ch == '\\' {
backslashes = backslashes.saturating_add(1);
continue;
}
if ch == '"' {
quoted.extend(iter::repeat_n(
'\\',
backslashes.saturating_mul(2).saturating_add(1),
));
quoted.push('"');
backslashes = 0;
continue;
}
quoted.extend(iter::repeat_n('\\', backslashes));
quoted.push(ch);
backslashes = 0;
}
quoted.extend(iter::repeat_n('\\', backslashes.saturating_mul(2)));
quoted.push('"');
quoted
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppCommand {
exe: String,
args: Vec<String>,
}
impl AppCommand {
#[must_use]
pub fn from_argv(argv: Vec<String>) -> Option<Self> {
let mut argv = argv.into_iter();
let exe = argv.next()?;
if exe.is_empty() {
return None;
}
Some(Self {
exe,
args: argv.collect(),
})
}
#[must_use]
pub fn exe(&self) -> &str {
&self.exe
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
#[must_use]
pub fn argv(&self) -> Vec<String> {
let mut argv = Vec::with_capacity(self.args.len().saturating_add(1));
argv.push(self.exe.clone());
argv.extend(self.args.iter().cloned());
argv
}
#[cfg(test)]
#[must_use]
pub(crate) fn for_test(argv: &[&str]) -> Self {
Self::from_argv(argv.iter().map(|arg| (*arg).to_string()).collect())
.expect("test argv names an executable")
}
}
impl fmt::Display for AppCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, token) in self.argv().iter().enumerate() {
if index > 0 {
f.write_str(" ")?;
}
for character in quote_windows_arg(token).chars() {
if character.is_control() {
for escaped in character.escape_debug() {
write!(f, "{escaped}")?;
}
} else {
write!(f, "{character}")?;
}
}
}
Ok(())
}
}
impl Serialize for AppCommand {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.argv().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for AppCommand {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let argv = Vec::<String>::deserialize(deserializer)?;
Self::from_argv(argv)
.ok_or_else(|| D::Error::custom("a session command names an executable"))
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn command(argv: &[&str]) -> AppCommand {
AppCommand::for_test(argv)
}
#[test]
fn an_argv_without_an_executable_is_refused() {
assert!(AppCommand::from_argv(Vec::new()).is_none());
assert!(AppCommand::from_argv(vec![String::new()]).is_none());
}
#[test]
fn the_first_element_is_the_executable() {
let command = command(&["app.exe", "--foo"]);
assert_eq!(command.exe(), "app.exe");
assert_eq!(command.args(), ["--foo"]);
assert_eq!(command.argv(), ["app.exe", "--foo"]);
}
#[test]
fn argument_boundaries_survive_being_displayed() {
let one_argument = command(&["tool.exe", r"C:\Program Files\tool.cfg"]);
let two_arguments = command(&["tool.exe", r"C:\Program", r"Files\tool.cfg"]);
assert_eq!(
one_argument.to_string(),
r#"tool.exe "C:\Program Files\tool.cfg""#
);
assert_eq!(
two_arguments.to_string(),
r"tool.exe C:\Program Files\tool.cfg"
);
assert_ne!(one_argument.to_string(), two_arguments.to_string());
}
#[test]
fn an_empty_argument_is_visible() {
assert_eq!(command(&["app.exe", ""]).to_string(), r#"app.exe """#);
}
#[test]
fn a_quote_doubles_the_backslashes_before_it() {
assert_eq!(
quote_windows_arg("a\\\"b").chars().collect::<Vec<_>>(),
vec!['"', 'a', '\\', '\\', '\\', '"', 'b', '"']
);
}
#[test]
fn a_control_character_cannot_reach_the_terminal() {
let rendered = command(&["app.exe", "safe\nforged"]).to_string();
assert!(!rendered.contains('\n'), "a newline survived in {rendered}");
assert!(rendered.contains(r"\n"));
let rendered = command(&["app.exe", "clear\u{1b}[2J"]).to_string();
assert!(!rendered.contains('\u{1b}'), "an escape survived");
}
#[test]
fn round_trips_as_a_flat_argv_array() {
let command = command(&["app.exe", "--foo"]);
let json = serde_json::to_string(&command).unwrap();
assert_eq!(json, r#"["app.exe","--foo"]"#);
assert_eq!(serde_json::from_str::<AppCommand>(&json).unwrap(), command);
}
#[test]
fn a_stored_command_without_an_executable_is_refused() {
serde_json::from_str::<AppCommand>("[]").unwrap_err();
}
}