use std::collections::BTreeMap;
use base64::Engine;
use minijinja::{Environment, Value};
use crate::error::{Error, Result};
fn forgejo_protocol(value: &str) -> String {
match value {
"starttls" => "smtp+starttls".to_string(),
"force_tls" => "smtps".to_string(),
_ => "smtp".to_string(),
}
}
fn authelia_scheme(value: &str) -> String {
match value {
"starttls" => "submission".to_string(),
"force_tls" => "submissions".to_string(),
_ => "smtp".to_string(),
}
}
fn smtp_no_tls(value: &str) -> String {
match value {
"off" => "true".to_string(),
_ => "false".to_string(),
}
}
pub fn render(template_str: &str, context: &BTreeMap<String, String>) -> Result<String> {
let mut env = Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.add_filter("forgejo_protocol", |value: &str| -> String {
forgejo_protocol(value)
});
env.add_filter("authelia_scheme", |value: &str| -> String {
authelia_scheme(value)
});
env.add_filter("smtp_no_tls", |value: &str| -> String {
smtp_no_tls(value)
});
env.add_filter("b64encode", |value: &str| -> String {
base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
});
env.add_template("tpl", template_str)
.map_err(|e| Error::Template(format!("invalid template: {e}")))?;
let tpl = env
.get_template("tpl")
.map_err(|e| Error::Template(e.to_string()))?;
let ctx = build_nested_context(context);
tpl.render(&ctx)
.map_err(|e| Error::Template(format!("render failed: {e}")))
}
fn build_nested_context(flat: &BTreeMap<String, String>) -> Value {
let mut root: BTreeMap<String, Value> = BTreeMap::new();
for (key, val) in flat {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 1 {
root.insert(key.clone(), Value::from(val.as_str()));
} else {
insert_nested(&mut root, &parts, val);
}
}
Value::from_object(root)
}
fn insert_nested(map: &mut BTreeMap<String, Value>, parts: &[&str], val: &str) {
if parts.len() == 1 {
map.insert(parts[0].to_string(), Value::from(val));
return;
}
let key = parts[0].to_string();
let existing = map.remove(&key);
let mut child: BTreeMap<String, Value> = match existing {
Some(v) => {
let mut rebuilt = BTreeMap::new();
if let Ok(iter) = v.try_iter() {
for k in iter {
let k_str = k.to_string();
if let Ok(attr) = v.get_attr(&k_str) {
rebuilt.insert(k_str, attr);
}
}
}
rebuilt
}
None => BTreeMap::new(),
};
insert_nested(&mut child, &parts[1..], val);
map.insert(key, Value::from_object(child));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_four_level_nesting() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut ctx = BTreeMap::new();
ctx.insert("services.postgres.port.tcp".into(), "5432".into());
ctx.insert(
"services.postgres.env.POSTGRES_PASSWORD".into(),
"secret123".into(),
);
ctx.insert("services.postgres.domain".into(), "pg.example.com".into());
let result = render(
"postgresql://user:{{ services.postgres.env.POSTGRES_PASSWORD }}@127.0.0.1:{{ services.postgres.port.tcp }}",
&ctx,
)?;
assert_eq!(result, "postgresql://user:secret123@127.0.0.1:5432");
Ok(())
}
#[test]
fn default_filter_on_missing_key() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut ctx = BTreeMap::new();
ctx.insert("service.name".into(), "whoami".into());
let result = render("{{ service.domain | default('localhost') }}", &ctx)?;
assert_eq!(result, "localhost");
Ok(())
}
#[test]
fn concat_with_default_filter() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut ctx = BTreeMap::new();
ctx.insert("service.port".into(), "10001".into());
let tpl = "{{ service.external_authority | default('127.0.0.1:' ~ service.port) }}";
assert_eq!(render(tpl, &ctx)?, "127.0.0.1:10001");
ctx.insert(
"service.external_authority".into(),
"seafile.example.com".into(),
);
assert_eq!(render(tpl, &ctx)?, "seafile.example.com");
Ok(())
}
#[test]
fn strict_mode_rejects_undefined_top_level() {
let ctx = BTreeMap::new();
let err = render("{{ bogus_top_level }}", &ctx);
assert!(
err.is_err(),
"expected strict mode to error on an undefined top-level variable"
);
}
#[test]
fn strict_mode_rejects_typo_without_default() {
let mut ctx = BTreeMap::new();
ctx.insert("smtp.host".into(), "mail.example.com".into());
let err = render("{{ smtp.hoist }}", &ctx);
assert!(
err.is_err(),
"expected strict mode to error on a typo'd attribute"
);
}
#[test]
fn forgejo_protocol_filter() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut ctx = BTreeMap::new();
ctx.insert("smtp.security".into(), "starttls".into());
let result = render("{{ smtp.security | forgejo_protocol }}", &ctx)?;
assert_eq!(result, "smtp+starttls");
ctx.insert("smtp.security".into(), "force_tls".into());
let result = render("{{ smtp.security | forgejo_protocol }}", &ctx)?;
assert_eq!(result, "smtps");
ctx.insert("smtp.security".into(), "off".into());
let result = render("{{ smtp.security | forgejo_protocol }}", &ctx)?;
assert_eq!(result, "smtp");
Ok(())
}
#[test]
fn authelia_scheme_filter() -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut ctx = BTreeMap::new();
ctx.insert("smtp.security".into(), "starttls".into());
let result = render("{{ smtp.security | authelia_scheme }}", &ctx)?;
assert_eq!(result, "submission");
ctx.insert("smtp.security".into(), "force_tls".into());
let result = render("{{ smtp.security | authelia_scheme }}", &ctx)?;
assert_eq!(result, "submissions");
ctx.insert("smtp.security".into(), "off".into());
let result = render("{{ smtp.security | authelia_scheme }}", &ctx)?;
assert_eq!(result, "smtp");
Ok(())
}
}