use crate::error::{Result, TemplateError};
use crate::renderer::TemplateRenderer;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct TemplateDiscovery {
search_paths: Vec<PathBuf>,
namespaces: HashMap<String, String>,
glob_patterns: Vec<String>,
recursive: bool,
extensions: Vec<String>,
hot_reload: bool,
organization: TemplateOrganization,
}
#[derive(Debug, Clone)]
pub enum TemplateOrganization {
Flat,
Hierarchical,
Custom { prefix: String },
}
impl Default for TemplateDiscovery {
fn default() -> Self {
Self {
search_paths: Vec::new(),
namespaces: HashMap::new(),
glob_patterns: Vec::new(),
recursive: true,
extensions: vec![
"toml".to_string(),
"tera".to_string(),
"tpl".to_string(),
"template".to_string(),
],
hot_reload: false,
organization: TemplateOrganization::Hierarchical,
}
}
}
impl TemplateDiscovery {
pub fn new() -> Self {
Self::default()
}
pub fn with_search_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.search_paths.push(path.as_ref().to_path_buf());
self
}
pub fn with_search_paths<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
for path in paths {
self.search_paths.push(path.as_ref().to_path_buf());
}
self
}
pub fn with_glob_pattern(mut self, pattern: &str) -> Self {
self.glob_patterns.push(pattern.to_string());
self
}
pub fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extensions = extensions.into_iter().map(|s| s.into()).collect();
self
}
pub fn hot_reload(mut self, enabled: bool) -> Self {
self.hot_reload = enabled;
self
}
pub fn with_organization(mut self, organization: TemplateOrganization) -> Self {
self.organization = organization;
self
}
pub fn with_namespace<S: Into<String>>(mut self, namespace: S, content: S) -> Self {
self.namespaces.insert(namespace.into(), content.into());
self
}
pub fn load(self) -> Result<TemplateLoader> {
let mut templates = HashMap::new();
for (namespace, content) in &self.namespaces {
templates.insert(namespace.to_string(), content.to_string());
}
for search_path in &self.search_paths {
self.discover_from_path(search_path, &mut templates)?;
}
for pattern in &self.glob_patterns {
self.discover_from_glob(pattern, &mut templates)?;
}
Ok(TemplateLoader {
templates,
hot_reload: self.hot_reload,
organization: self.organization,
})
}
fn discover_from_path(
&self,
path: &Path,
templates: &mut HashMap<String, String>,
) -> Result<()> {
if !path.exists() {
return Ok(()); }
if path.is_file() {
if self.should_include_file(path) {
let name = self.template_name_from_path(path);
let content = std::fs::read_to_string(path).map_err(|e| {
TemplateError::IoError(format!(
"Failed to read template file {:?}: {}",
path, e
))
})?;
templates.insert(name, content);
}
return Ok(());
}
self.scan_directory(path, templates)
}
fn discover_from_glob(
&self,
pattern: &str,
templates: &mut HashMap<String, String>,
) -> Result<()> {
use globset::{Glob, GlobSetBuilder};
let glob = Glob::new(pattern).map_err(|e| {
TemplateError::ConfigError(format!("Invalid glob pattern '{}': {}", pattern, e))
})?;
let glob_set = GlobSetBuilder::new().add(glob).build().map_err(|e| {
TemplateError::ConfigError(format!("Failed to build glob set for '{}': {}", pattern, e))
})?;
for search_path in &self.search_paths {
self.scan_path_with_glob(search_path, &glob_set, templates)?;
}
Ok(())
}
fn scan_directory(&self, dir: &Path, templates: &mut HashMap<String, String>) -> Result<()> {
use walkdir::WalkDir;
let walker = if self.recursive {
WalkDir::new(dir)
} else {
WalkDir::new(dir).max_depth(1)
};
for entry in walker {
let entry = entry.map_err(|e| {
TemplateError::IoError(format!("Failed to read directory entry: {}", e))
})?;
if entry.file_type().is_file() && self.should_include_file(entry.path()) {
let name = self.template_name_from_path(entry.path());
let content = std::fs::read_to_string(entry.path()).map_err(|e| {
TemplateError::IoError(format!(
"Failed to read template file {:?}: {}",
entry.path(),
e
))
})?;
templates.insert(name, content);
}
}
Ok(())
}
fn scan_path_with_glob(
&self,
path: &Path,
glob_set: &globset::GlobSet,
templates: &mut HashMap<String, String>,
) -> Result<()> {
use walkdir::WalkDir;
let walker = if self.recursive {
WalkDir::new(path)
} else {
WalkDir::new(path).max_depth(1)
};
for entry in walker {
let entry = entry.map_err(|e| {
TemplateError::IoError(format!("Failed to read directory entry: {}", e))
})?;
if entry.file_type().is_file() {
let path_str = entry.path().to_string_lossy();
if glob_set.is_match(&*path_str) && self.should_include_file(entry.path()) {
let name = self.template_name_from_path(entry.path());
let content = std::fs::read_to_string(entry.path()).map_err(|e| {
TemplateError::IoError(format!(
"Failed to read template file {:?}: {}",
entry.path(),
e
))
})?;
templates.insert(name, content);
}
}
}
Ok(())
}
fn should_include_file(&self, path: &Path) -> bool {
if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
self.extensions.contains(&extension.to_string())
} else {
false
}
}
fn template_name_from_path(&self, path: &Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
for search_path in &self.search_paths {
if let Ok(relative_path) = path.strip_prefix(search_path) {
let relative_str = relative_path.to_string_lossy().replace(['/', '\\'], ".");
let name_without_ext = Path::new(&relative_str)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(stem);
return match &self.organization {
TemplateOrganization::Flat => name_without_ext.to_string(),
TemplateOrganization::Hierarchical => {
let parent = relative_path
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
if parent.is_empty() {
name_without_ext.to_string()
} else {
format!("{}.{}", parent.replace(['/', '\\'], "."), name_without_ext)
}
}
TemplateOrganization::Custom { prefix } => {
format!("{}.{}", prefix, name_without_ext)
}
};
}
}
stem.to_string()
}
}
#[derive(Debug)]
pub struct TemplateLoader {
pub(crate) templates: HashMap<String, String>,
#[allow(dead_code)]
hot_reload: bool,
organization: TemplateOrganization,
}
impl Default for TemplateLoader {
fn default() -> Self {
Self::new()
}
}
impl TemplateLoader {
pub fn new() -> Self {
Self {
templates: HashMap::new(),
hot_reload: false,
organization: TemplateOrganization::Hierarchical,
}
}
pub fn get_template(&self, name: &str) -> Option<&str> {
self.templates.get(name).map(|s| s.as_str())
}
pub fn has_template(&self, name: &str) -> bool {
self.templates.contains_key(name)
}
pub fn template_names(&self) -> Vec<&str> {
self.templates.keys().map(|s| s.as_str()).collect()
}
pub fn templates_by_category(&self) -> HashMap<String, Vec<String>> {
let mut categories = HashMap::new();
for name in self.templates.keys() {
let category = if let Some(dot_pos) = name.rfind('.') {
name[..dot_pos].to_string()
} else {
"root".to_string()
};
categories
.entry(category)
.or_insert_with(Vec::new)
.push(name.clone());
}
categories
}
pub fn create_renderer(
&self,
context: crate::context::TemplateContext,
) -> Result<TemplateRenderer> {
let mut renderer = TemplateRenderer::new()?;
for (name, content) in &self.templates {
renderer.add_template(name, content).map_err(|e| {
TemplateError::RenderError(format!("Failed to add template '{}': {}", name, e))
})?;
}
Ok(renderer.with_context(context))
}
pub fn render(&self, name: &str, context: crate::context::TemplateContext) -> Result<String> {
let mut renderer = self.create_renderer(context)?;
renderer.render_str(&self.templates[name], name)
}
pub fn render_with_vars(
&self,
name: &str,
user_vars: std::collections::HashMap<String, serde_json::Value>,
) -> Result<String> {
let mut context = crate::context::TemplateContext::with_defaults();
context.merge_user_vars(user_vars);
self.render(name, context)
}
pub fn save_to_directory<P: AsRef<Path>>(&self, output_dir: P) -> Result<()> {
let output_dir = output_dir.as_ref();
std::fs::create_dir_all(output_dir).map_err(|e| {
TemplateError::IoError(format!("Failed to create output directory: {}", e))
})?;
for (name, content) in &self.templates {
let file_path = self.template_path_from_name(name, output_dir);
std::fs::write(&file_path, content).map_err(|e| {
TemplateError::IoError(format!("Failed to write template '{}': {}", name, e))
})?;
}
Ok(())
}
fn template_path_from_name(&self, name: &str, base_dir: &Path) -> PathBuf {
match &self.organization {
TemplateOrganization::Flat => base_dir.join(format!("{}.toml", name)),
TemplateOrganization::Hierarchical => {
let path_str = name.replace('.', "/");
base_dir.join(format!("{}.toml", path_str))
}
TemplateOrganization::Custom { prefix } => {
let path_part = if name.starts_with(&format!("{}.", prefix)) {
&name[prefix.len() + 1..]
} else {
name
};
let path_str = path_part.replace('.', "/");
base_dir.join(format!("{}.toml", path_str))
}
}
}
}
pub struct TemplateLoaderBuilder {
discovery: TemplateDiscovery,
}
impl TemplateLoaderBuilder {
pub fn new() -> Self {
Self {
discovery: TemplateDiscovery::new(),
}
}
pub fn search_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.discovery
.search_paths
.push(path.as_ref().to_path_buf());
self
}
pub fn glob_pattern(mut self, pattern: &str) -> Self {
self.discovery.glob_patterns.push(pattern.to_string());
self
}
pub fn namespace<S: Into<String>>(mut self, name: S, content: S) -> Self {
self.discovery
.namespaces
.insert(name.into(), content.into());
self
}
pub fn hot_reload(mut self) -> Self {
self.discovery.hot_reload = true;
self
}
pub fn organization(mut self, organization: TemplateOrganization) -> Self {
self.discovery.organization = organization;
self
}
pub fn build(self) -> Result<TemplateLoader> {
self.discovery.load()
}
}
impl Default for TemplateLoaderBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_template_discovery_basic() -> Result<()> {
let temp_dir = tempdir()?;
let template_file = temp_dir.path().join("test.toml");
std::fs::write(&template_file, "name = \"{{ test_var }}\"")?;
let discovery = TemplateDiscovery::new()
.with_search_path(&temp_dir)
.recursive(false);
let loader = discovery.load()?;
assert!(loader.has_template("test"));
assert_eq!(
loader.get_template("test"),
Some("name = \"{{ test_var }}\"")
);
Ok(())
}
#[test]
fn test_template_discovery_with_namespace() -> Result<()> {
let discovery = TemplateDiscovery::new()
.with_namespace("macros", "{% macro test() %}Hello{% endmacro %}");
let loader = discovery.load()?;
assert!(loader.has_template("macros"));
assert_eq!(
loader.get_template("macros"),
Some("{% macro test() %}Hello{% endmacro %}")
);
Ok(())
}
#[test]
fn test_template_loader_rendering() -> Result<()> {
let temp_dir = tempdir()?;
let template_file = temp_dir.path().join("config.toml");
std::fs::write(&template_file, "service = \"{{ svc }}\"")?;
let discovery = TemplateDiscovery::new().with_search_path(&temp_dir);
let loader = discovery.load()?;
let mut vars = std::collections::HashMap::new();
vars.insert(
"svc".to_string(),
serde_json::Value::String("test-service".to_string()),
);
let result = loader.render_with_vars("config", vars)?;
assert_eq!(result.trim(), "service = \"test-service\"");
Ok(())
}
#[test]
fn test_hierarchical_organization() -> Result<()> {
let temp_dir = tempdir()?;
let subdir = temp_dir.path().join("services");
std::fs::create_dir_all(&subdir)?;
let template_file = subdir.join("api.toml");
std::fs::write(&template_file, "service = \"api\"")?;
let discovery = TemplateDiscovery::new()
.with_search_path(&temp_dir)
.with_organization(TemplateOrganization::Hierarchical);
let loader = discovery.load()?;
assert!(loader.has_template("services.api"));
Ok(())
}
}