Skip to main content

lade_sdk/
resolve.rs

1use std::collections::{BTreeSet, HashMap, VecDeque};
2
3use anyhow::{Result, bail};
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7static VAR: Lazy<Regex> = Lazy::new(|| Regex::new(r"\$(?:\{(\w+)\}|(\w+))").unwrap());
8static TEMPLATE_VAR: Lazy<Regex> = Lazy::new(|| {
9    Regex::new(r"\$(?:\{(\.?[A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))").unwrap()
10});
11
12/// A source value compiled once for dependency discovery and interpolation.
13///
14/// Only braced references form graph edges. This intentionally leaves `$NAME`
15/// intact for shell-backed providers, where the shell owns that syntax.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Template {
18    parts: Vec<TemplatePart>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22enum TemplatePart {
23    Literal(String),
24    Reference {
25        name: String,
26        braced: bool,
27        private: bool,
28    },
29}
30
31impl Template {
32    pub fn parse(value: &str) -> Self {
33        let mut parts = Vec::new();
34        let mut offset = 0;
35        for captures in TEMPLATE_VAR.captures_iter(value) {
36            let matched = captures.get(0).expect("regex match");
37            if matched.start() > offset {
38                parts.push(TemplatePart::Literal(
39                    value[offset..matched.start()].to_string(),
40                ));
41            }
42            let braced = captures.get(1).is_some();
43            let raw_name = captures
44                .get(1)
45                .or_else(|| captures.get(2))
46                .expect("reference name")
47                .as_str();
48            parts.push(TemplatePart::Reference {
49                name: raw_name.trim_start_matches('.').to_string(),
50                braced,
51                private: raw_name.starts_with('.'),
52            });
53            offset = matched.end();
54        }
55        if offset < value.len() || parts.is_empty() {
56            parts.push(TemplatePart::Literal(value[offset..].to_string()));
57        }
58        Self { parts }
59    }
60
61    pub fn dependencies(&self) -> impl Iterator<Item = &str> {
62        self.parts.iter().filter_map(|part| match part {
63            TemplatePart::Reference { name, .. } => Some(name.as_str()),
64            TemplatePart::Literal(_) => None,
65        })
66    }
67
68    pub fn render(&self, values: &HashMap<String, String>) -> Result<String> {
69        let mut output = String::new();
70        for part in &self.parts {
71            match part {
72                TemplatePart::Literal(value) => output.push_str(value),
73                TemplatePart::Reference { name, braced, .. } => {
74                    if let Some(value) = values.get(name) {
75                        output.push_str(value);
76                    } else if *braced {
77                        bail!("missing dependency '{name}'");
78                    } else {
79                        output.push('$');
80                        output.push_str(name);
81                    }
82                }
83            }
84        }
85        Ok(output)
86    }
87
88    pub fn shell_source(&self) -> String {
89        let mut output = String::new();
90        for part in &self.parts {
91            match part {
92                TemplatePart::Literal(value) => output.push_str(value),
93                TemplatePart::Reference {
94                    name,
95                    braced,
96                    private,
97                } => {
98                    if *braced || *private {
99                        output.push_str("${");
100                        output.push_str(name);
101                        output.push('}');
102                    } else {
103                        output.push('$');
104                        output.push_str(name);
105                    }
106                }
107            }
108        }
109        output
110    }
111}
112
113/// A validated dependency graph. The execution owner chooses how to run ready
114/// nodes, while this type keeps planning deterministic and I/O-free.
115#[derive(Debug, Clone)]
116pub struct Dag {
117    templates: HashMap<String, Template>,
118    dependents: HashMap<String, Vec<String>>,
119    indegrees: HashMap<String, usize>,
120}
121
122impl Dag {
123    pub fn new(templates: HashMap<String, Template>) -> Result<Self> {
124        let mut dependents = HashMap::<String, Vec<String>>::new();
125        let mut indegrees = HashMap::<String, usize>::new();
126        for (name, template) in &templates {
127            let mut dependencies = BTreeSet::new();
128            for part in &template.parts {
129                let TemplatePart::Reference { name, braced, .. } = part else {
130                    continue;
131                };
132                if !templates.contains_key(name) {
133                    if !braced {
134                        continue;
135                    }
136                    bail!("binding '{name}' references missing dependency '{name}'");
137                }
138                dependencies.insert(name.clone());
139            }
140            indegrees.insert(name.clone(), dependencies.len());
141            for dependency in dependencies {
142                dependents.entry(dependency).or_default().push(name.clone());
143            }
144        }
145        for values in dependents.values_mut() {
146            values.sort();
147        }
148        let graph = Self {
149            templates,
150            dependents,
151            indegrees,
152        };
153        graph.validate_acyclic()?;
154        Ok(graph)
155    }
156
157    pub fn template(&self, name: &str) -> Option<&Template> {
158        self.templates.get(name)
159    }
160
161    pub fn initial_ready(&self) -> Vec<String> {
162        let mut ready = self
163            .indegrees
164            .iter()
165            .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
166            .collect::<Vec<_>>();
167        ready.sort();
168        ready
169    }
170
171    pub fn dependents(&self, name: &str) -> &[String] {
172        self.dependents
173            .get(name)
174            .map(Vec::as_slice)
175            .unwrap_or_default()
176    }
177
178    pub fn indegrees(&self) -> HashMap<String, usize> {
179        self.indegrees.clone()
180    }
181
182    fn validate_acyclic(&self) -> Result<()> {
183        let mut indegrees = self.indegrees();
184        let mut ready = self.initial_ready().into_iter().collect::<VecDeque<_>>();
185        let mut visited = 0;
186        while let Some(name) = ready.pop_front() {
187            visited += 1;
188            for dependent in self.dependents(&name) {
189                let degree = indegrees
190                    .get_mut(dependent)
191                    .expect("dependent must have an indegree");
192                *degree -= 1;
193                if *degree == 0 {
194                    ready.push_back(dependent.clone());
195                }
196            }
197        }
198        if visited == self.templates.len() {
199            return Ok(());
200        }
201        let mut cycle = indegrees
202            .into_iter()
203            .filter_map(|(name, degree)| (degree > 0).then_some(name))
204            .collect::<Vec<_>>();
205        cycle.sort();
206        bail!("cyclic binding dependencies: {}", cycle.join(", "))
207    }
208}
209
210pub fn resolve(
211    kvs: &HashMap<String, String>,
212    existing_vars: &HashMap<String, String>,
213) -> Result<HashMap<String, String>> {
214    kvs.iter()
215        .map(|(key, value)| resolve_one(value, existing_vars).map(|v| (key.clone(), v)))
216        .collect()
217}
218
219pub fn resolve_one(value: &str, existing_vars: &HashMap<String, String>) -> Result<String> {
220    Ok(VAR
221        .replace_all(value, |caps: &regex::Captures| {
222            let name = caps
223                .get(1)
224                .or_else(|| caps.get(2))
225                .map(|m| m.as_str())
226                .unwrap_or("");
227            existing_vars.get(name).cloned().unwrap_or_default()
228        })
229        .into_owned())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::collections::HashMap;
236
237    #[test]
238    fn test_resolve_one_no_vars() {
239        assert_eq!(
240            resolve_one("hello world", &HashMap::new()).unwrap(),
241            "hello world"
242        );
243    }
244
245    #[test]
246    fn test_resolve_one_dollar_var() {
247        let vars = HashMap::from([("FOO".to_string(), "bar".to_string())]);
248        assert_eq!(resolve_one("prefix_$FOO", &vars).unwrap(), "prefix_bar");
249    }
250
251    #[test]
252    fn test_resolve_one_braces_var() {
253        let vars = HashMap::from([("FOO".to_string(), "bar".to_string())]);
254        assert_eq!(
255            resolve_one("prefix_${FOO}_suffix", &vars).unwrap(),
256            "prefix_bar_suffix"
257        );
258    }
259
260    #[test]
261    fn test_resolve_one_multiple_vars() {
262        let vars = HashMap::from([
263            ("A".to_string(), "hello".to_string()),
264            ("B".to_string(), "world".to_string()),
265        ]);
266        assert_eq!(resolve_one("$A $B", &vars).unwrap(), "hello world");
267    }
268
269    #[test]
270    fn test_resolve_one_unknown_var_empty() {
271        assert_eq!(
272            resolve_one("val/$MISSING", &HashMap::new()).unwrap(),
273            "val/"
274        );
275    }
276
277    #[test]
278    fn test_resolve_one_adjacent_braced_vars() {
279        let vars = HashMap::from([
280            ("A".to_string(), "foo".to_string()),
281            ("B".to_string(), "bar".to_string()),
282        ]);
283        assert_eq!(resolve_one("${A}${B}", &vars).unwrap(), "foobar");
284    }
285
286    #[test]
287    fn test_resolve_one_word_boundary_without_braces() {
288        let vars = HashMap::from([("FOO".to_string(), "bar".to_string())]);
289        assert_eq!(resolve_one("$FOO_SUFFIX", &vars).unwrap(), "");
290    }
291
292    #[test]
293    fn test_resolve_one_no_double_expansion() {
294        // A value that itself looks like a variable reference must not be re-expanded.
295        let vars = HashMap::from([
296            ("A".into(), "$B".into()),
297            ("B".into(), "should_not_appear".into()),
298        ]);
299        assert_eq!(resolve_one("$A", &vars).unwrap(), "$B");
300    }
301
302    #[test]
303    fn test_resolve_one_unmatched_open_brace_is_literal() {
304        // "${FOO" has no closing brace — must not be treated as a variable reference.
305        let vars = HashMap::from([("FOO".to_string(), "bar".to_string())]);
306        assert_eq!(resolve_one("${FOO", &vars).unwrap(), "${FOO");
307    }
308
309    #[test]
310    fn test_resolve_one_trailing_brace_not_consumed() {
311        // "$FOO}" — only "$FOO" is a variable reference; the "}" is literal.
312        let vars = HashMap::from([("FOO".to_string(), "bar".to_string())]);
313        assert_eq!(resolve_one("$FOO}", &vars).unwrap(), "bar}");
314    }
315
316    #[test]
317    fn test_resolve_batch() {
318        let kvs = HashMap::from([
319            ("URL".to_string(), "https://$HOST/api".to_string()),
320            ("STATIC".to_string(), "literal".to_string()),
321        ]);
322        let vars = HashMap::from([("HOST".to_string(), "example.com".to_string())]);
323        let result = resolve(&kvs, &vars).unwrap();
324        assert_eq!(result.get("URL").unwrap(), "https://example.com/api");
325        assert_eq!(result.get("STATIC").unwrap(), "literal");
326    }
327
328    #[test]
329    fn template_tracks_braced_and_bare_references() {
330        let template = Template::parse("sh://echo $HOME ${TOKEN}");
331        assert_eq!(
332            template.dependencies().collect::<Vec<_>>(),
333            vec!["HOME", "TOKEN"]
334        );
335        assert_eq!(
336            template
337                .render(&HashMap::from([("TOKEN".into(), "value".into())]))
338                .unwrap(),
339            "sh://echo $HOME value"
340        );
341    }
342
343    #[test]
344    fn dag_allows_unknown_bare_shell_variables() {
345        Dag::new(HashMap::from([(
346            "AUTHORIZATION".into(),
347            Template::parse("sh://echo $HOME"),
348        )]))
349        .unwrap();
350    }
351
352    #[test]
353    fn template_normalizes_private_reference_for_shell() {
354        let template = Template::parse("sh://echo ${.TOKEN}");
355        assert_eq!(template.dependencies().collect::<Vec<_>>(), vec!["TOKEN"]);
356        assert_eq!(template.shell_source(), "sh://echo ${TOKEN}");
357    }
358
359    #[test]
360    fn dag_rejects_missing_dependency() {
361        let err = Dag::new(HashMap::from([(
362            "HEADER".into(),
363            Template::parse("Bearer ${TOKEN}"),
364        )]))
365        .unwrap_err();
366        assert!(err.to_string().contains("missing dependency 'TOKEN'"));
367    }
368
369    #[test]
370    fn dag_rejects_cycles() {
371        let err = Dag::new(HashMap::from([
372            ("A".into(), Template::parse("${B}")),
373            ("B".into(), Template::parse("${A}")),
374        ]))
375        .unwrap_err();
376        assert!(err.to_string().contains("cyclic binding dependencies"));
377    }
378}