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}