Skip to main content

netget/llm/
template_engine.rs

1//! Handlebars template engine for prompt management
2//!
3//! This module provides a centralized template engine that loads and manages
4//! Handlebars templates from embedded resources, supporting partials.
5//!
6//! Templates are embedded at compile time using include_dir, so the binary
7//! is self-contained and works in release builds.
8
9use anyhow::{Context, Result};
10use handlebars::Handlebars;
11use include_dir::{include_dir, Dir};
12use once_cell::sync::Lazy;
13use std::sync::{Arc, RwLock};
14use tracing::{debug, error, info, warn};
15
16/// Embedded template directory (compiled into binary)
17static EMBEDDED_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/prompts");
18
19/// Global template engine instance
20pub static TEMPLATE_ENGINE: Lazy<Arc<TemplateEngine>> = Lazy::new(|| {
21    info!("Initializing template engine from embedded templates");
22
23    let engine = TemplateEngine::from_embedded().unwrap_or_else(|e| {
24        warn!(
25            "Failed to initialize template engine from embedded templates: {}",
26            e
27        );
28        TemplateEngine::empty()
29    });
30
31    info!(
32        "Template engine initialized with {} templates",
33        engine.get_templates().len()
34    );
35    for template_name in engine.get_templates() {
36        debug!("  Loaded template: {}", template_name);
37    }
38
39    Arc::new(engine)
40});
41
42/// Template engine that manages Handlebars templates
43pub struct TemplateEngine {
44    handlebars: RwLock<Handlebars<'static>>,
45}
46
47impl TemplateEngine {
48    /// Create a template engine from embedded templates
49    pub fn from_embedded() -> Result<Self> {
50        let mut handlebars = Handlebars::new();
51
52        // Set strict mode to catch template errors
53        handlebars.set_strict_mode(true);
54
55        // Load all templates and partials from embedded directory
56        Self::load_embedded_templates(&mut handlebars, &EMBEDDED_TEMPLATES, "")?;
57
58        Ok(Self {
59            handlebars: RwLock::new(handlebars),
60        })
61    }
62
63    /// Create an empty template engine (fallback mode)
64    pub fn empty() -> Self {
65        Self {
66            handlebars: RwLock::new(Handlebars::new()),
67        }
68    }
69
70    /// Load all templates and partials from embedded directory
71    fn load_embedded_templates(
72        handlebars: &mut Handlebars<'static>,
73        dir: &Dir,
74        prefix: &str,
75    ) -> Result<()> {
76        // Process all files in this directory
77        for file in dir.files() {
78            let file_path = file.path();
79
80            // Only process .hbs files
81            if file_path.extension().and_then(|s| s.to_str()) != Some("hbs") {
82                continue;
83            }
84
85            // Get the full relative path
86            let file_name = file_path.file_name().unwrap().to_string_lossy().to_string();
87            let relative_path = if prefix.is_empty() {
88                file_name
89            } else {
90                format!("{}/{}", prefix, file_name)
91            };
92
93            // Convert to template name (without .hbs extension)
94            let template_name = relative_path
95                .trim_end_matches(".hbs")
96                .replace(std::path::MAIN_SEPARATOR, "/");
97
98            // Get template content
99            let content = file
100                .contents_utf8()
101                .context("Template file is not valid UTF-8")?;
102
103            // Check if this is a partial (in a "partials" directory)
104            let is_partial = file_path
105                .parent()
106                .and_then(|p| p.file_name())
107                .and_then(|n| n.to_str())
108                == Some("partials");
109
110            if is_partial {
111                // Register as partial (keep the full path name)
112                handlebars
113                    .register_partial(&template_name, content)
114                    .with_context(|| format!("Failed to register partial: {}", template_name))?;
115                debug!("Registered partial: {}", template_name);
116            } else {
117                // Register as template
118                handlebars
119                    .register_template_string(&template_name, content)
120                    .with_context(|| format!("Failed to register template: {}", template_name))?;
121                debug!("Registered template: {}", template_name);
122            }
123        }
124
125        // Recursively process subdirectories
126        for subdir in dir.dirs() {
127            let subdir_name = subdir
128                .path()
129                .file_name()
130                .unwrap()
131                .to_string_lossy()
132                .to_string();
133
134            let new_prefix = if prefix.is_empty() {
135                subdir_name
136            } else {
137                format!("{}/{}", prefix, subdir_name)
138            };
139
140            Self::load_embedded_templates(handlebars, subdir, &new_prefix)?;
141        }
142
143        Ok(())
144    }
145
146    /// Reload templates (no-op for embedded templates, kept for API compatibility)
147    pub fn reload(&self) -> Result<()> {
148        // Templates are embedded, so there's nothing to reload
149        info!("Reload requested but templates are embedded (no-op)");
150        Ok(())
151    }
152
153    /// Render a template with the given data
154    ///
155    /// The lock is taken with `unwrap_or_else(|e| e.into_inner())` rather than `unwrap()`.
156    /// This is shared infrastructure: a single panic while any reader held it would poison
157    /// the `RwLock` and make **every subsequent prompt build in the process** panic, turning
158    /// one transient fault into a permanent outage. The `Handlebars` registry behind it is
159    /// immutable after construction, so recovering the guard is safe.
160    pub fn render<T>(&self, template_name: &str, data: &T) -> Result<String>
161    where
162        T: serde::Serialize,
163    {
164        let handlebars = self.handlebars.read().unwrap_or_else(|e| e.into_inner());
165
166        // Check if template exists
167        if !handlebars.has_template(template_name) {
168            // Fallback: return empty string with warning
169            warn!(
170                "Template not found: {}. Using empty template.",
171                template_name
172            );
173            return Ok(String::new());
174        }
175
176        match handlebars.render(template_name, data) {
177            Ok(result) => Ok(result),
178            Err(e) => {
179                let available_partials: Vec<_> = handlebars
180                    .get_templates()
181                    .keys()
182                    .filter(|k| k.contains("partials"))
183                    .map(|k| k.to_string())
184                    .collect();
185                let error_msg = format!(
186                    "Template render failed\nTemplate: {}\nHandlebars Error: {}\nAvailable partials: {:#?}",
187                    template_name, e, available_partials
188                );
189                error!("{}", error_msg);
190                Err(anyhow::anyhow!(error_msg))
191            }
192        }
193    }
194
195    /// Render a template with raw JSON data
196    pub fn render_json(&self, template_name: &str, data: &serde_json::Value) -> Result<String> {
197        self.render(template_name, data)
198    }
199
200    /// Check if a template exists
201    pub fn has_template(&self, template_name: &str) -> bool {
202        let handlebars = self.handlebars.read().unwrap_or_else(|e| e.into_inner());
203        handlebars.has_template(template_name)
204    }
205
206    /// Get all registered template names
207    pub fn get_templates(&self) -> Vec<String> {
208        let handlebars = self.handlebars.read().unwrap_or_else(|e| e.into_inner());
209        handlebars
210            .get_templates()
211            .keys()
212            .map(|k| k.to_string())
213            .collect()
214    }
215}
216
217/// Helper to build template data
218pub struct TemplateDataBuilder {
219    data: serde_json::Map<String, serde_json::Value>,
220}
221
222impl TemplateDataBuilder {
223    /// Create a new template data builder
224    pub fn new() -> Self {
225        Self {
226            data: serde_json::Map::new(),
227        }
228    }
229
230    /// Add a field to the template data
231    pub fn field<T: serde::Serialize>(mut self, key: &str, value: T) -> Self {
232        self.data.insert(
233            key.to_string(),
234            serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
235        );
236        self
237    }
238
239    /// Add an optional field to the template data
240    pub fn optional_field<T: serde::Serialize>(mut self, key: &str, value: Option<T>) -> Self {
241        if let Some(v) = value {
242            self.data.insert(
243                key.to_string(),
244                serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
245            );
246        }
247        self
248    }
249
250    /// Build the template data as a JSON value
251    pub fn build(self) -> serde_json::Value {
252        serde_json::Value::Object(self.data)
253    }
254}
255
256impl Default for TemplateDataBuilder {
257    fn default() -> Self {
258        Self::new()
259    }
260}