Skip to main content

robin/utils/
command_utils.rs

1use anyhow::{Result, anyhow};
2use regex::Regex;
3use serde_json;
4
5pub fn split_command_and_args(args: &[String]) -> (String, Vec<String>) {
6    if args.is_empty() {
7        return (String::new(), vec![]);
8    }
9
10    let mut command_parts = Vec::new();
11    let mut var_args = Vec::new();
12    let mut found_args = false;
13
14    for arg in args {
15        if arg.starts_with("--") {
16            found_args = true;
17            var_args.push(arg.clone());
18        } else if !found_args {
19            command_parts.push(arg.clone());
20        } else {
21            var_args.push(arg.clone());
22        }
23    }
24
25    (command_parts.join(" "), var_args)
26}
27
28pub fn replace_variables(script: &serde_json::Value, args: &[String]) -> Result<serde_json::Value> {
29    match script {
30        serde_json::Value::String(cmd) => {
31            let replaced = replace_variables_in_string(cmd, args)?;
32            Ok(serde_json::Value::String(replaced))
33        }
34        serde_json::Value::Array(commands) => {
35            let mut replaced_commands = Vec::new();
36            for cmd in commands {
37                if let Some(cmd_str) = cmd.as_str() {
38                    let replaced = replace_variables_in_string(cmd_str, args)?;
39                    replaced_commands.push(serde_json::Value::String(replaced));
40                } else {
41                    replaced_commands.push(cmd.clone());
42                }
43            }
44            Ok(serde_json::Value::Array(replaced_commands))
45        }
46        _ => Ok(script.clone()),
47    }
48}
49
50/// Replaces Docker Compose-style environment-variable defaults.
51///
52/// Supports two forms (defaults only):
53///   - `${VAR:-default}` -> use `$VAR` if set and non-empty, otherwise `default`.
54///   - `${VAR-default}`  -> use `$VAR` if set (even if empty), otherwise `default`.
55///
56/// Bare `${VAR}` (no operator) is intentionally left untouched so it is still
57/// expanded by the shell at run time.
58fn replace_env_variables_in_string(script: &str) -> String {
59    let env_regex = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(:?-)([^}]*)\}").unwrap();
60    let mut result = script.to_string();
61
62    for capture in env_regex.captures_iter(script) {
63        let full_match = &capture[0];
64        let var_name = &capture[1];
65        let operator = &capture[2];
66        let default = &capture[3];
67
68        let env_value = std::env::var(var_name).ok();
69        let value = match operator {
70            ":-" => match env_value {
71                Some(v) if !v.is_empty() => v,
72                _ => default.to_string(),
73            },
74            // "-": use the env value whenever the variable is set, even if empty.
75            _ => env_value.unwrap_or_else(|| default.to_string()),
76        };
77
78        result = result.replace(full_match, &value);
79    }
80
81    result
82}
83
84fn replace_variables_in_string(script: &str, args: &[String]) -> Result<String> {
85    let script = replace_env_variables_in_string(script);
86    let var_regex = Regex::new(r"\{\{(\w+)(?:=([^}]+|\[[^\]]+\]))?\}\}").unwrap();
87    let mut result = script.clone();
88
89    for capture in var_regex.captures_iter(&script) {
90        let full_match = &capture[0];
91        let var_name = &capture[1];
92        let default_or_enum = capture.get(2).map(|m| m.as_str()).unwrap_or("");
93        let var_pattern = format!("--{}=", var_name);
94
95        if default_or_enum.starts_with('[') && default_or_enum.ends_with(']') {
96            let allowed_values: Vec<&str> = default_or_enum[1..default_or_enum.len() - 1]
97                .split(',')
98                .map(|s| s.trim())
99                .collect();
100
101            let value = args
102                .iter()
103                .find(|arg| arg.starts_with(&var_pattern))
104                .map(|arg| arg.trim_start_matches(&var_pattern))
105                .ok_or_else(|| anyhow!("Missing required variable: {}", var_name))?;
106
107            if !allowed_values.contains(&value) {
108                return Err(anyhow!(
109                    "Value '{}' for {} must be one of: {}",
110                    value,
111                    var_name,
112                    allowed_values.join(", ")
113                ));
114            }
115
116            result = result.replace(full_match, value);
117        } else {
118            let value = args
119                .iter()
120                .find(|arg| arg.starts_with(&var_pattern))
121                .map(|arg| arg.trim_start_matches(&var_pattern))
122                .or(if default_or_enum.is_empty() {
123                    None
124                } else {
125                    Some(default_or_enum)
126                })
127                .ok_or_else(|| anyhow!("Missing required variable: {}", var_name))?;
128
129            result = result.replace(full_match, value);
130        }
131    }
132
133    Ok(result)
134}