use crate::context::{Context, is_known_placeholder};
use crate::escape::escape;
use crate::model::{ParseMode, Status};
pub const DEFAULT_TEMPLATE: &str = "{{.StatusEmoji}} *{{.Workflow}}* on `{{.Repo}}`\n\
Status: {{.Status}}\n\
Branch: {{.Branch}} @ {{.ShortSha}}\n\
Actor: {{.Actor}}\n\
[Open run]({{.RunUrl}})";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedTemplate {
pub body: String,
pub source: TemplateSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateSource {
VerbatimMessage,
PerStatus(Status),
Generic,
Default,
}
impl TemplateSource {
pub fn as_str(self) -> &'static str {
match self {
TemplateSource::VerbatimMessage => "message",
TemplateSource::PerStatus(Status::Success) => "template_success",
TemplateSource::PerStatus(Status::Failure) => "template_failure",
TemplateSource::PerStatus(Status::Cancelled) => "template_cancelled",
TemplateSource::PerStatus(Status::Skipped) => "template_skipped",
TemplateSource::Generic => "message_template",
TemplateSource::Default => "default",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TemplateInputs {
pub message: Option<String>,
pub message_template: Option<String>,
pub success: Option<String>,
pub failure: Option<String>,
pub cancelled: Option<String>,
pub skipped: Option<String>,
}
impl TemplateInputs {
pub fn select(&self, status: Status) -> SelectedTemplate {
let nonempty = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).cloned();
if let Some(message) = nonempty(&self.message) {
return SelectedTemplate { body: message, source: TemplateSource::VerbatimMessage };
}
let per_status = match status {
Status::Success => &self.success,
Status::Failure => &self.failure,
Status::Cancelled => &self.cancelled,
Status::Skipped => &self.skipped,
};
if let Some(body) = nonempty(per_status) {
return SelectedTemplate { body, source: TemplateSource::PerStatus(status) };
}
if let Some(body) = nonempty(&self.message_template) {
return SelectedTemplate { body, source: TemplateSource::Generic };
}
SelectedTemplate { body: DEFAULT_TEMPLATE.to_string(), source: TemplateSource::Default }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rendered {
pub text: String,
pub unknown: Vec<String>,
}
pub fn render(template: &str, ctx: &Context, mode: ParseMode) -> Rendered {
let bytes = template.as_bytes();
let mut out = String::with_capacity(template.len());
let mut unknown: Vec<String> = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'{' && template[i..].starts_with("{{.") {
if let Some((name, end)) = scan_placeholder(template, i) {
match ctx.get(name) {
Some(value) => out.push_str(&escape(value, mode)),
None => {
if !unknown.iter().any(|n| n == name) {
unknown.push(name.to_string());
}
}
}
i = end;
continue;
}
}
let ch = template[i..].chars().next().expect("index is on a char boundary");
out.push(ch);
i += ch.len_utf8();
}
Rendered { text: out, unknown }
}
fn scan_placeholder(s: &str, start: usize) -> Option<(&str, usize)> {
let rest = &s[start + 3..];
let mut end = 0usize;
for (idx, ch) in rest.char_indices() {
let ok = if idx == 0 { ch.is_ascii_alphabetic() } else { ch.is_ascii_alphanumeric() };
if ok {
end = idx + ch.len_utf8();
} else {
break;
}
}
if end == 0 {
return None;
}
if !rest[end..].starts_with("}}") {
return None;
}
Some((&rest[..end], start + 3 + end + 2))
}
pub fn placeholder_is_known(name: &str) -> bool {
is_known_placeholder(name)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Context {
Context::from_pairs([
("Repo", "jtprogru/notiflow"),
("Workflow", "CI"),
("Actor", "jtprogru"),
("Status", "success"),
("StatusEmoji", "✅"),
("Branch", "main"),
("ShortSha", "abc1234"),
("RunUrl", "https://github.com/jtprogru/notiflow/actions/runs/1"),
])
}
#[test]
fn substitutes_known_placeholders() {
let r = render("repo={{.Repo}} actor={{.Actor}}", &ctx(), ParseMode::None);
assert_eq!(r.text, "repo=jtprogru/notiflow actor=jtprogru");
assert!(r.unknown.is_empty());
}
#[test]
fn escapes_values_but_not_the_template_body() {
let c = Context::from_pairs([("Branch", "fix/a.b-c")]);
let r = render("*Branch:* {{.Branch}}", &c, ParseMode::MarkdownV2);
assert_eq!(r.text, "*Branch:* fix/a\\.b\\-c");
}
#[test]
fn substituted_values_are_not_rescanned() {
let c = Context::from_pairs([("Workflow", "{{.Actor}}"), ("Actor", "attacker")]);
let r = render("{{.Workflow}}", &c, ParseMode::None);
assert_eq!(r.text, "{{.Actor}}");
}
#[test]
fn unknown_placeholders_are_dropped_and_reported_once() {
let r = render("a{{.Nope}}b{{.Nope}}c", &ctx(), ParseMode::None);
assert_eq!(r.text, "abc");
assert_eq!(r.unknown, vec!["Nope"]);
}
#[test]
fn malformed_braces_stay_literal() {
for input in ["{{.}}", "{{.1Bad}}", "{{.Repo}", "{{ .Repo }}", "{{Repo}}"] {
let r = render(input, &ctx(), ParseMode::None);
assert_eq!(r.text, input, "input {input:?}");
}
}
#[test]
fn multibyte_text_survives() {
let r = render("статус: {{.Status}} 🎉", &ctx(), ParseMode::None);
assert_eq!(r.text, "статус: success 🎉");
}
#[test]
fn precedence_message_beats_everything() {
let inputs = TemplateInputs {
message: Some("verbatim".into()),
message_template: Some("generic".into()),
success: Some("per-status".into()),
..Default::default()
};
let sel = inputs.select(Status::Success);
assert_eq!(sel.body, "verbatim");
assert_eq!(sel.source, TemplateSource::VerbatimMessage);
}
#[test]
fn precedence_per_status_beats_generic() {
let inputs = TemplateInputs {
message_template: Some("generic".into()),
failure: Some("boom".into()),
..Default::default()
};
assert_eq!(inputs.select(Status::Failure).body, "boom");
assert_eq!(inputs.select(Status::Success).body, "generic");
}
#[test]
fn empty_strings_do_not_count_as_set() {
let inputs = TemplateInputs {
message: Some(String::new()),
message_template: Some(String::new()),
..Default::default()
};
assert_eq!(inputs.select(Status::Success).source, TemplateSource::Default);
}
#[test]
fn default_template_has_no_trailing_newline() {
assert!(!DEFAULT_TEMPLATE.ends_with('\n'));
}
}