Skip to main content

ssm_core/
command.rs

1use std::collections::HashMap;
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5pub enum CommandError {
6    #[error("io error: {0}")]
7    Io(#[from] std::io::Error),
8    #[error("unresolved placeholder: '{0}'")]
9    UnresolvedPlaceholder(String),
10}
11
12/// Extract all `{{name}}` placeholders from a command string.
13/// Returns deduplicated names in the order they first appear.
14pub fn extract_placeholders(command: &str) -> Vec<String> {
15    let mut seen = std::collections::HashSet::new();
16    let mut result = Vec::new();
17
18    let mut chars = command.chars().peekable();
19    while let Some(c) = chars.next() {
20        if c == '{'
21            && chars.peek() == Some(&'{')
22        {
23            chars.next(); // consume second '{'
24            let mut name = String::new();
25            let mut closed = false;
26            while let Some(nc) = chars.next() {
27                if nc == '}' {
28                    if chars.peek() == Some(&'}') {
29                        chars.next(); // consume second '}'
30                        closed = true;
31                        break;
32                    } else {
33                        name.push(nc);
34                    }
35                } else {
36                    name.push(nc);
37                }
38            }
39            if closed && !name.is_empty() && seen.insert(name.clone()) {
40                result.push(name);
41            }
42        }
43    }
44    result
45}
46
47/// Replace all `{{name}}` placeholders in a command string with values from the map.
48/// Returns an error if a placeholder has no corresponding value.
49pub fn expand_placeholders(
50    command: &str,
51    values: &HashMap<String, String>,
52) -> Result<String, CommandError> {
53    let placeholders = extract_placeholders(command);
54    let mut result = command.to_string();
55    for name in &placeholders {
56        match values.get(name) {
57            Some(value) => {
58                let pattern = format!("{{{{{}}}}}", name);
59                result = result.replace(&pattern, value);
60            }
61            None => return Err(CommandError::UnresolvedPlaceholder(name.clone())),
62        }
63    }
64    Ok(result)
65}
66
67#[derive(Debug, Clone)]
68pub struct CapturedOutput {
69    pub stdout: String,
70    pub stderr: String,
71    pub exit_code: i32,
72}
73
74/// Run `ssh <host_alias> <command>` and capture stdout/stderr.
75pub fn run_and_capture(host_alias: &str, command: &str) -> Result<CapturedOutput, CommandError> {
76    let output = std::process::Command::new("ssh")
77        .args([host_alias, command])
78        .output()?;
79
80    Ok(CapturedOutput {
81        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
82        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
83        exit_code: output.status.code().unwrap_or(-1),
84    })
85}
86
87/// Build args for an interactive SSH session that runs a command then drops to $SHELL.
88/// Produces: `["ssh", "alias", "-t", "cmd; $SHELL"]`
89pub fn build_session_args(host_alias: &str, command: &str) -> Vec<String> {
90    vec![
91        "ssh".to_string(),
92        host_alias.to_string(),
93        "-t".to_string(),
94        format!("{}; $SHELL", command),
95    ]
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_no_placeholders() {
104        let result = extract_placeholders("ls -la /var/log");
105        assert!(result.is_empty());
106    }
107
108    #[test]
109    fn test_single_placeholder() {
110        let result = extract_placeholders("tail -f {{logfile}}");
111        assert_eq!(result, vec!["logfile"]);
112    }
113
114    #[test]
115    fn test_multiple_placeholders() {
116        let result = extract_placeholders("grep {{pattern}} {{file}}");
117        assert_eq!(result, vec!["pattern", "file"]);
118    }
119
120    #[test]
121    fn test_dedup_placeholders() {
122        let result = extract_placeholders("echo {{name}} {{name}} {{other}}");
123        assert_eq!(result, vec!["name", "other"]);
124    }
125
126    #[test]
127    fn test_empty_braces_ignored() {
128        // {{}} should not be treated as a valid placeholder (empty name)
129        let result = extract_placeholders("echo {{}}");
130        assert!(result.is_empty());
131    }
132
133    #[test]
134    fn test_expand_placeholders() {
135        let mut values = HashMap::new();
136        values.insert("logfile".to_string(), "/var/log/app.log".to_string());
137        let result = expand_placeholders("tail -f {{logfile}}", &values).unwrap();
138        assert_eq!(result, "tail -f /var/log/app.log");
139    }
140
141    #[test]
142    fn test_expand_missing_fails() {
143        let values = HashMap::new();
144        let err = expand_placeholders("tail -f {{logfile}}", &values).unwrap_err();
145        assert!(matches!(err, CommandError::UnresolvedPlaceholder(name) if name == "logfile"));
146    }
147
148    #[test]
149    fn test_expand_multiple_placeholders() {
150        let mut values = HashMap::new();
151        values.insert("pattern".to_string(), "ERROR".to_string());
152        values.insert("file".to_string(), "/var/log/syslog".to_string());
153        let result = expand_placeholders("grep {{pattern}} {{file}}", &values).unwrap();
154        assert_eq!(result, "grep ERROR /var/log/syslog");
155    }
156
157    #[test]
158    fn test_build_session_args() {
159        let args = build_session_args("myserver", "htop");
160        assert_eq!(
161            args,
162            vec!["ssh", "myserver", "-t", "htop; $SHELL"]
163        );
164    }
165}