Skip to main content

ralph_workflow/prompts/
template_registry.rs

1//! Template Registry for Runtime Template Loading
2//!
3//! This module provides a centralized template registry that supports:
4//! - User-defined template overrides (from `~/.config/ralph/templates/`)
5//! - Embedded templates as fallback (compiled into binary)
6//! - Runtime template loading with caching
7//!
8//! # Template Loading Priority
9//!
10//! 1. User template: `{user_templates_dir}/{name}.txt`
11//! 2. Embedded template: Compiled-in fallback
12//! 3. Error: Template not found
13
14use std::fs;
15use std::path::PathBuf;
16
17/// Error type for template loading operations.
18#[derive(Debug, Clone, thiserror::Error)]
19pub enum TemplateError {
20    /// Template not found in user directory or embedded catalog
21    #[error("Template '{name}' not found")]
22    TemplateNotFound { name: String },
23
24    /// Error reading user template file
25    #[error("Failed to read template '{name}': {reason}")]
26    ReadError { name: String, reason: String },
27}
28
29/// Template registry for loading templates from multiple sources.
30///
31/// The registry maintains a user templates directory for template overrides.
32/// Templates are loaded from user directory first, falling back to embedded templates.
33#[derive(Debug, Clone)]
34pub struct TemplateRegistry {
35    /// User templates directory (higher priority than embedded templates).
36    user_templates_dir: Option<PathBuf>,
37}
38
39impl TemplateRegistry {
40    /// Create a new template registry.
41    ///
42    /// # Arguments
43    ///
44    /// * `user_templates_dir` - Optional path to user templates directory.
45    ///   When set, templates in this directory override embedded templates.
46    #[must_use]
47    pub const fn new(user_templates_dir: Option<PathBuf>) -> Self {
48        Self { user_templates_dir }
49    }
50
51    /// Get the default user templates directory path.
52    ///
53    /// Returns `~/.config/ralph/templates/` by default.
54    /// Respects `XDG_CONFIG_HOME` environment variable.
55    ///
56    /// # Returns
57    ///
58    /// `None` if home directory cannot be determined.
59    #[must_use]
60    pub fn default_user_templates_dir() -> Option<PathBuf> {
61        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
62            let xdg = xdg.trim();
63            if !xdg.is_empty() {
64                return Some(PathBuf::from(xdg).join("ralph").join("templates"));
65            }
66        }
67
68        dirs::home_dir().map(|d| d.join(".config").join("ralph").join("templates"))
69    }
70
71    /// Check if a user template exists for the given name.
72    ///
73    /// # Returns
74    ///
75    /// `true` if a user template file exists (not embedded)
76    #[must_use]
77    pub fn has_user_template(&self, name: &str) -> bool {
78        self.user_templates_dir
79            .as_ref()
80            .is_some_and(|user_dir| user_dir.join(format!("{name}.txt")).exists())
81    }
82
83    /// Get the source of a template (user or embedded).
84    ///
85    /// # Returns
86    ///
87    /// * `"user"` - Template is from user directory
88    /// * `"embedded"` - Template is embedded
89    #[must_use]
90    pub fn template_source(&self, name: &str) -> &'static str {
91        if self.has_user_template(name) {
92            "user"
93        } else {
94            "embedded"
95        }
96    }
97
98    /// Load a template by name.
99    ///
100    /// Template loading priority:
101    /// 1. User template: `{user_templates_dir}/{name}.txt`
102    /// 2. Embedded template from catalog
103    /// 3. Error if neither exists
104    ///
105    /// # Arguments
106    ///
107    /// * `name` - Template name (without `.txt` extension)
108    ///
109    /// # Returns
110    ///
111    /// * `Ok(String)` - Template content
112    /// * `Err(TemplateError)` - Template not found or read error
113    pub fn get_template(&self, name: &str) -> Result<String, TemplateError> {
114        use crate::prompts::template_catalog;
115
116        // Try user template first
117        if let Some(user_dir) = &self.user_templates_dir {
118            let user_path = user_dir.join(format!("{name}.txt"));
119            if user_path.exists() {
120                return fs::read_to_string(&user_path).map_err(|e| TemplateError::ReadError {
121                    name: name.to_string(),
122                    reason: e.to_string(),
123                });
124            }
125        }
126
127        // Fall back to embedded template
128        if let Some(content) = template_catalog::get_embedded_template(name) {
129            return Ok(content);
130        }
131
132        // Template not found
133        Err(TemplateError::TemplateNotFound {
134            name: name.to_string(),
135        })
136    }
137
138    /// Get all template names available in the embedded catalog.
139    ///
140    /// # Returns
141    ///
142    /// A vector of all embedded template names, sorted alphabetically.
143    #[must_use]
144    #[cfg(test)]
145    pub fn all_template_names() -> Vec<String> {
146        use crate::prompts::template_catalog;
147        template_catalog::list_all_templates()
148            .iter()
149            .map(|t| t.name.to_string())
150            .collect()
151    }
152
153    /// Check if a template exists (either user or embedded).
154    ///
155    /// # Returns
156    ///
157    /// `true` if the template exists in user directory or embedded catalog
158    #[must_use]
159    #[cfg(test)]
160    pub fn template_exists(&self, name: &str) -> bool {
161        self.has_user_template(name) || self.get_template(name).is_ok()
162    }
163}
164
165impl Default for TemplateRegistry {
166    fn default() -> Self {
167        Self::new(Self::default_user_templates_dir())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_registry_creation() {
177        let registry = TemplateRegistry::new(None);
178        assert!(registry.user_templates_dir.is_none());
179
180        let custom_dir = PathBuf::from("/custom/templates");
181        let registry = TemplateRegistry::new(Some(custom_dir.clone()));
182        assert_eq!(registry.user_templates_dir, Some(custom_dir));
183    }
184
185    #[test]
186    fn test_default_user_templates_dir() {
187        let dir = TemplateRegistry::default_user_templates_dir();
188        assert!(dir.is_some());
189        let path = dir.unwrap();
190        assert!(path.to_string_lossy().contains("templates"));
191    }
192
193    #[test]
194    fn test_has_user_template_no_dir() {
195        let registry = TemplateRegistry::new(None);
196        assert!(!registry.has_user_template("commit_message_xml"));
197    }
198
199    #[test]
200    fn test_template_source_no_dir() {
201        let registry = TemplateRegistry::new(None);
202        let source = registry.template_source("commit_message_xml");
203        assert_eq!(source, "embedded");
204    }
205
206    #[test]
207    fn test_template_source_not_found() {
208        let registry = TemplateRegistry::new(None);
209        let source = registry.template_source("nonexistent_template");
210        assert_eq!(source, "embedded");
211    }
212
213    #[test]
214    fn test_default_registry() {
215        let registry = TemplateRegistry::default();
216        // Default registry should have a user templates dir if home dir exists
217        if TemplateRegistry::default_user_templates_dir().is_some() {
218            assert!(registry.user_templates_dir.is_some());
219        }
220    }
221
222    #[test]
223    fn test_get_template_embedded() {
224        let registry = TemplateRegistry::new(None);
225        let result = registry.get_template("developer_iteration_xml");
226        assert!(result.is_ok());
227        let content = result.unwrap();
228        assert!(!content.is_empty());
229        assert!(content.contains("IMPLEMENTATION MODE") || content.contains("Developer"));
230    }
231
232    #[test]
233    fn test_get_template_not_found() {
234        let registry = TemplateRegistry::new(None);
235        let result = registry.get_template("nonexistent_template");
236        assert!(result.is_err());
237        assert!(matches!(
238            result.unwrap_err(),
239            TemplateError::TemplateNotFound { .. }
240        ));
241    }
242
243    #[test]
244    fn test_all_template_names() {
245        let names = TemplateRegistry::all_template_names();
246        assert!(!names.is_empty());
247        assert!(names.len() >= 10); // At least 10 templates (reduced after removing unused reviewer templates)
248        assert!(names.contains(&"developer_iteration_xml".to_string()));
249        assert!(names.contains(&"commit_message_xml".to_string()));
250    }
251
252    #[test]
253    fn test_template_exists_embedded() {
254        let registry = TemplateRegistry::new(None);
255        assert!(registry.template_exists("developer_iteration_xml"));
256        assert!(registry.template_exists("commit_message_xml"));
257    }
258
259    #[test]
260    fn test_template_not_exists() {
261        let registry = TemplateRegistry::new(None);
262        assert!(!registry.template_exists("nonexistent_template"));
263    }
264
265    #[test]
266    fn test_get_commit_template() {
267        let registry = TemplateRegistry::new(None);
268        let result = registry.get_template("commit_message_xml");
269        assert!(result.is_ok());
270        let content = result.unwrap();
271        assert!(!content.is_empty());
272    }
273
274    #[test]
275    fn test_get_review_xml_template() {
276        let registry = TemplateRegistry::new(None);
277        // The review phase uses review_xml template
278        let result = registry.get_template("review_xml");
279        assert!(result.is_ok());
280        let content = result.unwrap();
281        assert!(!content.is_empty());
282        assert!(content.contains("REVIEW MODE"));
283    }
284
285    #[test]
286    fn test_get_fix_mode_template() {
287        let registry = TemplateRegistry::new(None);
288        let result = registry.get_template("fix_mode_xml");
289        assert!(result.is_ok());
290        let content = result.unwrap();
291        assert!(!content.is_empty());
292    }
293
294    #[test]
295    fn test_all_templates_have_content() {
296        let registry = TemplateRegistry::new(None);
297        for name in TemplateRegistry::all_template_names() {
298            let result = registry.get_template(&name);
299            assert!(result.is_ok(), "Template '{name}' should load successfully");
300            let content = result.unwrap();
301            assert!(!content.is_empty(), "Template '{name}' should not be empty");
302        }
303    }
304}