use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
use tokio::process::Command as TokioCommand;
#[derive(Debug, Clone)]
pub struct CommandSpec {
pub program: OsString,
pub args: Vec<OsString>,
pub cwd: Option<PathBuf>,
pub env: Option<HashMap<OsString, OsString>>,
}
impl CommandSpec {
#[must_use]
pub fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
cwd: None,
env: None,
}
}
#[must_use]
pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
#[must_use]
pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env
.get_or_insert_with(HashMap::new)
.insert(key.into(), value.into());
self
}
#[must_use]
pub fn envs<I, K, V>(mut self, envs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<OsString>,
V: Into<OsString>,
{
let env_map = self.env.get_or_insert_with(HashMap::new);
for (key, value) in envs {
env_map.insert(key.into(), value.into());
}
self
}
#[must_use]
pub fn to_command(&self) -> Command {
let mut cmd = Command::new(&self.program);
cmd.args(&self.args);
if let Some(ref cwd) = self.cwd {
cmd.current_dir(cwd);
}
if let Some(ref env) = self.env {
for (key, value) in env {
cmd.env(key, value);
}
}
cmd
}
#[must_use]
pub fn to_tokio_command(&self) -> TokioCommand {
let mut cmd = TokioCommand::new(&self.program);
cmd.args(&self.args);
if let Some(ref cwd) = self.cwd {
cmd.current_dir(cwd);
}
if let Some(ref env) = self.env {
for (key, value) in env {
cmd.env(key, value);
}
}
cmd
}
}
impl Default for CommandSpec {
fn default() -> Self {
Self {
program: OsString::new(),
args: Vec::new(),
cwd: None,
env: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_command_spec_new() {
let cmd = CommandSpec::new("claude");
assert_eq!(cmd.program, OsString::from("claude"));
assert!(cmd.args.is_empty());
assert!(cmd.cwd.is_none());
assert!(cmd.env.is_none());
}
#[test]
fn test_command_spec_arg() {
let cmd = CommandSpec::new("claude").arg("--print").arg("--verbose");
assert_eq!(cmd.args.len(), 2);
assert_eq!(cmd.args[0], OsString::from("--print"));
assert_eq!(cmd.args[1], OsString::from("--verbose"));
}
#[test]
fn test_command_spec_args() {
let cmd = CommandSpec::new("claude").args(["--print", "--output-format", "json"]);
assert_eq!(cmd.args.len(), 3);
assert_eq!(cmd.args[0], OsString::from("--print"));
assert_eq!(cmd.args[1], OsString::from("--output-format"));
assert_eq!(cmd.args[2], OsString::from("json"));
}
#[test]
fn test_command_spec_cwd() {
let cmd = CommandSpec::new("claude").cwd("/path/to/workspace");
assert_eq!(cmd.cwd, Some(PathBuf::from("/path/to/workspace")));
}
#[test]
fn test_command_spec_env() {
let cmd = CommandSpec::new("claude")
.env("DEBUG", "1")
.env("VERBOSE", "true");
let env = cmd.env.as_ref().unwrap();
assert_eq!(env.len(), 2);
assert_eq!(
env.get(&OsString::from("DEBUG")),
Some(&OsString::from("1"))
);
assert_eq!(
env.get(&OsString::from("VERBOSE")),
Some(&OsString::from("true"))
);
}
#[test]
fn test_command_spec_envs() {
let cmd = CommandSpec::new("claude").envs([("DEBUG", "1"), ("VERBOSE", "true")]);
let env = cmd.env.as_ref().unwrap();
assert_eq!(env.len(), 2);
assert_eq!(
env.get(&OsString::from("DEBUG")),
Some(&OsString::from("1"))
);
assert_eq!(
env.get(&OsString::from("VERBOSE")),
Some(&OsString::from("true"))
);
}
#[test]
fn test_command_spec_builder_chain() {
let cmd = CommandSpec::new("claude")
.arg("--print")
.args(["--output-format", "json"])
.cwd("/workspace")
.env("DEBUG", "1")
.envs([("VERBOSE", "true")]);
assert_eq!(cmd.program, OsString::from("claude"));
assert_eq!(cmd.args.len(), 3);
assert_eq!(cmd.cwd, Some(PathBuf::from("/workspace")));
let env = cmd.env.as_ref().unwrap();
assert_eq!(env.len(), 2);
}
#[test]
fn test_command_spec_default() {
let cmd = CommandSpec::default();
assert_eq!(cmd.program, OsString::new());
assert!(cmd.args.is_empty());
assert!(cmd.cwd.is_none());
assert!(cmd.env.is_none());
}
#[test]
fn test_command_spec_clone() {
let cmd = CommandSpec::new("claude")
.arg("--print")
.cwd("/workspace")
.env("DEBUG", "1");
let cloned = cmd.clone();
assert_eq!(cloned.program, cmd.program);
assert_eq!(cloned.args, cmd.args);
assert_eq!(cloned.cwd, cmd.cwd);
assert_eq!(cloned.env, cmd.env);
}
#[test]
fn test_command_spec_to_command() {
let cmd = CommandSpec::new("echo").arg("hello").arg("world");
let std_cmd = cmd.to_command();
assert!(std::mem::size_of_val(&std_cmd) > 0);
}
#[test]
fn test_command_spec_to_tokio_command() {
let cmd = CommandSpec::new("echo").arg("hello");
let tokio_cmd = cmd.to_tokio_command();
assert!(std::mem::size_of_val(&tokio_cmd) > 0);
}
#[test]
fn test_command_spec_osstring_args() {
let cmd = CommandSpec::new(OsString::from("claude")).arg(OsString::from("--print"));
assert_eq!(cmd.program, OsString::from("claude"));
assert_eq!(cmd.args[0], OsString::from("--print"));
}
#[test]
fn test_command_spec_args_are_vec_osstring() {
let cmd = CommandSpec::new("claude")
.arg("arg with spaces")
.arg("arg;with;semicolons")
.arg("arg|with|pipes")
.arg("arg&with&ersands");
assert_eq!(cmd.args.len(), 4);
assert_eq!(cmd.args[0], OsString::from("arg with spaces"));
assert_eq!(cmd.args[1], OsString::from("arg;with;semicolons"));
assert_eq!(cmd.args[2], OsString::from("arg|with|pipes"));
assert_eq!(cmd.args[3], OsString::from("arg&with&ersands"));
}
#[test]
fn test_command_spec_shell_metacharacters_preserved() {
let cmd = CommandSpec::new("echo")
.arg("$(whoami)")
.arg("`id`")
.arg("${HOME}")
.arg("$PATH");
assert_eq!(cmd.args[0], OsString::from("$(whoami)"));
assert_eq!(cmd.args[1], OsString::from("`id`"));
assert_eq!(cmd.args[2], OsString::from("${HOME}"));
assert_eq!(cmd.args[3], OsString::from("$PATH"));
}
}