Skip to main content

utils/
shell_expander.rs

1use futures::future::join_all;
2use regex::Regex;
3use std::env;
4use std::path::Path;
5use std::sync::LazyLock;
6use tokio::process::Command;
7
8/// Expands `` !`command` `` markers in `content` by running each command via
9/// `$SHELL -c` (fallback `sh`) from `cwd` and substituting the trimmed stdout.
10/// Returns `content` unchanged if no markers are present.
11pub async fn expand_shell(content: &str, cwd: &Path) -> String {
12    if !SHELL_MARKER.is_match(content) {
13        return content.to_string();
14    }
15
16    let spans: Vec<(usize, usize, &str)> = SHELL_MARKER
17        .captures_iter(content)
18        .filter_map(|captures| {
19            let whole = captures.get(0)?;
20            let cmd = captures.get(1)?;
21            Some((whole.start(), whole.end(), cmd.as_str()))
22        })
23        .collect();
24
25    let outputs = join_all(spans.iter().map(|(_, _, cmd)| run(cmd, cwd))).await;
26    let mut out = String::with_capacity(content.len());
27    let mut last = 0;
28
29    for ((start, end, _), result) in spans.iter().zip(outputs) {
30        out.push_str(&content[last..*start]);
31        match result {
32            Ok(output) => out.push_str(&output),
33            Err(err) => tracing::warn!("{err}"),
34        }
35        last = *end;
36    }
37
38    out.push_str(&content[last..]);
39    out
40}
41
42#[derive(Debug, thiserror::Error)]
43enum ShellExpansionError {
44    #[error("Failed to spawn {shell} for `{cmd}`: {error}")]
45    Spawn { shell: String, cmd: String, error: String },
46    #[error("Shell interpolation `{cmd}` failed with {status}: {stderr}")]
47    NonZeroExit { cmd: String, status: String, stderr: String },
48}
49
50static SHELL_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!`([^`\n]+)`").expect("valid regex"));
51
52async fn run(cmd: &str, cwd: &Path) -> Result<String, ShellExpansionError> {
53    let shell = env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
54    let output =
55        Command::new(&shell).arg("-c").arg(cmd).current_dir(cwd).output().await.map_err(|e| {
56            ShellExpansionError::Spawn { shell: shell.clone(), cmd: cmd.to_string(), error: e.to_string() }
57        })?;
58
59    if !output.status.success() {
60        let stderr = String::from_utf8_lossy(&output.stderr);
61        return Err(ShellExpansionError::NonZeroExit {
62            cmd: cmd.to_string(),
63            status: output.status.to_string(),
64            stderr: stderr.trim().to_string(),
65        });
66    }
67
68    Ok(String::from_utf8_lossy(&output.stdout).trim_end().to_string())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[tokio::test]
76    async fn no_op_without_marker() {
77        let content = "Just some plain content with no directives";
78        let cwd = std::env::current_dir().unwrap();
79        let result = expand_shell(content, &cwd).await;
80        assert_eq!(result, content);
81    }
82
83    #[tokio::test]
84    async fn runs_shell_command() {
85        let cwd = std::env::current_dir().unwrap();
86        let result = expand_shell("branch: !`echo main`", &cwd).await;
87        assert_eq!(result, "branch: main");
88    }
89
90    #[tokio::test]
91    async fn runs_command_in_cwd() {
92        let dir = tempfile::tempdir().unwrap();
93        std::fs::write(dir.path().join("sentinel.txt"), "").unwrap();
94
95        let result = expand_shell("files: !`ls`", dir.path()).await;
96        assert!(result.contains("sentinel.txt"), "expected sentinel.txt in output: {result}");
97    }
98
99    #[tokio::test]
100    async fn handles_multiple_commands() {
101        let cwd = std::env::current_dir().unwrap();
102        let result = expand_shell("a=!`echo one`, b=!`echo two`", &cwd).await;
103        assert_eq!(result, "a=one, b=two");
104    }
105
106    #[tokio::test]
107    async fn failed_command_substitutes_empty_string() {
108        let cwd = std::env::current_dir().unwrap();
109        let result = expand_shell("before !`exit 1` after", &cwd).await;
110        assert_eq!(result, "before  after");
111    }
112
113    #[tokio::test]
114    async fn trims_trailing_whitespace() {
115        let cwd = std::env::current_dir().unwrap();
116        let result = expand_shell("!`printf 'hi\\n\\n'`", &cwd).await;
117        assert_eq!(result, "hi");
118    }
119}