raz_common/
shell.rs

1//! Shell command parsing and manipulation utilities
2
3use crate::error::{CommonError, Result};
4use shell_words;
5use std::fmt;
6
7/// Shell command builder and parser
8#[derive(Debug, Clone, PartialEq)]
9pub struct ShellCommand {
10    /// The base command
11    pub command: String,
12    /// Command arguments
13    pub args: Vec<String>,
14}
15
16impl ShellCommand {
17    /// Create a new shell command
18    pub fn new(command: impl Into<String>) -> Self {
19        Self {
20            command: command.into(),
21            args: Vec::new(),
22        }
23    }
24
25    /// Add an argument
26    pub fn arg(mut self, arg: impl Into<String>) -> Self {
27        self.args.push(arg.into());
28        self
29    }
30
31    /// Add multiple arguments
32    pub fn args<I, S>(mut self, args: I) -> Self
33    where
34        I: IntoIterator<Item = S>,
35        S: Into<String>,
36    {
37        self.args.extend(args.into_iter().map(Into::into));
38        self
39    }
40
41    /// Parse a shell command string
42    pub fn parse(input: &str) -> Result<Self> {
43        let parts =
44            shell_words::split(input).map_err(|e| CommonError::ShellParse(e.to_string()))?;
45
46        if parts.is_empty() {
47            return Err(CommonError::ShellParse("Empty command".to_string()));
48        }
49
50        Ok(Self {
51            command: parts[0].clone(),
52            args: parts[1..].to_vec(),
53        })
54    }
55
56    /// Convert to a shell-escaped string
57    pub fn to_shell_string(&self) -> String {
58        let mut parts = vec![self.command.clone()];
59        parts.extend(self.args.clone());
60        shell_words::join(&parts)
61    }
62
63    /// Get all parts (command + args) as a vector
64    pub fn parts(&self) -> Vec<&str> {
65        let mut parts = vec![self.command.as_str()];
66        parts.extend(self.args.iter().map(|s| s.as_str()));
67        parts
68    }
69}
70
71impl fmt::Display for ShellCommand {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "{}", self.to_shell_string())
74    }
75}
76
77/// Parse shell-style key=value pairs
78pub fn parse_env_vars(input: &str) -> Result<Vec<(String, String)>> {
79    let mut env_vars = Vec::new();
80    let parts = shell_words::split(input).map_err(|e| CommonError::ShellParse(e.to_string()))?;
81
82    for part in parts {
83        if let Some((key, value)) = part.split_once('=') {
84            if is_valid_env_var_name(key) {
85                env_vars.push((key.to_string(), value.to_string()));
86            }
87        }
88    }
89
90    Ok(env_vars)
91}
92
93/// Check if a string is a valid environment variable name
94pub fn is_valid_env_var_name(name: &str) -> bool {
95    !name.is_empty()
96        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
97        && name
98            .chars()
99            .next()
100            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_shell_command_parse() {
109        let cmd = ShellCommand::parse("cargo test --features foo").unwrap();
110        assert_eq!(cmd.command, "cargo");
111        assert_eq!(cmd.args, vec!["test", "--features", "foo"]);
112    }
113
114    #[test]
115    fn test_shell_command_display() {
116        let cmd = ShellCommand::new("echo").arg("hello world").arg("test");
117        assert_eq!(cmd.to_string(), "echo 'hello world' test");
118        assert_eq!(cmd.to_shell_string(), "echo 'hello world' test");
119    }
120
121    #[test]
122    fn test_parse_env_vars() {
123        let vars = parse_env_vars("FOO=bar BAZ='quoted value'").unwrap();
124        assert_eq!(
125            vars,
126            vec![
127                ("FOO".to_string(), "bar".to_string()),
128                ("BAZ".to_string(), "quoted value".to_string()),
129            ]
130        );
131    }
132}