Skip to main content

gba_pm/
manager.rs

1//! Prompt manager for loading and rendering Jinja templates.
2//!
3//! This module provides the [`PromptManager`] struct for managing prompt templates
4//! using the MiniJinja template engine.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use gba_pm::PromptManager;
10//! use serde_json::json;
11//!
12//! let mut manager = PromptManager::new();
13//!
14//! // Add a template from a string
15//! manager.add("greeting", "Hello, {{ name }}!").unwrap();
16//!
17//! // Render the template
18//! let result = manager.render("greeting", json!({"name": "World"})).unwrap();
19//! assert_eq!(result, "Hello, World!");
20//! ```
21
22use std::fs;
23use std::path::Path;
24
25use minijinja::Environment;
26use serde::Serialize;
27use tracing::{debug, instrument};
28
29use crate::error::{PromptError, Result};
30
31/// Template file extensions that will be loaded from directories.
32const TEMPLATE_EXTENSIONS: &[&str] = &["j2", "jinja", "jinja2"];
33
34/// Prompt manager for loading and rendering Jinja templates.
35///
36/// The manager uses MiniJinja as the template engine and supports loading
37/// templates from directories or adding them programmatically.
38#[derive(Debug)]
39#[non_exhaustive]
40pub struct PromptManager<'a> {
41    /// The MiniJinja environment containing all templates.
42    env: Environment<'a>,
43}
44
45impl Default for PromptManager<'_> {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl<'a> PromptManager<'a> {
52    /// Create a new prompt manager with an empty template environment.
53    ///
54    /// # Example
55    ///
56    /// ```
57    /// use gba_pm::PromptManager;
58    ///
59    /// let manager = PromptManager::new();
60    /// assert!(manager.names().is_empty());
61    /// ```
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            env: Environment::new(),
66        }
67    }
68
69    /// Load templates from a directory.
70    ///
71    /// This method recursively scans the given directory for template files
72    /// with extensions `.j2`, `.jinja`, or `.jinja2`. Template names are derived
73    /// from the relative path with the extension stripped.
74    ///
75    /// # Arguments
76    ///
77    /// * `path` - Path to the directory containing templates
78    ///
79    /// # Returns
80    ///
81    /// Returns `&mut Self` to allow method chaining.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if:
86    /// - The directory cannot be read
87    /// - A template file cannot be read
88    /// - A template has invalid syntax
89    ///
90    /// # Example
91    ///
92    /// ```no_run
93    /// use gba_pm::PromptManager;
94    ///
95    /// let mut manager = PromptManager::new();
96    /// manager.load_dir("./prompts")?;
97    /// # Ok::<(), gba_pm::PromptError>(())
98    /// ```
99    #[instrument(skip(self), fields(path = %path.as_ref().display()))]
100    pub fn load_dir(&mut self, path: impl AsRef<Path>) -> Result<&mut Self> {
101        let path = path.as_ref();
102        self.load_dir_recursive(path, path)?;
103        Ok(self)
104    }
105
106    /// Recursively load templates from a directory.
107    fn load_dir_recursive(&mut self, base: &Path, current: &Path) -> Result<()> {
108        let entries = fs::read_dir(current).map_err(|e| PromptError::io_error(current, e))?;
109
110        for entry in entries {
111            let entry = entry.map_err(|e| PromptError::io_error(current, e))?;
112            let path = entry.path();
113
114            if path.is_dir() {
115                self.load_dir_recursive(base, &path)?;
116            } else if let Some(ext) = path.extension() {
117                let ext_str = ext.to_string_lossy();
118                if TEMPLATE_EXTENSIONS.contains(&ext_str.as_ref()) {
119                    self.load_template_file(base, &path)?;
120                }
121            }
122        }
123
124        Ok(())
125    }
126
127    /// Load a single template file.
128    fn load_template_file(&mut self, base: &Path, path: &Path) -> Result<()> {
129        let content = fs::read_to_string(path).map_err(|e| PromptError::io_error(path, e))?;
130
131        // Compute template name from relative path, stripping extension
132        let relative = path
133            .strip_prefix(base)
134            .map_err(|e| PromptError::io_error(path, std::io::Error::other(e)))?;
135
136        let name = relative.with_extension("").to_string_lossy().to_string();
137        // Normalize path separators to forward slashes for cross-platform consistency
138        let name = name.replace('\\', "/");
139
140        debug!(template = %name, "loading template");
141        self.env.add_template_owned(name, content)?;
142
143        Ok(())
144    }
145
146    /// Add a template from a string.
147    ///
148    /// # Arguments
149    ///
150    /// * `name` - The name to register the template under
151    /// * `content` - The template content
152    ///
153    /// # Returns
154    ///
155    /// Returns `&mut Self` to allow method chaining.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the template has invalid syntax.
160    ///
161    /// # Example
162    ///
163    /// ```
164    /// use gba_pm::PromptManager;
165    ///
166    /// let mut manager = PromptManager::new();
167    /// manager.add("hello", "Hello, {{ name }}!")?;
168    /// # Ok::<(), gba_pm::PromptError>(())
169    /// ```
170    pub fn add(&mut self, name: &str, content: &str) -> Result<&mut Self> {
171        debug!(template = %name, "adding template");
172        self.env
173            .add_template_owned(name.to_string(), content.to_string())?;
174        Ok(self)
175    }
176
177    /// Render a template with the given context.
178    ///
179    /// # Arguments
180    ///
181    /// * `name` - The name of the template to render
182    /// * `ctx` - The context data to pass to the template
183    ///
184    /// # Returns
185    ///
186    /// Returns the rendered template as a string.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if:
191    /// - The template is not found
192    /// - The template cannot be rendered with the given context
193    ///
194    /// # Example
195    ///
196    /// ```
197    /// use gba_pm::PromptManager;
198    /// use serde_json::json;
199    ///
200    /// let mut manager = PromptManager::new();
201    /// manager.add("greeting", "Hello, {{ name }}!")?;
202    ///
203    /// let result = manager.render("greeting", json!({"name": "World"}))?;
204    /// assert_eq!(result, "Hello, World!");
205    /// # Ok::<(), gba_pm::PromptError>(())
206    /// ```
207    #[instrument(skip(self, ctx), fields(template = %name))]
208    pub fn render(&self, name: &str, ctx: impl Serialize) -> Result<String> {
209        let template = self
210            .env
211            .get_template(name)
212            .map_err(|_| PromptError::TemplateNotFound(name.to_string()))?;
213
214        let result = template.render(ctx)?;
215        Ok(result)
216    }
217
218    /// Render a string template directly without registering it.
219    ///
220    /// This is useful for one-off template rendering where you don't need
221    /// to store the template for later use.
222    ///
223    /// # Arguments
224    ///
225    /// * `template` - The template string to render
226    /// * `ctx` - The context data to pass to the template
227    ///
228    /// # Returns
229    ///
230    /// Returns the rendered template as a string.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the template cannot be parsed or rendered.
235    ///
236    /// # Example
237    ///
238    /// ```
239    /// use gba_pm::PromptManager;
240    /// use serde_json::json;
241    ///
242    /// let manager = PromptManager::new();
243    /// let result = manager.render_str("Hello, {{ name }}!", json!({"name": "World"}))?;
244    /// assert_eq!(result, "Hello, World!");
245    /// # Ok::<(), gba_pm::PromptError>(())
246    /// ```
247    pub fn render_str(&self, template: &str, ctx: impl Serialize) -> Result<String> {
248        let result = self.env.render_str(template, ctx)?;
249        Ok(result)
250    }
251
252    /// List all registered template names.
253    ///
254    /// # Returns
255    ///
256    /// Returns a vector of template names.
257    ///
258    /// # Example
259    ///
260    /// ```
261    /// use gba_pm::PromptManager;
262    ///
263    /// let mut manager = PromptManager::new();
264    /// manager.add("hello", "Hello!")?;
265    /// manager.add("goodbye", "Goodbye!")?;
266    ///
267    /// let names = manager.names();
268    /// assert!(names.contains(&"hello"));
269    /// assert!(names.contains(&"goodbye"));
270    /// # Ok::<(), gba_pm::PromptError>(())
271    /// ```
272    #[must_use]
273    pub fn names(&self) -> Vec<&str> {
274        self.env.templates().map(|(name, _)| name).collect()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use serde_json::json;
281    use std::fs;
282    use tempfile::TempDir;
283
284    use super::*;
285
286    #[test]
287    fn test_should_create_empty_manager() {
288        let manager = PromptManager::new();
289        assert!(manager.names().is_empty());
290    }
291
292    #[test]
293    fn test_should_add_and_render_template() {
294        let mut manager = PromptManager::new();
295        manager.add("test", "Hello, {{ name }}!").unwrap();
296
297        let result = manager.render("test", json!({"name": "World"})).unwrap();
298        assert_eq!(result, "Hello, World!");
299    }
300
301    #[test]
302    fn test_should_render_string_template() {
303        let manager = PromptManager::new();
304        let result = manager
305            .render_str("Value: {{ value }}", json!({"value": 42}))
306            .unwrap();
307        assert_eq!(result, "Value: 42");
308    }
309
310    #[test]
311    fn test_should_return_error_for_missing_template() {
312        let manager = PromptManager::new();
313        let result = manager.render("nonexistent", json!({}));
314        assert!(matches!(result, Err(PromptError::TemplateNotFound(_))));
315    }
316
317    #[test]
318    fn test_should_list_template_names() {
319        let mut manager = PromptManager::new();
320        manager.add("alpha", "A").unwrap();
321        manager.add("beta", "B").unwrap();
322        manager.add("gamma", "C").unwrap();
323
324        let names = manager.names();
325        assert_eq!(names.len(), 3);
326        assert!(names.contains(&"alpha"));
327        assert!(names.contains(&"beta"));
328        assert!(names.contains(&"gamma"));
329    }
330
331    #[test]
332    fn test_should_load_templates_from_directory() {
333        let temp_dir = TempDir::new().unwrap();
334        let templates_path = temp_dir.path();
335
336        // Create template files
337        fs::write(templates_path.join("hello.j2"), "Hello, {{ name }}!").unwrap();
338        fs::write(templates_path.join("bye.jinja"), "Goodbye, {{ name }}!").unwrap();
339        fs::write(templates_path.join("nested.jinja2"), "Nested: {{ value }}").unwrap();
340
341        // Create a subdirectory with templates
342        let subdir = templates_path.join("sub");
343        fs::create_dir(&subdir).unwrap();
344        fs::write(subdir.join("inner.j2"), "Inner: {{ data }}").unwrap();
345
346        let mut manager = PromptManager::new();
347        manager.load_dir(templates_path).unwrap();
348
349        let names = manager.names();
350        assert!(names.contains(&"hello"));
351        assert!(names.contains(&"bye"));
352        assert!(names.contains(&"nested"));
353        assert!(names.contains(&"sub/inner"));
354
355        // Verify rendering works
356        let result = manager.render("hello", json!({"name": "World"})).unwrap();
357        assert_eq!(result, "Hello, World!");
358
359        let result = manager
360            .render("sub/inner", json!({"data": "test"}))
361            .unwrap();
362        assert_eq!(result, "Inner: test");
363    }
364
365    #[test]
366    fn test_should_ignore_non_template_files() {
367        let temp_dir = TempDir::new().unwrap();
368        let templates_path = temp_dir.path();
369
370        fs::write(templates_path.join("valid.j2"), "Valid").unwrap();
371        fs::write(templates_path.join("ignored.txt"), "Ignored").unwrap();
372        fs::write(templates_path.join("readme.md"), "Readme").unwrap();
373
374        let mut manager = PromptManager::new();
375        manager.load_dir(templates_path).unwrap();
376
377        let names = manager.names();
378        assert_eq!(names.len(), 1);
379        assert!(names.contains(&"valid"));
380    }
381
382    #[test]
383    fn test_should_support_method_chaining() {
384        let mut manager = PromptManager::new();
385        manager
386            .add("a", "A: {{ x }}")
387            .unwrap()
388            .add("b", "B: {{ y }}")
389            .unwrap();
390
391        assert_eq!(manager.names().len(), 2);
392    }
393
394    #[test]
395    fn test_should_handle_complex_templates() {
396        let mut manager = PromptManager::new();
397        let template = r#"
398{% for item in items %}
399- {{ item.name }}: {{ item.value }}
400{% endfor %}
401"#;
402        manager.add("list", template).unwrap();
403
404        let result = manager
405            .render(
406                "list",
407                json!({
408                    "items": [
409                        {"name": "foo", "value": 1},
410                        {"name": "bar", "value": 2}
411                    ]
412                }),
413            )
414            .unwrap();
415
416        assert!(result.contains("foo: 1"));
417        assert!(result.contains("bar: 2"));
418    }
419
420    #[test]
421    fn test_should_return_error_for_invalid_template_syntax() {
422        let mut manager = PromptManager::new();
423        let result = manager.add("invalid", "{{ unclosed");
424        assert!(matches!(result, Err(PromptError::RenderError(_))));
425    }
426}