rskit_util/template/
dynamic.rs1use std::collections::BTreeSet;
12
13use super::error::TemplateError;
14
15#[derive(Debug, Clone, Eq, PartialEq)]
17enum Part {
18 Literal(String),
20 Variable(String),
22}
23
24#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct DynamicTemplate {
27 parts: Vec<Part>,
28}
29
30impl DynamicTemplate {
31 #[must_use]
34 pub fn parse(template: &str) -> Self {
35 let mut parts = Vec::new();
36 let mut rest = template;
37 while let Some(start) = rest.find("{{") {
38 let (before, after) = (&rest[..start], &rest[start + 2..]);
39 if !before.is_empty() {
40 parts.push(Part::Literal(before.to_owned()));
41 }
42 let Some(end) = after.find("}}") else {
43 parts.push(Part::Literal("{{".to_owned()));
44 rest = after;
45 continue;
46 };
47 let name = after[..end].trim();
48 if is_valid_name(name) {
49 parts.push(Part::Variable(name.to_owned()));
50 } else {
51 parts.push(Part::Literal(format!("{{{{{}}}}}", &after[..end])));
52 }
53 rest = &after[end + 2..];
54 }
55 if !rest.is_empty() {
56 parts.push(Part::Literal(rest.to_owned()));
57 }
58 Self { parts }
59 }
60
61 #[must_use]
63 pub fn variables(&self) -> BTreeSet<String> {
64 self.parts
65 .iter()
66 .filter_map(|part| match part {
67 Part::Variable(name) => Some(name.clone()),
68 Part::Literal(_) => None,
69 })
70 .collect()
71 }
72
73 pub fn render<F>(&self, mut lookup: F) -> Result<String, TemplateError>
76 where
77 F: FnMut(&str) -> Option<String>,
78 {
79 let mut out = String::new();
80 for part in &self.parts {
81 match part {
82 Part::Literal(text) => out.push_str(text),
83 Part::Variable(name) => {
84 let value =
85 lookup(name).ok_or_else(|| TemplateError::MissingVariable(name.clone()))?;
86 out.push_str(&value);
87 }
88 }
89 }
90 Ok(out)
91 }
92}
93
94fn is_valid_name(name: &str) -> bool {
95 !name.is_empty()
96 && name
97 .chars()
98 .all(|c| c == '_' || c == '-' || c.is_ascii_alphanumeric())
99}
100
101#[cfg(test)]
102mod tests {
103 use super::{DynamicTemplate, TemplateError};
104
105 #[test]
106 fn renders_dynamic_variables_with_optional_whitespace() {
107 let tpl = DynamicTemplate::parse("hi {{ name }}, {{count}} items");
108 let out = tpl
109 .render(|n| match n {
110 "name" => Some("ada".to_owned()),
111 "count" => Some("3".to_owned()),
112 _ => None,
113 })
114 .expect("renders");
115 assert_eq!(out, "hi ada, 3 items");
116 }
117
118 #[test]
119 fn lists_referenced_variables() {
120 let tpl = DynamicTemplate::parse("{{a}}{{ b }}{{a}}");
121 let vars = tpl.variables();
122 assert!(vars.contains("a") && vars.contains("b") && vars.len() == 2);
123 }
124
125 #[test]
126 fn missing_variable_errors() {
127 let tpl = DynamicTemplate::parse("{{x}}");
128 let err = tpl.render(|_| None).expect_err("missing");
129 assert_eq!(err, TemplateError::MissingVariable("x".to_owned()));
130 }
131
132 #[test]
133 fn malformed_placeholders_are_literal() {
134 let tpl = DynamicTemplate::parse("a {{ bad name }} b {{");
135 let out = tpl.render(|_| None).expect("no valid vars");
136 assert_eq!(out, "a {{ bad name }} b {{");
137 }
138}