use anyhow::{bail, Result};
fn tokenize_shell_free_command(command: &str) -> Vec<String> {
command
.split_whitespace()
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect()
}
fn command_requires_shell(command: &str) -> bool {
command.chars().any(|ch| {
matches!(
ch,
'|' | '&'
| ';'
| '<'
| '>'
| '('
| ')'
| '$'
| '`'
| '*'
| '?'
| '['
| ']'
| '{'
| '}'
| '~'
| '\''
| '"'
| '\\'
| '\n'
)
})
}
fn is_posix_shell_builtin(command: &str) -> bool {
matches!(
command,
"." | ":"
| "break"
| "cd"
| "continue"
| "eval"
| "exec"
| "exit"
| "export"
| "readonly"
| "return"
| "set"
| "shift"
| "times"
| "trap"
| "umask"
| "unset"
)
}
pub(crate) fn resolve_exec_command(command: &str) -> Result<(String, Vec<String>)> {
let tokens = tokenize_shell_free_command(command);
let requires_shell = command_requires_shell(command)
|| tokens
.first()
.is_some_and(|head| is_posix_shell_builtin(head));
if requires_shell {
return Ok((
String::from("sh"),
vec![String::from("-c"), command.to_owned()],
));
}
let Some((head, args)) = tokens.split_first() else {
bail!("exec: command must not be empty");
};
Ok((head.clone(), args.to_vec()))
}
#[cfg(test)]
mod tests {
use super::resolve_exec_command;
#[test]
fn simple_command_splits_to_argv() {
let (command, args) = resolve_exec_command("echo hello").unwrap();
assert_eq!(command, "echo");
assert_eq!(args, vec!["hello".to_string()]);
}
#[test]
fn single_token_is_direct() {
let (command, args) = resolve_exec_command("echo").unwrap();
assert_eq!(command, "echo");
assert!(args.is_empty());
}
#[test]
fn missing_file_command_stays_direct() {
let (command, args) = resolve_exec_command("cat /no/such/file").unwrap();
assert_eq!(command, "cat");
assert_eq!(args, vec!["/no/such/file".to_string()]);
}
#[test]
fn shell_syntax_wraps_in_sh_c() {
for line in ["echo a && echo b", "echo hi > /tmp/x", "echo 'a b'", "ls *.txt", "a | b"] {
let (command, args) = resolve_exec_command(line).unwrap();
assert_eq!(command, "sh", "line {line:?} should use sh -c");
assert_eq!(args, vec!["-c".to_string(), line.to_string()]);
}
}
#[test]
fn builtin_head_wraps_in_sh_c() {
let (command, args) = resolve_exec_command("cd /tmp").unwrap();
assert_eq!(command, "sh");
assert_eq!(args, vec!["-c".to_string(), "cd /tmp".to_string()]);
}
#[test]
fn empty_command_is_error() {
assert!(resolve_exec_command("").is_err());
assert!(resolve_exec_command(" ").is_err());
}
}