use crate::Notification;
#[cfg(test)]
mod tests;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateErrorKind {
Missing,
EnvNotSet,
EnvNotUnicode,
BadSyntax,
NotificationEncode,
}
#[derive(Debug, Clone)]
pub(crate) struct TemplateError {
pub raw_template: String,
pub field: String,
pub kind: TemplateErrorKind,
}
#[derive(Debug, Clone)]
pub(crate) struct CompiledTemplate {
raw: String,
segments: Vec<Segment>,
}
#[derive(Debug, Clone)]
enum Segment {
Literal(String),
NotificationPath(Vec<String>),
EnvVar(String),
}
pub(crate) fn compile(template: &str) -> Result<CompiledTemplate, TemplateError> {
let mut segments: Vec<Segment> = Vec::new();
let mut literal_buf = String::new();
let mut chars = template.char_indices().peekable();
while let Some((_, ch)) = chars.next() {
if ch == '\\' {
if let Some(&(_, next1)) = chars.peek() {
if next1 == '{' {
chars.next();
if let Some(&(_, next2)) = chars.peek() {
if next2 == '{' {
chars.next();
literal_buf.push_str("{{");
continue;
}
}
literal_buf.push('\\');
literal_buf.push('{');
continue;
}
}
literal_buf.push('\\');
continue;
}
if ch == '{' {
if let Some(&(_, '{')) = chars.peek() {
chars.next();
if !literal_buf.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut literal_buf)));
}
let segment = parse_expression(&mut chars, template)?;
segments.push(segment);
continue;
}
}
literal_buf.push(ch);
}
if !literal_buf.is_empty() {
segments.push(Segment::Literal(literal_buf));
}
Ok(CompiledTemplate {
raw: template.to_string(),
segments,
})
}
fn parse_expression(
chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
template: &str,
) -> Result<Segment, TemplateError> {
let mut expr = String::new();
let mut closed = false;
while let Some((_, ch)) = chars.next() {
if ch == '\\' {
return Err(TemplateError {
raw_template: template.to_string(),
field: "escape_inside_expr".to_string(),
kind: TemplateErrorKind::BadSyntax,
});
}
if ch == '}' {
if let Some(&(_, '}')) = chars.peek() {
chars.next();
closed = true;
break;
}
}
expr.push(ch);
}
if !closed {
return Err(TemplateError {
raw_template: template.to_string(),
field: "unclosed_braces".to_string(),
kind: TemplateErrorKind::BadSyntax,
});
}
let trimmed = expr.trim();
if let Some(rest) = trimmed.strip_prefix("notification") {
if rest.is_empty() {
return Ok(Segment::NotificationPath(Vec::new()));
}
if let Some(path_str) = rest.strip_prefix('.') {
let path: Vec<String> = path_str.split('.').map(str::to_string).collect();
if path.iter().any(String::is_empty) {
return Err(TemplateError {
raw_template: template.to_string(),
field: "empty_path_segment".to_string(),
kind: TemplateErrorKind::BadSyntax,
});
}
return Ok(Segment::NotificationPath(path));
}
return Err(TemplateError {
raw_template: template.to_string(),
field: "unknown_namespace".to_string(),
kind: TemplateErrorKind::BadSyntax,
});
}
if let Some(rest) = trimmed.strip_prefix("env.") {
if rest.is_empty() {
return Err(TemplateError {
raw_template: template.to_string(),
field: "empty_path_segment".to_string(),
kind: TemplateErrorKind::BadSyntax,
});
}
return Ok(Segment::EnvVar(rest.to_string()));
}
Err(TemplateError {
raw_template: template.to_string(),
field: "unknown_namespace".to_string(),
kind: TemplateErrorKind::BadSyntax,
})
}
impl CompiledTemplate {
pub(crate) fn render(&self, notification: &Notification) -> Result<String, TemplateError> {
self.render_with_env(notification, |name| match std::env::var(name) {
Ok(value) => Ok(value),
Err(std::env::VarError::NotPresent) => Err(TemplateErrorKind::EnvNotSet),
Err(std::env::VarError::NotUnicode(_)) => Err(TemplateErrorKind::EnvNotUnicode),
})
}
pub(crate) fn render_with_env<F>(
&self,
notification: &Notification,
env_resolver: F,
) -> Result<String, TemplateError>
where
F: Fn(&str) -> Result<String, TemplateErrorKind>,
{
let notification_json: serde_json::Value =
serde_json::to_value(notification).map_err(|_| TemplateError {
raw_template: self.raw.clone(),
field: "notification".to_string(),
kind: TemplateErrorKind::NotificationEncode,
})?;
let mut out = String::new();
for segment in &self.segments {
match segment {
Segment::Literal(s) => out.push_str(s),
Segment::NotificationPath(path) => {
let value =
walk_path(¬ification_json, path).ok_or_else(|| TemplateError {
raw_template: self.raw.clone(),
field: if path.is_empty() {
"notification".to_string()
} else {
format!("notification.{}", path.join("."))
},
kind: TemplateErrorKind::Missing,
})?;
out.push_str(&render_value(value));
}
Segment::EnvVar(name) => {
let value = env_resolver(name).map_err(|kind| TemplateError {
raw_template: self.raw.clone(),
field: name.clone(),
kind,
})?;
out.push_str(&value);
}
}
}
Ok(out)
}
#[cfg(test)]
pub(crate) fn raw(&self) -> &str {
&self.raw
}
}
fn walk_path<'a>(root: &'a serde_json::Value, path: &[String]) -> Option<&'a serde_json::Value> {
let mut cursor = root;
for segment in path {
cursor = cursor.get(segment)?;
}
Some(cursor)
}
fn render_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => "null".to_string(),
other => other.to_string(),
}
}
pub(crate) fn template_error_to_trigger_error(
e: TemplateError,
context: impl Into<String>,
) -> crate::watch::TriggerError {
let context_str = context.into();
tracing::debug!(
event.name = "client.trigger.template.render_failed",
context = %context_str,
raw_template = %e.raw_template,
field = %e.field,
kind = ?e.kind,
"template render failed (raw template suppressed from public error)"
);
crate::watch::TriggerError::Template {
context: context_str,
field: e.field,
kind: e.kind,
}
}