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#[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#[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: ®ex::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 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 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 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}