clnrm_template/
discovery.rs

1//! Template discovery and auto-loading system
2//!
3//! Provides convenient APIs for discovering and loading templates from:
4//! - Directories (recursive)
5//! - Glob patterns
6//! - File paths
7//! - Template collections/namespaces
8
9use crate::error::{Result, TemplateError};
10use crate::renderer::TemplateRenderer;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// Template discovery and loading system
15///
16/// Provides fluent API for discovering templates from various sources:
17/// - Auto-discovery in directories
18/// - Glob pattern matching
19/// - Template namespaces
20/// - Hot-reload support
21#[derive(Debug)]
22pub struct TemplateDiscovery {
23    /// Base search paths for template discovery
24    search_paths: Vec<PathBuf>,
25    /// Template namespace mappings (namespace -> template content)
26    namespaces: HashMap<String, String>,
27    /// Glob patterns for template inclusion
28    glob_patterns: Vec<String>,
29    /// Enable recursive directory scanning
30    recursive: bool,
31    /// File extensions to include (default: .toml, .tera, .tpl)
32    extensions: Vec<String>,
33    /// Enable hot-reload (watch for file changes)
34    hot_reload: bool,
35    /// Template organization strategy
36    organization: TemplateOrganization,
37}
38
39/// Template organization strategies
40#[derive(Debug, Clone)]
41pub enum TemplateOrganization {
42    /// Flat organization (all templates in single namespace)
43    Flat,
44    /// Hierarchical organization (templates organized by directory structure)
45    Hierarchical,
46    /// Custom organization with prefixes
47    Custom { prefix: String },
48}
49
50impl Default for TemplateDiscovery {
51    fn default() -> Self {
52        Self {
53            search_paths: Vec::new(),
54            namespaces: HashMap::new(),
55            glob_patterns: Vec::new(),
56            recursive: true,
57            extensions: vec![
58                "toml".to_string(),
59                "tera".to_string(),
60                "tpl".to_string(),
61                "template".to_string(),
62            ],
63            hot_reload: false,
64            organization: TemplateOrganization::Hierarchical,
65        }
66    }
67}
68
69impl TemplateDiscovery {
70    /// Create new template discovery instance
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Add search path for template discovery
76    ///
77    /// # Arguments
78    /// * `path` - Directory path to search for templates
79    pub fn with_search_path<P: AsRef<Path>>(mut self, path: P) -> Self {
80        self.search_paths.push(path.as_ref().to_path_buf());
81        self
82    }
83
84    /// Add multiple search paths
85    pub fn with_search_paths<I, P>(mut self, paths: I) -> Self
86    where
87        I: IntoIterator<Item = P>,
88        P: AsRef<Path>,
89    {
90        for path in paths {
91            self.search_paths.push(path.as_ref().to_path_buf());
92        }
93        self
94    }
95
96    /// Add glob pattern for template inclusion
97    ///
98    /// # Arguments
99    /// * `pattern` - Glob pattern (e.g., "**/*.toml", "tests/**/*.tera")
100    pub fn with_glob_pattern(mut self, pattern: &str) -> Self {
101        self.glob_patterns.push(pattern.to_string());
102        self
103    }
104
105    /// Enable/disable recursive directory scanning
106    pub fn recursive(mut self, recursive: bool) -> Self {
107        self.recursive = recursive;
108        self
109    }
110
111    /// Set file extensions to include in discovery
112    pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
113    where
114        I: IntoIterator<Item = S>,
115        S: Into<String>,
116    {
117        self.extensions = extensions.into_iter().map(|s| s.into()).collect();
118        self
119    }
120
121    /// Enable hot-reload for template files
122    pub fn hot_reload(mut self, enabled: bool) -> Self {
123        self.hot_reload = enabled;
124        self
125    }
126
127    /// Set template organization strategy
128    pub fn with_organization(mut self, organization: TemplateOrganization) -> Self {
129        self.organization = organization;
130        self
131    }
132
133    /// Add template to namespace
134    ///
135    /// # Arguments
136    /// * `namespace` - Namespace name (e.g., "macros", "partials")
137    /// * `content` - Template content
138    pub fn with_namespace<S: Into<String>>(mut self, namespace: S, content: S) -> Self {
139        self.namespaces.insert(namespace.into(), content.into());
140        self
141    }
142
143    /// Discover and load all templates
144    ///
145    /// Returns a TemplateLoader with all discovered templates ready for rendering
146    pub fn load(self) -> Result<TemplateLoader> {
147        let mut templates = HashMap::new();
148
149        // Load namespace templates first (highest priority)
150        for (namespace, content) in &self.namespaces {
151            templates.insert(namespace.to_string(), content.to_string());
152        }
153
154        // Discover templates from search paths
155        for search_path in &self.search_paths {
156            self.discover_from_path(search_path, &mut templates)?;
157        }
158
159        // Discover templates from glob patterns
160        for pattern in &self.glob_patterns {
161            self.discover_from_glob(pattern, &mut templates)?;
162        }
163
164        Ok(TemplateLoader {
165            templates,
166            hot_reload: self.hot_reload,
167            organization: self.organization,
168        })
169    }
170
171    /// Discover templates from a directory path
172    fn discover_from_path(
173        &self,
174        path: &Path,
175        templates: &mut HashMap<String, String>,
176    ) -> Result<()> {
177        if !path.exists() {
178            return Ok(()); // Skip non-existent paths
179        }
180
181        if path.is_file() {
182            // Single file
183            if self.should_include_file(path) {
184                let name = self.template_name_from_path(path);
185                let content = std::fs::read_to_string(path).map_err(|e| {
186                    TemplateError::IoError(format!(
187                        "Failed to read template file {:?}: {}",
188                        path, e
189                    ))
190                })?;
191                templates.insert(name, content);
192            }
193            return Ok(());
194        }
195
196        // Directory scanning
197        self.scan_directory(path, templates)
198    }
199
200    /// Discover templates from glob pattern
201    fn discover_from_glob(
202        &self,
203        pattern: &str,
204        templates: &mut HashMap<String, String>,
205    ) -> Result<()> {
206        use globset::{Glob, GlobSetBuilder};
207
208        let glob = Glob::new(pattern).map_err(|e| {
209            TemplateError::ConfigError(format!("Invalid glob pattern '{}': {}", pattern, e))
210        })?;
211
212        let glob_set = GlobSetBuilder::new().add(glob).build().map_err(|e| {
213            TemplateError::ConfigError(format!("Failed to build glob set for '{}': {}", pattern, e))
214        })?;
215
216        for search_path in &self.search_paths {
217            self.scan_path_with_glob(search_path, &glob_set, templates)?;
218        }
219
220        Ok(())
221    }
222
223    /// Scan directory for template files
224    fn scan_directory(&self, dir: &Path, templates: &mut HashMap<String, String>) -> Result<()> {
225        use walkdir::WalkDir;
226
227        let walker = if self.recursive {
228            WalkDir::new(dir)
229        } else {
230            WalkDir::new(dir).max_depth(1)
231        };
232
233        for entry in walker {
234            let entry = entry.map_err(|e| {
235                TemplateError::IoError(format!("Failed to read directory entry: {}", e))
236            })?;
237
238            if entry.file_type().is_file() && self.should_include_file(entry.path()) {
239                let name = self.template_name_from_path(entry.path());
240                let content = std::fs::read_to_string(entry.path()).map_err(|e| {
241                    TemplateError::IoError(format!(
242                        "Failed to read template file {:?}: {}",
243                        entry.path(),
244                        e
245                    ))
246                })?;
247
248                templates.insert(name, content);
249            }
250        }
251
252        Ok(())
253    }
254
255    /// Scan path with glob pattern
256    fn scan_path_with_glob(
257        &self,
258        path: &Path,
259        glob_set: &globset::GlobSet,
260        templates: &mut HashMap<String, String>,
261    ) -> Result<()> {
262        use walkdir::WalkDir;
263
264        let walker = if self.recursive {
265            WalkDir::new(path)
266        } else {
267            WalkDir::new(path).max_depth(1)
268        };
269
270        for entry in walker {
271            let entry = entry.map_err(|e| {
272                TemplateError::IoError(format!("Failed to read directory entry: {}", e))
273            })?;
274
275            if entry.file_type().is_file() {
276                let path_str = entry.path().to_string_lossy();
277                if glob_set.is_match(&*path_str) && self.should_include_file(entry.path()) {
278                    let name = self.template_name_from_path(entry.path());
279                    let content = std::fs::read_to_string(entry.path()).map_err(|e| {
280                        TemplateError::IoError(format!(
281                            "Failed to read template file {:?}: {}",
282                            entry.path(),
283                            e
284                        ))
285                    })?;
286
287                    templates.insert(name, content);
288                }
289            }
290        }
291
292        Ok(())
293    }
294
295    /// Check if file should be included based on extension
296    fn should_include_file(&self, path: &Path) -> bool {
297        if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
298            self.extensions.contains(&extension.to_string())
299        } else {
300            false
301        }
302    }
303
304    /// Generate template name from file path
305    fn template_name_from_path(&self, path: &Path) -> String {
306        // Remove extension and convert path separators to dots
307        let stem = path
308            .file_stem()
309            .and_then(|s| s.to_str())
310            .unwrap_or("unknown");
311
312        // For relative paths within search paths, use relative structure
313        for search_path in &self.search_paths {
314            if let Ok(relative_path) = path.strip_prefix(search_path) {
315                let relative_str = relative_path.to_string_lossy().replace(['/', '\\'], ".");
316                let name_without_ext = Path::new(&relative_str)
317                    .file_stem()
318                    .and_then(|s| s.to_str())
319                    .unwrap_or(stem);
320
321                return match &self.organization {
322                    TemplateOrganization::Flat => name_without_ext.to_string(),
323                    TemplateOrganization::Hierarchical => {
324                        // Use full relative path as template name
325                        let parent = relative_path
326                            .parent()
327                            .and_then(|p| p.to_str())
328                            .unwrap_or("");
329                        if parent.is_empty() {
330                            name_without_ext.to_string()
331                        } else {
332                            format!("{}.{}", parent.replace(['/', '\\'], "."), name_without_ext)
333                        }
334                    }
335                    TemplateOrganization::Custom { prefix } => {
336                        format!("{}.{}", prefix, name_without_ext)
337                    }
338                };
339            }
340        }
341
342        stem.to_string()
343    }
344}
345
346/// Template loader with loaded templates ready for rendering
347///
348/// Provides template rendering with loaded template collection.
349/// Supports hot-reload if enabled during discovery.
350#[derive(Debug)]
351pub struct TemplateLoader {
352    /// Loaded templates (name -> content)
353    pub(crate) templates: HashMap<String, String>,
354    /// Hot-reload enabled
355    #[allow(dead_code)]
356    hot_reload: bool,
357    /// Template organization strategy
358    organization: TemplateOrganization,
359}
360
361impl Default for TemplateLoader {
362    fn default() -> Self {
363        Self::new()
364    }
365}
366
367impl TemplateLoader {
368    /// Create new template loader
369    pub fn new() -> Self {
370        Self {
371            templates: HashMap::new(),
372            hot_reload: false,
373            organization: TemplateOrganization::Hierarchical,
374        }
375    }
376
377    /// Get template content by name
378    pub fn get_template(&self, name: &str) -> Option<&str> {
379        self.templates.get(name).map(|s| s.as_str())
380    }
381
382    /// Check if template exists
383    pub fn has_template(&self, name: &str) -> bool {
384        self.templates.contains_key(name)
385    }
386
387    /// List all available template names
388    pub fn template_names(&self) -> Vec<&str> {
389        self.templates.keys().map(|s| s.as_str()).collect()
390    }
391
392    /// List templates by category (for hierarchical organization)
393    pub fn templates_by_category(&self) -> HashMap<String, Vec<String>> {
394        let mut categories = HashMap::new();
395
396        for name in self.templates.keys() {
397            let category = if let Some(dot_pos) = name.rfind('.') {
398                name[..dot_pos].to_string()
399            } else {
400                "root".to_string()
401            };
402
403            categories
404                .entry(category)
405                .or_insert_with(Vec::new)
406                .push(name.clone());
407        }
408
409        categories
410    }
411
412    /// Create template renderer with loaded templates
413    ///
414    /// # Arguments
415    /// * `context` - Template context for rendering
416    /// * `determinism` - Optional determinism configuration
417    pub fn create_renderer(
418        &self,
419        context: crate::context::TemplateContext,
420    ) -> Result<TemplateRenderer> {
421        let mut renderer = TemplateRenderer::new()?;
422
423        // Add all loaded templates
424        for (name, content) in &self.templates {
425            renderer.add_template(name, content).map_err(|e| {
426                TemplateError::RenderError(format!("Failed to add template '{}': {}", name, e))
427            })?;
428        }
429
430        Ok(renderer.with_context(context))
431    }
432
433    /// Render template by name
434    ///
435    /// # Arguments
436    /// * `name` - Template name
437    /// * `context` - Template context
438    /// * `determinism` - Optional determinism configuration
439    pub fn render(&self, name: &str, context: crate::context::TemplateContext) -> Result<String> {
440        let mut renderer = self.create_renderer(context)?;
441        renderer.render_str(&self.templates[name], name)
442    }
443
444    /// Render template with user variables
445    ///
446    /// Convenience method for simple rendering with user vars
447    pub fn render_with_vars(
448        &self,
449        name: &str,
450        user_vars: std::collections::HashMap<String, serde_json::Value>,
451    ) -> Result<String> {
452        let mut context = crate::context::TemplateContext::with_defaults();
453        context.merge_user_vars(user_vars);
454        self.render(name, context)
455    }
456
457    /// Save all templates to files (for template generation)
458    ///
459    /// # Arguments
460    /// * `output_dir` - Directory to save templates to
461    pub fn save_to_directory<P: AsRef<Path>>(&self, output_dir: P) -> Result<()> {
462        let output_dir = output_dir.as_ref();
463
464        // Create output directory if it doesn't exist
465        std::fs::create_dir_all(output_dir).map_err(|e| {
466            TemplateError::IoError(format!("Failed to create output directory: {}", e))
467        })?;
468
469        for (name, content) in &self.templates {
470            let file_path = self.template_path_from_name(name, output_dir);
471            std::fs::write(&file_path, content).map_err(|e| {
472                TemplateError::IoError(format!("Failed to write template '{}': {}", name, e))
473            })?;
474        }
475
476        Ok(())
477    }
478
479    /// Convert template name back to file path
480    fn template_path_from_name(&self, name: &str, base_dir: &Path) -> PathBuf {
481        match &self.organization {
482            TemplateOrganization::Flat => base_dir.join(format!("{}.toml", name)),
483            TemplateOrganization::Hierarchical => {
484                // Convert dots back to path separators
485                let path_str = name.replace('.', "/");
486                base_dir.join(format!("{}.toml", path_str))
487            }
488            TemplateOrganization::Custom { prefix } => {
489                // Remove prefix and convert to path
490                let path_part = if name.starts_with(&format!("{}.", prefix)) {
491                    &name[prefix.len() + 1..]
492                } else {
493                    name
494                };
495                let path_str = path_part.replace('.', "/");
496                base_dir.join(format!("{}.toml", path_str))
497            }
498        }
499    }
500}
501
502/// Fluent API for template loading
503pub struct TemplateLoaderBuilder {
504    discovery: TemplateDiscovery,
505}
506
507impl TemplateLoaderBuilder {
508    /// Start building template loader
509    pub fn new() -> Self {
510        Self {
511            discovery: TemplateDiscovery::new(),
512        }
513    }
514
515    /// Add search path
516    pub fn search_path<P: AsRef<Path>>(mut self, path: P) -> Self {
517        self.discovery
518            .search_paths
519            .push(path.as_ref().to_path_buf());
520        self
521    }
522
523    /// Add glob pattern
524    pub fn glob_pattern(mut self, pattern: &str) -> Self {
525        self.discovery.glob_patterns.push(pattern.to_string());
526        self
527    }
528
529    /// Add namespace template
530    pub fn namespace<S: Into<String>>(mut self, name: S, content: S) -> Self {
531        self.discovery
532            .namespaces
533            .insert(name.into(), content.into());
534        self
535    }
536
537    /// Enable hot reload
538    pub fn hot_reload(mut self) -> Self {
539        self.discovery.hot_reload = true;
540        self
541    }
542
543    /// Set organization strategy
544    pub fn organization(mut self, organization: TemplateOrganization) -> Self {
545        self.discovery.organization = organization;
546        self
547    }
548
549    /// Build the template loader
550    pub fn build(self) -> Result<TemplateLoader> {
551        self.discovery.load()
552    }
553}
554
555impl Default for TemplateLoaderBuilder {
556    fn default() -> Self {
557        Self::new()
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use tempfile::tempdir;
565
566    #[test]
567    fn test_template_discovery_basic() -> Result<()> {
568        let temp_dir = tempdir()?;
569        let template_file = temp_dir.path().join("test.toml");
570        std::fs::write(&template_file, "name = \"{{ test_var }}\"")?;
571
572        let discovery = TemplateDiscovery::new()
573            .with_search_path(&temp_dir)
574            .recursive(false);
575
576        let loader = discovery.load()?;
577
578        assert!(loader.has_template("test"));
579        assert_eq!(
580            loader.get_template("test"),
581            Some("name = \"{{ test_var }}\"")
582        );
583
584        Ok(())
585    }
586
587    #[test]
588    fn test_template_discovery_with_namespace() -> Result<()> {
589        let discovery = TemplateDiscovery::new()
590            .with_namespace("macros", "{% macro test() %}Hello{% endmacro %}");
591
592        let loader = discovery.load()?;
593
594        assert!(loader.has_template("macros"));
595        assert_eq!(
596            loader.get_template("macros"),
597            Some("{% macro test() %}Hello{% endmacro %}")
598        );
599
600        Ok(())
601    }
602
603    #[test]
604    fn test_template_loader_rendering() -> Result<()> {
605        let temp_dir = tempdir()?;
606        let template_file = temp_dir.path().join("config.toml");
607        std::fs::write(&template_file, "service = \"{{ svc }}\"")?;
608
609        let discovery = TemplateDiscovery::new().with_search_path(&temp_dir);
610
611        let loader = discovery.load()?;
612
613        let mut vars = std::collections::HashMap::new();
614        vars.insert(
615            "svc".to_string(),
616            serde_json::Value::String("test-service".to_string()),
617        );
618
619        let result = loader.render_with_vars("config", vars)?;
620        assert_eq!(result.trim(), "service = \"test-service\"");
621
622        Ok(())
623    }
624
625    #[test]
626    fn test_hierarchical_organization() -> Result<()> {
627        let temp_dir = tempdir()?;
628        let subdir = temp_dir.path().join("services");
629        std::fs::create_dir_all(&subdir)?;
630
631        let template_file = subdir.join("api.toml");
632        std::fs::write(&template_file, "service = \"api\"")?;
633
634        let discovery = TemplateDiscovery::new()
635            .with_search_path(&temp_dir)
636            .with_organization(TemplateOrganization::Hierarchical);
637
638        let loader = discovery.load()?;
639
640        assert!(loader.has_template("services.api"));
641
642        Ok(())
643    }
644}