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 anyhow::Result;
5use regex::Regex;
6use std::collections::HashMap;
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
17            .get(key)
18            .cloned()
19            .unwrap_or_else(|| format!("{{{{{key}}}}}"))
20    })
21    .to_string()
22}
23
24/// Substitute all string fields in a RecipeStep using the given params.
25pub fn substitute_step(step: &RecipeStep, params: &HashMap<String, String>) -> RecipeStep {
26    RecipeStep {
27        id: step.id,
28        action: substitute(&step.action, params),
29        target: step.target.as_ref().map(|t| super::types::Locator {
30            query: t.query.as_deref().map(|s| substitute(s, params)),
31            role: t.role.as_deref().map(|s| substitute(s, params)),
32            dom_id: t.dom_id.as_deref().map(|s| substitute(s, params)),
33            dom_class: t.dom_class.as_deref().map(|s| substitute(s, params)),
34            identifier: t.identifier.as_deref().map(|s| substitute(s, params)),
35            app: t.app.as_deref().map(|s| substitute(s, params)),
36        }),
37        params: step.params.as_ref().map(|p| {
38            p.iter()
39                .map(|(k, v)| (k.clone(), substitute(v, params)))
40                .collect()
41        }),
42        wait_after: step.wait_after.clone(),
43        note: step.note.clone(),
44        on_failure: step.on_failure.clone(),
45    }
46}
47
48/// Validate that all required params are provided.
49pub fn validate_params(recipe: &Recipe, provided: &HashMap<String, String>) -> Result<()> {
50    let Some(param_defs) = &recipe.params else {
51        return Ok(());
52    };
53    let mut missing = vec![];
54    for (name, def) in param_defs {
55        if def.required.unwrap_or(false) && !provided.contains_key(name.as_str()) {
56            missing.push(name.as_str());
57        }
58    }
59    if !missing.is_empty() {
60        anyhow::bail!("Missing required params: {}", missing.join(", "));
61    }
62    Ok(())
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn test_substitute_basic() {
71        let mut params = HashMap::new();
72        params.insert("recipient".to_string(), "alice@example.com".to_string());
73        params.insert("subject".to_string(), "Hello".to_string());
74        let s = substitute(
75            "Send email to {{recipient}} with subject {{subject}}",
76            &params,
77        );
78        assert_eq!(s, "Send email to alice@example.com with subject Hello");
79    }
80
81    #[test]
82    fn test_substitute_missing_key() {
83        let params = HashMap::new();
84        let s = substitute("Hello {{name}}", &params);
85        assert_eq!(s, "Hello {{name}}");
86    }
87}