use std::path::Path;
const SHELL_OUTPUT_MAX: usize = 32_768;
pub fn expand_command_template(
template: &str,
arguments: &str,
working_directory: &Path,
) -> String {
let mut result = template.to_string();
let has_arguments_var = result.contains("$ARGUMENTS");
let max_pos = find_max_positional(&result);
let had_variables = has_arguments_var || max_pos > 0;
if max_pos > 0 {
let positionals = shell_split(arguments);
for i in 1..max_pos {
let placeholder = format!("${}", i);
let value = positionals.get(i - 1).map(|s| s.as_str()).unwrap_or("");
result = result.replace(&placeholder, value);
}
let last_placeholder = format!("${}", max_pos);
let remainder = if positionals.len() >= max_pos {
positionals[max_pos - 1..].join(" ")
} else {
String::new()
};
result = result.replace(&last_placeholder, &remainder);
}
if has_arguments_var {
result = result.replace("$ARGUMENTS", arguments);
}
if !had_variables && !arguments.is_empty() {
result.push_str("\n\n");
result.push_str(arguments);
}
result = expand_file_inclusions(&result, working_directory);
result = expand_shell_injections(&result, working_directory);
result
}
fn find_max_positional(template: &str) -> usize {
let mut max = 0usize;
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > start {
if let Ok(n) = std::str::from_utf8(&bytes[start..i])
.unwrap_or("")
.parse::<usize>()
{
max = max.max(n);
}
}
} else {
i += 1;
}
}
max
}
fn shell_split(input: &str) -> Vec<String> {
let input = input.trim();
if input.is_empty() {
return Vec::new();
}
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_single_quote = false;
let mut in_double_quote = false;
while let Some(ch) = chars.next() {
match ch {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
c if c.is_whitespace() && !in_single_quote && !in_double_quote => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
'\\' if !in_single_quote => {
if let Some(next) = chars.next() {
current.push(next);
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn expand_file_inclusions(template: &str, working_directory: &Path) -> String {
let mut result = String::with_capacity(template.len());
let mut last_end = 0;
let bytes = template.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'@' {
let at_boundary = i == 0 || bytes[i - 1].is_ascii_whitespace();
if at_boundary {
let path_start = i + 1;
let mut path_end = path_start;
while path_end < len && !bytes[path_end].is_ascii_whitespace() {
path_end += 1;
}
if path_end > path_start {
let file_path =
std::str::from_utf8(&bytes[path_start..path_end]).unwrap_or("");
if !file_path.is_empty()
&& !file_path.starts_with('@')
&& (file_path.contains('/') || file_path.contains('.'))
{
let resolved = working_directory.join(file_path);
result.push_str(&template[last_end..i]);
match std::fs::read_to_string(&resolved) {
Ok(content) => {
let trimmed = content.trim();
result.push_str(&format!(
"<file path=\"{}\">\n{}\n</file>",
file_path, trimmed
));
}
Err(_) => {
result.push_str(&template[i..path_end]);
}
}
last_end = path_end;
i = path_end;
continue;
}
}
}
}
i += 1;
}
result.push_str(&template[last_end..]);
result
}
fn expand_shell_injections(template: &str, working_directory: &Path) -> String {
let mut result = template.to_string();
loop {
let Some(start) = result.find("!`") else {
break;
};
let cmd_start = start + 2;
let Some(end_offset) = result[cmd_start..].find('`') else {
break;
};
let cmd_end = cmd_start + end_offset;
let command = result[cmd_start..cmd_end].to_string();
let full_end = cmd_end + 1;
let replacement = execute_shell_command(&command, working_directory);
result.replace_range(start..full_end, &replacement);
}
result
}
fn execute_shell_command(command: &str, working_directory: &Path) -> String {
use std::process::Command;
let output = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(working_directory)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
String::new()
} else if trimmed.len() > SHELL_OUTPUT_MAX {
format!(
"{}\n[... truncated at {} bytes]",
&trimmed[..SHELL_OUTPUT_MAX],
SHELL_OUTPUT_MAX
)
} else {
trimmed.to_string()
}
}
Err(_) => format!("[Error: failed to execute `{}`]", command),
}
}