1use crate::error::{Result, TemplateError};
10use crate::renderer::TemplateRenderer;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug)]
22pub struct TemplateDiscovery {
23 search_paths: Vec<PathBuf>,
25 namespaces: HashMap<String, String>,
27 glob_patterns: Vec<String>,
29 recursive: bool,
31 extensions: Vec<String>,
33 hot_reload: bool,
35 organization: TemplateOrganization,
37}
38
39#[derive(Debug, Clone)]
41pub enum TemplateOrganization {
42 Flat,
44 Hierarchical,
46 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 pub fn new() -> Self {
72 Self::default()
73 }
74
75 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 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 pub fn with_glob_pattern(mut self, pattern: &str) -> Self {
101 self.glob_patterns.push(pattern.to_string());
102 self
103 }
104
105 pub fn recursive(mut self, recursive: bool) -> Self {
107 self.recursive = recursive;
108 self
109 }
110
111 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 pub fn hot_reload(mut self, enabled: bool) -> Self {
123 self.hot_reload = enabled;
124 self
125 }
126
127 pub fn with_organization(mut self, organization: TemplateOrganization) -> Self {
129 self.organization = organization;
130 self
131 }
132
133 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 pub fn load(self) -> Result<TemplateLoader> {
147 let mut templates = HashMap::new();
148
149 for (namespace, content) in &self.namespaces {
151 templates.insert(namespace.to_string(), content.to_string());
152 }
153
154 for search_path in &self.search_paths {
156 self.discover_from_path(search_path, &mut templates)?;
157 }
158
159 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 fn discover_from_path(
173 &self,
174 path: &Path,
175 templates: &mut HashMap<String, String>,
176 ) -> Result<()> {
177 if !path.exists() {
178 return Ok(()); }
180
181 if path.is_file() {
182 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 self.scan_directory(path, templates)
198 }
199
200 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 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 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 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 fn template_name_from_path(&self, path: &Path) -> String {
306 let stem = path
308 .file_stem()
309 .and_then(|s| s.to_str())
310 .unwrap_or("unknown");
311
312 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 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#[derive(Debug)]
351pub struct TemplateLoader {
352 pub(crate) templates: HashMap<String, String>,
354 #[allow(dead_code)]
356 hot_reload: bool,
357 organization: TemplateOrganization,
359}
360
361impl Default for TemplateLoader {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367impl TemplateLoader {
368 pub fn new() -> Self {
370 Self {
371 templates: HashMap::new(),
372 hot_reload: false,
373 organization: TemplateOrganization::Hierarchical,
374 }
375 }
376
377 pub fn get_template(&self, name: &str) -> Option<&str> {
379 self.templates.get(name).map(|s| s.as_str())
380 }
381
382 pub fn has_template(&self, name: &str) -> bool {
384 self.templates.contains_key(name)
385 }
386
387 pub fn template_names(&self) -> Vec<&str> {
389 self.templates.keys().map(|s| s.as_str()).collect()
390 }
391
392 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 pub fn create_renderer(
418 &self,
419 context: crate::context::TemplateContext,
420 ) -> Result<TemplateRenderer> {
421 let mut renderer = TemplateRenderer::new()?;
422
423 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 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 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 pub fn save_to_directory<P: AsRef<Path>>(&self, output_dir: P) -> Result<()> {
462 let output_dir = output_dir.as_ref();
463
464 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 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 let path_str = name.replace('.', "/");
486 base_dir.join(format!("{}.toml", path_str))
487 }
488 TemplateOrganization::Custom { prefix } => {
489 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
502pub struct TemplateLoaderBuilder {
504 discovery: TemplateDiscovery,
505}
506
507impl TemplateLoaderBuilder {
508 pub fn new() -> Self {
510 Self {
511 discovery: TemplateDiscovery::new(),
512 }
513 }
514
515 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 pub fn glob_pattern(mut self, pattern: &str) -> Self {
525 self.discovery.glob_patterns.push(pattern.to_string());
526 self
527 }
528
529 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 pub fn hot_reload(mut self) -> Self {
539 self.discovery.hot_reload = true;
540 self
541 }
542
543 pub fn organization(mut self, organization: TemplateOrganization) -> Self {
545 self.discovery.organization = organization;
546 self
547 }
548
549 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}