use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use camel_api::{CamelError, StepLifecycle, StepShutdownReason};
use camel_component_api::RuntimeObservability;
use camel_component_api::template_reload::{
RegistrationGuard, TemplateReloadRegistry, TemplateReloadTarget,
};
use camel_language_api::MinijinjaLimitsConfig;
use crate::closure;
use crate::config::ResolvedExternalTemplateLimits;
use crate::error::TemplateReloadError;
use crate::path_util;
use crate::reload::ReloadHandler;
use crate::template_set::{SharedTemplates, TemplateSet};
pub(crate) struct StartupBuildHandle {
pub(crate) shared: SharedTemplates,
pub(crate) entry_abs_path: PathBuf,
pub(crate) render_limits: MinijinjaLimitsConfig,
pub(crate) limits: ResolvedExternalTemplateLimits,
pub(crate) rt: Option<Arc<dyn RuntimeObservability>>,
pub(crate) route_id: String,
pub(crate) handler: Mutex<Option<Arc<ReloadHandler>>>,
pub(crate) guard: Mutex<Option<RegistrationGuard>>,
}
impl std::fmt::Debug for StartupBuildHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StartupBuildHandle")
.field("entry_abs_path", &self.entry_abs_path)
.field("route_id", &self.route_id)
.field("rt_set", &self.rt.is_some())
.field(
"handler_set",
&self.handler.lock().expect("poisoned").is_some(), )
.field("guard_set", &self.guard.lock().expect("poisoned").is_some()) .finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl StepLifecycle for StartupBuildHandle {
fn name(&self) -> &'static str {
"template-startup"
}
async fn start(&self) -> Result<(), CamelError> {
let parent = self.entry_abs_path.parent().ok_or_else(|| {
CamelError::from(TemplateReloadError::PathEscape(
"entry has no parent directory".into(),
))
})?;
let entry_name = self.entry_abs_path.file_name().ok_or_else(|| {
CamelError::from(TemplateReloadError::PathEscape(
"entry has no file name".into(),
))
})?;
let entry_str = entry_name.to_string_lossy();
let (root, _root_identity) = path_util::open_root(parent).map_err(CamelError::from)?;
let root = Arc::new(root);
let snapshot = closure::build_snapshot(&self.entry_abs_path, root.as_ref(), self.limits)
.map_err(CamelError::from)?;
let set = TemplateSet::compile(&snapshot, &entry_str, self.render_limits.clone())
.map_err(CamelError::from)?;
self.shared.store(Arc::new(set));
let handler = Arc::new(ReloadHandler {
shared: Arc::clone(&self.shared),
entry_abs_path: self.entry_abs_path.clone(),
render_limits: self.render_limits.clone(),
limits: self.limits,
generation: Mutex::new(0),
root: Arc::clone(&root),
rt: self.rt.clone(),
route_id: self.route_id.clone(),
});
*self.handler.lock().expect("handler cell poisoned") = Some(Arc::clone(&handler));
let guard = TemplateReloadRegistry::global()
.register(Arc::clone(&handler) as Arc<dyn TemplateReloadTarget>);
*self.guard.lock().expect("guard cell poisoned") = Some(guard);
Ok(())
}
async fn shutdown(&self, _reason: StepShutdownReason) -> Result<(), CamelError> {
*self.guard.lock().expect("guard cell poisoned") = None; Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::fs;
use arc_swap::ArcSwap;
use camel_language_minijinja::ResolvedLimits;
use crate::config::ResolvedExternalTemplateLimits;
use crate::template_set::TemplateSet;
fn default_limits() -> ResolvedExternalTemplateLimits {
ResolvedExternalTemplateLimits {
max_total_source_bytes: 1024 * 1024,
max_include_count: 64,
max_include_depth: 16,
max_template_size: 1024 * 1024,
reload_timeout_ms: 5000,
}
}
fn ctx_name(k: &str) -> minijinja::Value {
let mut m: BTreeMap<&str, &str> = BTreeMap::new();
m.insert("name", k);
minijinja::Value::from_serialize(&m)
}
#[tokio::test]
async fn startup_build_compiles_and_seeds() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("page.html");
fs::write(
&entry,
r#"{% autoescape "none" %}Hi {{name}}{% endautoescape %}"#,
)
.expect("write page");
let shared: SharedTemplates = Arc::new(ArcSwap::from_pointee(TemplateSet::empty()));
let handle = StartupBuildHandle {
shared: Arc::clone(&shared),
entry_abs_path: entry,
render_limits: MinijinjaLimitsConfig::default(),
limits: default_limits(),
rt: None,
route_id: "test-route".to_string(),
handler: Mutex::new(None),
guard: Mutex::new(None),
};
handle.start().await.expect("start must succeed");
let set = shared.load_full();
let rendered = set
.render_entry(ctx_name("k"), ResolvedLimits::default())
.await
.expect("render entry");
assert_eq!(rendered, "Hi k");
}
#[tokio::test]
async fn startup_build_fails_closed_on_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("does_not_exist.html");
let shared: SharedTemplates = Arc::new(ArcSwap::from_pointee(TemplateSet::empty()));
let handle = StartupBuildHandle {
shared: Arc::clone(&shared),
entry_abs_path: entry,
render_limits: MinijinjaLimitsConfig::default(),
limits: default_limits(),
rt: None,
route_id: "test-route".to_string(),
handler: Mutex::new(None),
guard: Mutex::new(None),
};
let result = handle.start().await;
assert!(
matches!(result, Err(CamelError::TemplateReload(_))),
"missing entry must surface as CamelError::TemplateReload, got: {result:?}"
);
let set = shared.load_full();
let rendered = set
.render_entry(ctx_name("k"), ResolvedLimits::default())
.await;
assert!(
rendered.is_err(),
"shared must remain the empty seed after a failed start"
);
}
fn make_handle(route: &str) -> (tempfile::TempDir, StartupBuildHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("page.html");
fs::write(
&entry,
r#"{% autoescape "none" %}Hi {{name}}{% endautoescape %}"#,
)
.expect("write page");
let shared: SharedTemplates = Arc::new(ArcSwap::from_pointee(TemplateSet::empty()));
let handle = StartupBuildHandle {
shared: Arc::clone(&shared),
entry_abs_path: entry,
render_limits: MinijinjaLimitsConfig::default(),
limits: default_limits(),
rt: None,
route_id: route.to_string(),
handler: Mutex::new(None),
guard: Mutex::new(None),
};
(dir, handle)
}
#[tokio::test]
async fn start_registers_shutdown_unregisters() {
use camel_component_api::template_reload::TemplateReloadRegistry;
let route = "test-start-reg-shutdown-unreg-5.3";
let (_dir, handle) = make_handle(route);
let reg = TemplateReloadRegistry::global();
assert_eq!(reg.find_all(route).len(), 0);
handle.start().await.expect("start");
assert_eq!(
reg.find_all(route).len(),
1,
"start() must register the handler"
);
handle
.shutdown(StepShutdownReason::RouteStop)
.await
.expect("shutdown");
assert_eq!(
reg.find_all(route).len(),
0,
"shutdown() must drop the guard and unregister"
);
}
#[tokio::test]
async fn restart_re_registers_new_guard() {
use camel_component_api::template_reload::TemplateReloadRegistry;
let route = "test-restart-re-registers-new-guard-5.3";
let (_dir, handle) = make_handle(route);
let reg = TemplateReloadRegistry::global();
handle.start().await.expect("start #1");
assert_eq!(reg.find_all(route).len(), 1);
handle
.shutdown(StepShutdownReason::RouteStop)
.await
.expect("shutdown #1");
assert_eq!(reg.find_all(route).len(), 0);
handle.start().await.expect("start #2");
assert_eq!(
reg.find_all(route).len(),
1,
"restart must re-register with a fresh guard"
);
handle
.shutdown(StepShutdownReason::HotSwap)
.await
.expect("shutdown #2");
assert_eq!(reg.find_all(route).len(), 0);
}
}