Skip to main content

cordis_include/
interpolate.rs

1//! `${{ env.NAME }}` string templates — the safe stand-in for upstream's
2//! `!js` interpolation. No expression evaluation.
3
4use crate::error::{IncludeError, Result};
5use crate::node::Node;
6
7/// Expand every `${{ env.NAME }}` template inside the string using process
8/// environment variables.
9pub fn interpolate_str(input: &str) -> Result<String> {
10    interpolate_str_with(input, &resolve_env)
11}
12
13/// Recursively expand templates in every string of the value tree using
14/// process environment variables.
15pub fn interpolate_node(node: &Node) -> Result<Node> {
16    interpolate_node_with(node, &resolve_env)
17}
18
19/// Recursively expand templates using a caller-supplied lookup (see
20/// [`interpolate_str_with`]).
21pub fn interpolate_node_with(
22    node: &Node,
23    lookup: &dyn Fn(&str) -> Result<Option<String>>,
24) -> Result<Node> {
25    match node {
26        Node::String(value) => interpolate_str_with(value, lookup).map(Node::String),
27        Node::Array(items) => items
28            .iter()
29            .map(|item| interpolate_node_with(item, lookup))
30            .collect::<Result<Vec<_>>>()
31            .map(Node::Array),
32        Node::Object(map) => {
33            let mut expanded = crate::node::NodeMap::new();
34            for (key, value) in map {
35                expanded.insert(key.clone(), interpolate_node_with(value, lookup)?);
36            }
37            Ok(Node::Object(expanded))
38        }
39        other => Ok(other.clone()),
40    }
41}
42
43/// Expand templates using a caller-supplied lookup, which maps an
44/// expression like `env.NAME` to its value. `Ok(None)` means the
45/// expression is well-formed but has no value; an `Err` rejects the
46/// expression outright.
47pub fn interpolate_str_with(
48    input: &str,
49    lookup: &dyn Fn(&str) -> Result<Option<String>>,
50) -> Result<String> {
51    let mut out = String::with_capacity(input.len());
52    let mut rest = input;
53    while let Some(start) = rest.find("${{") {
54        out.push_str(&rest[..start]);
55        let tail = &rest[start + 3..];
56        let end = tail.find("}}").ok_or_else(|| IncludeError::Unterminated {
57            input: input.to_owned(),
58        })?;
59        let expression = tail[..end].trim();
60        if expression.is_empty() {
61            return Err(IncludeError::UnknownExpression {
62                expression: expression.to_owned(),
63            });
64        }
65        let value = lookup(expression)?.ok_or_else(|| IncludeError::MissingEnv {
66            expression: expression.to_owned(),
67        })?;
68        out.push_str(&value);
69        rest = &tail[end + 2..];
70    }
71    out.push_str(rest);
72    Ok(out)
73}
74
75/// The default lookup: `env.NAME` reads `NAME` from the environment.
76fn resolve_env(expression: &str) -> Result<Option<String>> {
77    match expression.strip_prefix("env.") {
78        Some(name) if !name.is_empty() => Ok(std::env::var(name).ok()),
79        _ => Err(IncludeError::UnknownExpression {
80            expression: expression.to_owned(),
81        }),
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn lookup(expression: &str) -> Result<Option<String>> {
90        match expression {
91            "env.HOST" => Ok(Some("example.org".to_owned())),
92            "env.MISSING" => Ok(None),
93            other => Err(IncludeError::UnknownExpression {
94                expression: other.to_owned(),
95            }),
96        }
97    }
98
99    #[test]
100    fn substitutes_single_and_multiple_templates() {
101        let text = interpolate_str_with("host=${{ env.HOST }}:${{ env.HOST }}", &lookup).unwrap();
102        assert_eq!(text, "host=example.org:example.org");
103    }
104
105    #[test]
106    fn plain_strings_pass_through() {
107        assert_eq!(
108            interpolate_str_with("no templates", &lookup).unwrap(),
109            "no templates"
110        );
111        assert_eq!(interpolate_str_with("", &lookup).unwrap(), "");
112    }
113
114    #[test]
115    fn missing_and_unknown_expressions_error() {
116        assert!(matches!(
117            interpolate_str_with("${{ env.MISSING }}", &lookup),
118            Err(IncludeError::MissingEnv { .. })
119        ));
120        assert!(matches!(
121            interpolate_str_with("${{ shell.rm }}", &lookup),
122            Err(IncludeError::UnknownExpression { .. })
123        ));
124        assert!(matches!(
125            interpolate_str_with("${{ env.MISSING", &lookup),
126            Err(IncludeError::Unterminated { .. })
127        ));
128    }
129
130    #[test]
131    fn nodes_recurse() {
132        let node: Node = serde_yaml_ng::from_str("url: http://${{ env.HOST }}/api\nn: 1").unwrap();
133        let expanded = interpolate_node_with(&node, &lookup).unwrap();
134        let map = expanded.as_object().unwrap();
135        assert_eq!(map["url"].as_str(), Some("http://example.org/api"));
136        assert_eq!(map["n"], Node::Int(1));
137    }
138}