Skip to main content

ghost_core/recipe/
engine.rs

1// Recipe engine: {{param}} substitution and step preparation.
2// Actual step execution is handled by apps/ghost which dispatches to tool handlers.
3
4use std::collections::HashMap;
5use anyhow::Result;
6use regex::Regex;
7
8use super::types::{Recipe, RecipeStep};
9
10/// Substitute {{param}} placeholders in a string using provided values.
11pub fn substitute(template: &str, params: &HashMap<String, String>) -> String {
12    // Lazily compiled regex for {{param_name}}
13    let re = Regex::new(r"\{\{(\w+)\}\}").expect("valid regex");
14    re.replace_all(template, |caps: &regex::Captures| {
15        let key = &caps[1];
16        params.get(key).cloned().unwrap_or_else(|| format!("{{{{{key}}}}}"))
17    })
18    .to_string()
19}
20
21/// Substitute all string fields in a RecipeStep using the given params.
22pub fn substitute_step(step: &RecipeStep, params: &HashMap<String, String>) -> RecipeStep {
23    RecipeStep {
24        id: step.id,
25        action: substitute(&step.action, params),
26        target: step.target.as_ref().map(|t| super::types::Locator {
27            query:      t.query.as_deref().map(|s| substitute(s, params)),
28            role:       t.role.as_deref().map(|s| substitute(s, params)),
29            dom_id:     t.dom_id.as_deref().map(|s| substitute(s, params)),
30            dom_class:  t.dom_class.as_deref().map(|s| substitute(s, params)),
31            identifier: t.identifier.as_deref().map(|s| substitute(s, params)),
32            app:        t.app.as_deref().map(|s| substitute(s, params)),
33        }),
34        params: step.params.as_ref().map(|p| {
35            p.iter().map(|(k, v)| (k.clone(), substitute(v, params))).collect()
36        }),
37        wait_after: step.wait_after.clone(),
38        note: step.note.clone(),
39        on_failure: step.on_failure.clone(),
40    }
41}
42
43/// Validate that all required params are provided.
44pub fn validate_params(recipe: &Recipe, provided: &HashMap<String, String>) -> Result<()> {
45    let Some(param_defs) = &recipe.params else {
46        return Ok(());
47    };
48    let mut missing = vec![];
49    for (name, def) in param_defs {
50        if def.required.unwrap_or(false) && !provided.contains_key(name.as_str()) {
51            missing.push(name.as_str());
52        }
53    }
54    if !missing.is_empty() {
55        anyhow::bail!("Missing required params: {}", missing.join(", "));
56    }
57    Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn test_substitute_basic() {
66        let mut params = HashMap::new();
67        params.insert("recipient".to_string(), "alice@example.com".to_string());
68        params.insert("subject".to_string(), "Hello".to_string());
69        let s = substitute("Send email to {{recipient}} with subject {{subject}}", &params);
70        assert_eq!(s, "Send email to alice@example.com with subject Hello");
71    }
72
73    #[test]
74    fn test_substitute_missing_key() {
75        let params = HashMap::new();
76        let s = substitute("Hello {{name}}", &params);
77        assert_eq!(s, "Hello {{name}}");
78    }
79}