Skip to main content

opendev_runtime/custom_commands/
expansion.rs

1//! Template expansion: placeholder substitution, shell execution, argument replacement.
2
3use std::collections::HashMap;
4
5use regex::Regex;
6
7use super::CustomCommand;
8
9/// Parse YAML frontmatter delimited by `---` lines.
10///
11/// Returns `(frontmatter_map, body)` where frontmatter_map contains
12/// key-value pairs and body is the content after the closing `---`.
13/// If no frontmatter is present, returns empty map and full content.
14pub(super) fn parse_frontmatter(content: &str) -> (HashMap<String, String>, String) {
15    let trimmed = content.trim_start();
16    if !trimmed.starts_with("---") {
17        return (HashMap::new(), content.to_string());
18    }
19
20    // Find the closing ---
21    let after_first = &trimmed[3..];
22    let after_first = after_first.trim_start_matches(['\r', '\n']);
23
24    if let Some(end_pos) = after_first.find("\n---") {
25        let fm_text = &after_first[..end_pos];
26        let body_start = end_pos + 4; // skip \n---
27        let body = after_first[body_start..].trim_start_matches(['\r', '\n']);
28
29        let mut map = HashMap::new();
30        for line in fm_text.lines() {
31            let line = line.trim();
32            if line.is_empty() || line.starts_with('#') {
33                continue;
34            }
35            if let Some((key, value)) = line.split_once(':') {
36                let key = key.trim().to_string();
37                let value = value
38                    .trim()
39                    .trim_matches('"')
40                    .trim_matches('\'')
41                    .to_string();
42                map.insert(key, value);
43            }
44        }
45
46        (map, body.to_string())
47    } else {
48        // No closing ---, treat as no frontmatter
49        (HashMap::new(), content.to_string())
50    }
51}
52
53/// Execute shell command substitutions in the template.
54///
55/// Replaces `!`cmd`` patterns with the stdout of the command.
56/// Failures are replaced with `[error: ...]` inline.
57pub(super) fn expand_shell_commands(content: &str) -> String {
58    let re = Regex::new(r"!`([^`]+)`").expect("valid regex");
59    re.replace_all(content, |caps: &regex::Captures| {
60        let cmd = &caps[1];
61        match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
62            Ok(output) if output.status.success() => {
63                String::from_utf8_lossy(&output.stdout).trim().to_string()
64            }
65            Ok(output) => {
66                let stderr = String::from_utf8_lossy(&output.stderr);
67                format!("[error: {cmd}: {}]", stderr.trim())
68            }
69            Err(e) => format!("[error: {cmd}: {e}]"),
70        }
71    })
72    .to_string()
73}
74
75impl CustomCommand {
76    /// Expand the template with the given arguments and optional context.
77    ///
78    /// - Executes `!`cmd`` shell substitutions.
79    /// - Replaces `$ARGUMENTS` with the full argument string.
80    /// - Replaces `$1`, `$2`, etc. with positional args (whitespace-split).
81    /// - Cleans up unreplaced `$N` positional patterns.
82    /// - Replaces context variables: `$KEY` → value.
83    pub fn expand(&self, arguments: &str, context: Option<&HashMap<String, String>>) -> String {
84        let mut result = self.template.replace("$ARGUMENTS", arguments);
85
86        // Replace positional $1, $2, etc.
87        let parts: Vec<&str> = if arguments.is_empty() {
88            Vec::new()
89        } else {
90            arguments.split_whitespace().collect()
91        };
92        for (i, part) in parts.iter().enumerate() {
93            let placeholder = format!("${}", i + 1);
94            result = result.replace(&placeholder, part);
95        }
96
97        // Clean up unreplaced positional args
98        let re = Regex::new(r"\$\d+").expect("valid regex");
99        result = re.replace_all(&result, "").to_string();
100
101        // Replace context variables
102        if let Some(ctx) = context {
103            for (key, value) in ctx {
104                let placeholder = format!("${}", key.to_uppercase());
105                result = result.replace(&placeholder, value);
106            }
107        }
108
109        // Execute shell command substitutions last
110        result = expand_shell_commands(&result);
111
112        result.trim().to_string()
113    }
114}
115
116#[cfg(test)]
117#[path = "expansion_tests.rs"]
118mod tests;