Skip to main content

eoka_runner/config/
params.rs

1use crate::{Error, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5/// Runtime parameters passed to a config.
6#[derive(Debug, Clone, Default)]
7pub struct Params {
8    values: HashMap<String, String>,
9}
10
11impl Params {
12    /// Create empty params.
13    pub fn new() -> Self {
14        Self::default()
15    }
16
17    /// Set a parameter value.
18    pub fn set(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
19        self.values.insert(key.into(), value.into());
20        self
21    }
22
23    /// Get a parameter value.
24    pub fn get(&self, key: &str) -> Option<&str> {
25        self.values.get(key).map(|s| s.as_str())
26    }
27
28    /// Check if empty.
29    pub fn is_empty(&self) -> bool {
30        self.values.is_empty()
31    }
32
33    /// Parse from CLI args like "key=value".
34    pub fn from_args(args: &[String]) -> Result<Self> {
35        let mut params = Self::new();
36        for arg in args {
37            let (key, value) = arg.split_once('=').ok_or_else(|| {
38                Error::Config(format!("invalid param '{}', expected key=value", arg))
39            })?;
40            params.values.insert(key.to_string(), value.to_string());
41        }
42        Ok(params)
43    }
44}
45
46/// Parameter definition in config.
47#[derive(Debug, Clone, Deserialize)]
48pub struct ParamDef {
49    /// Whether this parameter is required.
50    #[serde(default)]
51    pub required: bool,
52
53    /// Default value if not provided.
54    pub default: Option<String>,
55
56    /// Description for documentation.
57    pub description: Option<String>,
58}
59
60/// Substitute `${var}` patterns in a string.
61pub fn substitute(
62    template: &str,
63    params: &Params,
64    defs: &HashMap<String, ParamDef>,
65) -> Result<String> {
66    let mut result = template.to_string();
67    let mut start = 0;
68
69    while let Some(var_start) = result[start..].find("${") {
70        let var_start = start + var_start;
71        let Some(var_end) = result[var_start..].find('}') else {
72            break;
73        };
74        let var_end = var_start + var_end;
75
76        let var_name = &result[var_start + 2..var_end];
77
78        let value = if let Some(v) = params.get(var_name) {
79            v.to_string()
80        } else if let Some(def) = defs.get(var_name) {
81            if let Some(ref default) = def.default {
82                default.clone()
83            } else if def.required {
84                return Err(Error::Config(format!(
85                    "missing required parameter: {}",
86                    var_name
87                )));
88            } else {
89                // Optional param with no default - leave empty
90                String::new()
91            }
92        } else {
93            // Unknown param - leave as-is for now (might be env var or other substitution)
94            start = var_end + 1;
95            continue;
96        };
97
98        result.replace_range(var_start..=var_end, &value);
99        start = var_start + value.len();
100    }
101
102    Ok(result)
103}
104
105/// Recursively substitute params in a serde_yaml::Value.
106pub fn substitute_value(
107    value: &mut serde_yaml::Value,
108    params: &Params,
109    defs: &HashMap<String, ParamDef>,
110) -> Result<()> {
111    match value {
112        serde_yaml::Value::String(s) => {
113            *s = substitute(s, params, defs)?;
114        }
115        serde_yaml::Value::Mapping(map) => {
116            for (_, v) in map.iter_mut() {
117                substitute_value(v, params, defs)?;
118            }
119        }
120        serde_yaml::Value::Sequence(seq) => {
121            for v in seq.iter_mut() {
122                substitute_value(v, params, defs)?;
123            }
124        }
125        _ => {}
126    }
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_substitute_simple() {
136        let params = Params::new().set("name", "world");
137        let defs = HashMap::new();
138        let result = substitute("hello ${name}!", &params, &defs).unwrap();
139        assert_eq!(result, "hello world!");
140    }
141
142    #[test]
143    fn test_substitute_multiple() {
144        let params = Params::new().set("a", "1").set("b", "2");
145        let defs = HashMap::new();
146        let result = substitute("${a} + ${b} = 3", &params, &defs).unwrap();
147        assert_eq!(result, "1 + 2 = 3");
148    }
149
150    #[test]
151    fn test_substitute_default() {
152        let params = Params::new();
153        let mut defs = HashMap::new();
154        defs.insert(
155            "name".to_string(),
156            ParamDef {
157                required: false,
158                default: Some("default".to_string()),
159                description: None,
160            },
161        );
162        let result = substitute("hello ${name}", &params, &defs).unwrap();
163        assert_eq!(result, "hello default");
164    }
165
166    #[test]
167    fn test_substitute_required_missing() {
168        let params = Params::new();
169        let mut defs = HashMap::new();
170        defs.insert(
171            "name".to_string(),
172            ParamDef {
173                required: true,
174                default: None,
175                description: None,
176            },
177        );
178        let result = substitute("hello ${name}", &params, &defs);
179        assert!(result.is_err());
180    }
181
182    #[test]
183    fn test_params_from_args() {
184        let args = vec!["user=alice".to_string(), "pass=secret".to_string()];
185        let params = Params::from_args(&args).unwrap();
186        assert_eq!(params.get("user"), Some("alice"));
187        assert_eq!(params.get("pass"), Some("secret"));
188    }
189}