Skip to main content

agentctl/skill/
vars.rs

1use anyhow::{bail, Result};
2use std::collections::HashMap;
3
4pub fn expand(template: &str, vars: &HashMap<String, String>) -> Result<String> {
5    let mut result = template.to_string();
6    // iterate to handle chained substitutions in a single pass
7    let mut i = 0;
8    let bytes = result.as_bytes().to_vec();
9    let mut out = String::with_capacity(result.len());
10    let s = std::str::from_utf8(&bytes).unwrap();
11    let chars: Vec<char> = s.chars().collect();
12    while i < chars.len() {
13        if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1] == '{' {
14            let start = i + 2;
15            if let Some(end) = chars[start..].iter().position(|&c| c == '}') {
16                let name: String = chars[start..start + end].iter().collect();
17                match vars.get(&name) {
18                    Some(val) => out.push_str(val),
19                    None => bail!("undefined variable: ${{{}}}", name),
20                }
21                i = start + end + 1;
22                continue;
23            }
24        }
25        out.push(chars[i]);
26        i += 1;
27    }
28    result = out;
29    Ok(result)
30}
31
32pub fn resolve(
33    skill_name: &str,
34    skill_path: &str,
35    custom: &HashMap<String, String>,
36) -> Result<HashMap<String, String>> {
37    let mut vars = HashMap::new();
38    vars.insert("SKILL_NAME".into(), skill_name.into());
39    vars.insert("SKILL_PATH".into(), skill_path.into());
40    vars.insert("HOME".into(), std::env::var("HOME").unwrap_or_default());
41    vars.insert(
42        "PLATFORM".into(),
43        if cfg!(target_os = "macos") {
44            "macos"
45        } else if cfg!(target_os = "windows") {
46            "windows"
47        } else {
48            "linux"
49        }
50        .into(),
51    );
52    // custom vars evaluated in declaration order (HashMap doesn't preserve order,
53    // caller must pass an IndexMap or pre-ordered vec — here we accept HashMap for simplicity)
54    for (k, v) in custom {
55        let expanded = expand(v, &vars)?;
56        vars.insert(k.clone(), expanded);
57    }
58    Ok(vars)
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn base_vars() -> HashMap<String, String> {
66        let mut m = HashMap::new();
67        m.insert("SKILL_PATH".into(), "/skills/my-skill".into());
68        m.insert("HOME".into(), "/home/user".into());
69        m
70    }
71
72    #[test]
73    fn expand_builtin() {
74        let vars = base_vars();
75        assert_eq!(
76            expand("${SKILL_PATH}/.venv", &vars).unwrap(),
77            "/skills/my-skill/.venv"
78        );
79    }
80
81    #[test]
82    fn expand_multiple() {
83        let vars = base_vars();
84        assert_eq!(
85            expand("${HOME}/${SKILL_PATH}", &vars).unwrap(),
86            "/home/user//skills/my-skill"
87        );
88    }
89
90    #[test]
91    fn expand_undefined_errors() {
92        let vars = base_vars();
93        assert!(expand("${UNDEFINED}", &vars).is_err());
94    }
95
96    #[test]
97    fn resolve_builtins_present() {
98        let vars = resolve("my-skill", "/skills/my-skill", &HashMap::new()).unwrap();
99        assert_eq!(vars["SKILL_NAME"], "my-skill");
100        assert_eq!(vars["SKILL_PATH"], "/skills/my-skill");
101        assert!(vars.contains_key("HOME"));
102        assert!(vars.contains_key("PLATFORM"));
103    }
104
105    #[test]
106    fn resolve_custom_expands_against_builtins() {
107        let mut custom = HashMap::new();
108        custom.insert("VENV".into(), "${SKILL_PATH}/.venv".into());
109        let vars = resolve("s", "/p", &custom).unwrap();
110        assert_eq!(vars["VENV"], "/p/.venv");
111    }
112
113    #[test]
114    fn resolve_custom_undefined_ref_errors() {
115        let mut custom = HashMap::new();
116        custom.insert("X".into(), "${UNDEFINED}".into());
117        assert!(resolve("s", "/p", &custom).is_err());
118    }
119}