use std::sync::Arc;
use arc_swap::ArcSwap;
use camel_language_api::MinijinjaLimitsConfig;
use camel_language_minijinja::{LimitedWriter, ResolvedLimits, validate_autoescape_wrapper};
use crate::closure::ClosureSnapshot;
use crate::error::TemplateReloadError;
#[allow(dead_code)] #[derive(Debug)]
pub(crate) struct TemplateSet {
env: Arc<minijinja::Environment<'static>>,
entry: String,
}
#[allow(dead_code)] pub(crate) type SharedTemplates = Arc<ArcSwap<TemplateSet>>;
impl TemplateSet {
#[allow(dead_code)] pub fn empty() -> Self {
Self {
env: Arc::new(minijinja::Environment::new()),
entry: String::new(),
}
}
#[allow(dead_code)] pub fn compile(
snapshot: &ClosureSnapshot,
entry: &str,
render_limits: MinijinjaLimitsConfig,
) -> Result<Self, TemplateReloadError> {
let limits = ResolvedLimits::from_config(&render_limits);
let entry_file = snapshot.entries().get(entry).ok_or_else(|| {
TemplateReloadError::Compile(format!("entry {entry:?} not in closure snapshot"))
})?;
let entry_source = std::str::from_utf8(&entry_file.bytes).map_err(|e| {
TemplateReloadError::Compile(format!("entry {entry:?} is not utf-8: {e}"))
})?;
validate_autoescape_wrapper(entry_source)
.map_err(|e| TemplateReloadError::Compile(format!("autoescape wrapper: {e}")))?;
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.set_fuel(Some(limits.fuel));
env.set_recursion_limit(limits.max_recursion_depth as usize);
for (name, file) in snapshot.entries() {
let source = std::str::from_utf8(&file.bytes).map_err(|e| {
TemplateReloadError::Compile(format!("template {name:?} is not utf-8: {e}"))
})?;
env.add_template_owned(name.clone(), source.to_string())
.map_err(|e| TemplateReloadError::Compile(format!("compile {name:?}: {e}")))?;
}
Ok(Self {
env: Arc::new(env),
entry: entry.to_string(),
})
}
#[allow(dead_code)] pub async fn render_entry(
&self,
context: minijinja::Value,
render_limits: ResolvedLimits,
) -> Result<String, TemplateReloadError> {
let env = Arc::clone(&self.env);
let entry = self.entry.clone();
let max_output = render_limits.max_output_size as u64;
let timeout = std::time::Duration::from_millis(render_limits.execution_timeout_ms);
let join = tokio::task::spawn_blocking(move || -> Result<String, TemplateReloadError> {
let tmpl = env
.get_template(&entry)
.map_err(|e| TemplateReloadError::Compile(format!("template lookup: {e}")))?;
let mut buf = Vec::new();
let mut writer = LimitedWriter::new(&mut buf, max_output);
tmpl.render_captured_to(&context, &mut writer)
.map_err(|e| TemplateReloadError::Compile(format!("render: {e}")))?;
String::from_utf8(buf)
.map_err(|e| TemplateReloadError::Compile(format!("non-utf8 output: {e}")))
});
match tokio::time::timeout(timeout, join).await {
Ok(Ok(rendered)) => rendered,
Ok(Err(join_err)) => Err(TemplateReloadError::Compile(format!(
"minijinja spawn_blocking join: {join_err}"
))),
Err(_) => Err(TemplateReloadError::Compile(
"minijinja execution timeout".to_string(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use camel_language_api::MinijinjaLimitsConfig;
use std::collections::BTreeMap;
fn snapshot(entry: &str, source: &str) -> ClosureSnapshot {
ClosureSnapshot::from_single_entry(entry, source.as_bytes().to_vec())
}
fn ctx_name(k: &str) -> minijinja::Value {
let mut m: BTreeMap<&str, &str> = BTreeMap::new();
m.insert("name", k);
minijinja::Value::from_serialize(&m)
}
fn ctx_empty() -> minijinja::Value {
minijinja::Value::from_serialize(BTreeMap::<&str, &str>::new())
}
#[tokio::test]
async fn template_set_compile_and_render() {
let snap = snapshot(
"page.html",
r#"{% autoescape "none" %}Hi {{name}}{% endautoescape %}"#,
);
let set = TemplateSet::compile(&snap, "page.html", MinijinjaLimitsConfig::default())
.expect("compile");
let rendered = set
.render_entry(ctx_name("k"), ResolvedLimits::default())
.await
.expect("render");
assert_eq!(rendered, "Hi k");
}
#[tokio::test]
async fn template_set_compile_strict_undefined() {
let snap = snapshot(
"page.html",
r#"{% autoescape "none" %}{{nope}}{% endautoescape %}"#,
);
let set = TemplateSet::compile(&snap, "page.html", MinijinjaLimitsConfig::default())
.expect("compile");
let result = set
.render_entry(ctx_empty(), ResolvedLimits::default())
.await;
assert!(
matches!(result, Err(TemplateReloadError::Compile(_))),
"strict-undefined must surface as Compile, got: {result:?}"
);
}
#[test]
fn template_set_compile_requires_autoescape() {
let snap = snapshot("page.html", "Hi {{name}}");
let result = TemplateSet::compile(&snap, "page.html", MinijinjaLimitsConfig::default());
assert!(
matches!(result, Err(TemplateReloadError::Compile(_))),
"missing top-level autoescape wrapper must surface as Compile, got: {result:?}"
);
}
#[test]
fn template_set_compile_rejects_parse_error() {
let snap = snapshot(
"broken.html",
r#"{% autoescape "none" %}{% for x in items %}{{x}}{% endautoescape %}"#,
);
let result = TemplateSet::compile(&snap, "broken.html", MinijinjaLimitsConfig::default());
assert!(
matches!(result, Err(TemplateReloadError::Compile(_))),
"parse error must surface as TemplateReloadError::Compile (control-plane), \
not as a render-time ProcessorError, got: {result:?}"
);
}
#[tokio::test]
async fn template_set_renders_multi_entry_include() {
let snap = ClosureSnapshot::from_entries(vec![
(
"page.html",
br#"{% autoescape "none" %}HEAD[{% include "partial.html" %}]TAIL{% endautoescape %}"#,
),
(
"partial.html",
br#"{% autoescape "none" %}inner{% endautoescape %}"#,
),
]);
let set = TemplateSet::compile(&snap, "page.html", MinijinjaLimitsConfig::default())
.expect("compile must succeed — entry + include are both valid templates");
let rendered = set
.render_entry(ctx_empty(), ResolvedLimits::default())
.await
.expect("render");
assert_eq!(
rendered, "HEAD[inner]TAIL",
"include within a TemplateSet must resolve to the registered entry name; \
a regression here would also break F2b's recursion-bomb"
);
}
#[tokio::test]
async fn template_set_fails_closed_on_recursion_bomb() {
let snap = ClosureSnapshot::from_single_entry(
"bomb.html",
br#"{% autoescape "none" %}{% include "bomb.html" %}{% endautoescape %}"#.to_vec(),
);
let limits = MinijinjaLimitsConfig {
max_recursion_depth: Some(8),
..MinijinjaLimitsConfig::default()
};
let set = TemplateSet::compile(&snap, "bomb.html", limits)
.expect("compile must succeed — a self-include is syntactically valid");
let result = set
.render_entry(ctx_empty(), ResolvedLimits::default())
.await;
let err_msg = match &result {
Err(TemplateReloadError::Compile(msg)) => msg.clone(),
other => panic!(
"recursion bomb must fail-closed as TemplateReloadError::Compile \
(no hang, no stack overflow, no panic), got: {other:?}"
),
};
assert!(
err_msg.contains("render:"),
"recursion-bomb rejection must originate from the render path \
(render_entry prefix); got: {err_msg}"
);
}
#[test]
fn template_set_compile_rejects_non_utf8() {
let mut invalid = br#"{% autoescape "none" %}"#.to_vec();
invalid.extend_from_slice(&[0xFF, 0xFE]);
invalid.extend_from_slice(br#"{% endautoescape %}"#);
let snap = ClosureSnapshot::from_single_entry("bad.html", invalid);
let result = TemplateSet::compile(&snap, "bad.html", MinijinjaLimitsConfig::default());
assert!(
matches!(result, Err(TemplateReloadError::Compile(_))),
"non-UTF-8 source must be rejected at COMPILE (control-plane → \
TemplateReloadError::Compile), NOT silently accepted or deferred \
to render, got: {result:?}"
);
}
}