1use futures::future::join_all;
2use regex::Regex;
3use std::env;
4use std::path::Path;
5use std::sync::LazyLock;
6use tokio::process::Command;
7
8pub 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}