cordis_include/
interpolate.rs1use crate::error::{IncludeError, Result};
5use crate::node::Node;
6
7pub fn interpolate_str(input: &str) -> Result<String> {
10 interpolate_str_with(input, &resolve_env)
11}
12
13pub fn interpolate_node(node: &Node) -> Result<Node> {
16 interpolate_node_with(node, &resolve_env)
17}
18
19pub 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
43pub 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
75fn 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}