use super::ComposableWorkflow;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct TemplateRegistry {
templates: Arc<RwLock<HashMap<String, TemplateEntry>>>,
storage: Box<dyn TemplateStorage>,
}
impl Default for TemplateRegistry {
fn default() -> Self {
Self::new()
}
}
impl TemplateRegistry {
pub fn new() -> Self {
Self::with_storage(Box::new(FileTemplateStorage::new(PathBuf::from(
"templates",
))))
}
pub fn with_storage(storage: Box<dyn TemplateStorage>) -> Self {
Self {
templates: Arc::new(RwLock::new(HashMap::new())),
storage,
}
}
pub async fn register_template(
&self,
name: String,
template: ComposableWorkflow,
) -> Result<()> {
self.validate_template(&template)
.with_context(|| format!("Template '{}' validation failed", name))?;
let entry = TemplateEntry {
name: name.clone(),
template: template.clone(),
metadata: TemplateMetadata {
description: None,
author: None,
version: "1.0.0".to_string(),
tags: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
};
self.storage
.store(&name, &entry)
.await
.with_context(|| format!("Failed to store template '{}'", name))?;
self.templates.write().await.insert(name, entry);
Ok(())
}
pub async fn register_template_with_metadata(
&self,
name: String,
template: ComposableWorkflow,
metadata: TemplateMetadata,
) -> Result<()> {
self.validate_template(&template)
.with_context(|| format!("Template '{}' validation failed", name))?;
let entry = TemplateEntry {
name: name.clone(),
template: template.clone(),
metadata,
};
self.storage
.store(&name, &entry)
.await
.with_context(|| format!("Failed to store template '{}'", name))?;
self.templates.write().await.insert(name, entry);
Ok(())
}
pub async fn get(&self, name: &str) -> Result<ComposableWorkflow> {
{
let templates = self.templates.read().await;
if let Some(entry) = templates.get(name) {
return Ok(entry.template.clone());
}
}
let entry = self
.storage
.load(name)
.await
.with_context(|| format!("Template '{}' not found", name))?;
let template = entry.template.clone();
self.templates.write().await.insert(name.to_string(), entry);
Ok(template)
}
pub async fn get_with_metadata(&self, name: &str) -> Result<TemplateEntry> {
{
let templates = self.templates.read().await;
if let Some(entry) = templates.get(name) {
return Ok(entry.clone());
}
}
let entry = self
.storage
.load(name)
.await
.with_context(|| format!("Template '{}' not found", name))?;
self.templates
.write()
.await
.insert(name.to_string(), entry.clone());
Ok(entry)
}
pub async fn list(&self) -> Result<Vec<TemplateInfo>> {
self.storage.list().await
}
pub async fn search_by_tags(&self, tags: &[String]) -> Result<Vec<TemplateInfo>> {
let all_templates = self.list().await?;
Ok(all_templates
.into_iter()
.filter(|info| tags.iter().any(|tag| info.tags.contains(tag)))
.collect())
}
pub async fn delete(&self, name: &str) -> Result<()> {
self.storage
.delete(name)
.await
.with_context(|| format!("Failed to delete template '{}'", name))?;
self.templates.write().await.remove(name);
Ok(())
}
fn validate_template(&self, template: &ComposableWorkflow) -> Result<()> {
if let Some(params) = &template.parameters {
for param in ¶ms.required {
if param.default.is_none() && param.validation.is_none() {
tracing::warn!(
"Template parameter '{}' has no default and no validation",
param.name
);
}
}
}
if let Some(workflows) = &template.workflows {
for (name, sub) in workflows {
if !sub.source.exists() && !sub.source.to_str().unwrap_or("").starts_with("${") {
tracing::warn!(
"Template sub-workflow '{}' references non-existent source: {:?}",
name,
sub.source
);
}
}
}
Ok(())
}
pub async fn load_all(&self) -> Result<()> {
let templates = self.storage.list().await?;
for info in templates {
if let Ok(entry) = self.storage.load(&info.name).await {
self.templates
.write()
.await
.insert(info.name.clone(), entry);
}
}
Ok(())
}
}
#[async_trait]
pub trait TemplateStorage: Send + Sync {
async fn store(&self, name: &str, entry: &TemplateEntry) -> Result<()>;
async fn load(&self, name: &str) -> Result<TemplateEntry>;
async fn list(&self) -> Result<Vec<TemplateInfo>>;
async fn delete(&self, name: &str) -> Result<()>;
async fn exists(&self, name: &str) -> Result<bool>;
}
pub struct FileTemplateStorage {
base_dir: PathBuf,
}
impl FileTemplateStorage {
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
fn template_path(&self, name: &str) -> PathBuf {
self.base_dir.join(format!("{}.yml", name))
}
fn metadata_path(&self, name: &str) -> PathBuf {
self.base_dir.join(format!("{}.meta.json", name))
}
async fn load_template_yaml(&self, name: &str) -> Result<ComposableWorkflow> {
let template_path = self.template_path(name);
let template_content = tokio::fs::read_to_string(&template_path)
.await
.with_context(|| format!("Failed to read template file: {:?}", template_path))?;
serde_yaml::from_str(&template_content)
.with_context(|| format!("Failed to parse template YAML: {:?}", template_path))
}
async fn load_metadata_if_exists(&self, name: &str) -> Result<TemplateMetadata> {
let metadata_path = self.metadata_path(name);
if metadata_path.exists() {
let metadata_content = tokio::fs::read_to_string(&metadata_path)
.await
.with_context(|| format!("Failed to read metadata file: {:?}", metadata_path))?;
serde_json::from_str(&metadata_content)
.with_context(|| format!("Failed to parse metadata JSON: {:?}", metadata_path))
} else {
Ok(TemplateMetadata::default())
}
}
fn is_template_file(path: &std::path::Path) -> bool {
path.extension().and_then(|s| s.to_str()) == Some("yml")
}
fn extract_template_name(path: &std::path::Path) -> Option<String> {
let stem = path.file_stem().and_then(|s| s.to_str())?;
if stem.ends_with(".meta") {
return None;
}
Some(stem.to_string())
}
async fn load_template_metadata(&self, name: &str) -> TemplateMetadata {
let metadata_path = self.metadata_path(name);
if !metadata_path.exists() {
return TemplateMetadata::default();
}
match tokio::fs::read_to_string(&metadata_path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
tracing::warn!(
"Failed to parse metadata for template '{}' at {:?}: {}. Using default metadata.",
name,
metadata_path,
e
);
TemplateMetadata::default()
}),
Err(e) => {
tracing::warn!(
"Failed to read metadata file for template '{}' at {:?}: {}. Using default metadata.",
name,
metadata_path,
e
);
TemplateMetadata::default()
}
}
}
}
#[async_trait]
impl TemplateStorage for FileTemplateStorage {
async fn store(&self, name: &str, entry: &TemplateEntry) -> Result<()> {
tokio::fs::create_dir_all(&self.base_dir)
.await
.with_context(|| format!("Failed to create template directory: {:?}", self.base_dir))?;
let template_path = self.template_path(name);
let template_yaml =
serde_yaml::to_string(&entry.template).context("Failed to serialize template")?;
tokio::fs::write(&template_path, template_yaml)
.await
.with_context(|| format!("Failed to write template file: {:?}", template_path))?;
let metadata_path = self.metadata_path(name);
let metadata_json = serde_json::to_string_pretty(&entry.metadata)
.context("Failed to serialize metadata")?;
tokio::fs::write(&metadata_path, metadata_json)
.await
.with_context(|| format!("Failed to write metadata file: {:?}", metadata_path))?;
Ok(())
}
async fn load(&self, name: &str) -> Result<TemplateEntry> {
let template = self.load_template_yaml(name).await?;
let metadata = self.load_metadata_if_exists(name).await?;
Ok(TemplateEntry {
name: name.to_string(),
template,
metadata,
})
}
async fn list(&self) -> Result<Vec<TemplateInfo>> {
let mut templates = Vec::new();
if !self.base_dir.exists() {
return Ok(templates);
}
let mut entries = tokio::fs::read_dir(&self.base_dir)
.await
.with_context(|| format!("Failed to read template directory: {:?}", self.base_dir))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !Self::is_template_file(&path) {
continue;
}
let Some(name) = Self::extract_template_name(&path) else {
continue;
};
let metadata = self.load_template_metadata(&name).await;
templates.push(TemplateInfo {
name,
description: metadata.description.clone(),
version: metadata.version.clone(),
tags: metadata.tags.clone(),
});
}
Ok(templates)
}
async fn delete(&self, name: &str) -> Result<()> {
let template_path = self.template_path(name);
if template_path.exists() {
tokio::fs::remove_file(&template_path)
.await
.with_context(|| format!("Failed to delete template file: {:?}", template_path))?;
}
let metadata_path = self.metadata_path(name);
if metadata_path.exists() {
tokio::fs::remove_file(&metadata_path)
.await
.with_context(|| format!("Failed to delete metadata file: {:?}", metadata_path))?;
}
Ok(())
}
async fn exists(&self, name: &str) -> Result<bool> {
Ok(self.template_path(name).exists())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateEntry {
pub name: String,
pub template: ComposableWorkflow,
pub metadata: TemplateMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateMetadata {
pub description: Option<String>,
pub author: Option<String>,
pub version: String,
pub tags: Vec<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl Default for TemplateMetadata {
fn default() -> Self {
Self {
description: None,
author: None,
version: "1.0.0".to_string(),
tags: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateInfo {
pub name: String,
pub description: Option<String>,
pub version: String,
pub tags: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_template_registry() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = Box::new(FileTemplateStorage::new(temp_dir.path().to_path_buf()));
let registry = TemplateRegistry::with_storage(storage);
let workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
registry
.register_template("test-template".to_string(), workflow.clone())
.await
.unwrap();
let retrieved = registry.get("test-template").await.unwrap();
assert_eq!(
retrieved.config.commands.len(),
workflow.config.commands.len()
);
}
#[test]
fn test_template_metadata() {
let metadata = TemplateMetadata {
description: Some("Test template".to_string()),
author: Some("Test Author".to_string()),
version: "2.0.0".to_string(),
tags: vec!["test".to_string(), "example".to_string()],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
assert_eq!(metadata.version, "2.0.0");
assert_eq!(metadata.tags.len(), 2);
}
#[tokio::test]
async fn test_file_template_storage_load_with_metadata() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
let workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
let metadata = TemplateMetadata {
description: Some("Test description".to_string()),
author: Some("Test Author".to_string()),
version: "2.1.0".to_string(),
tags: vec!["test".to_string()],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let entry = TemplateEntry {
name: "test-template".to_string(),
template: workflow,
metadata: metadata.clone(),
};
storage.store("test-template", &entry).await.unwrap();
let loaded = storage.load("test-template").await.unwrap();
assert_eq!(loaded.name, "test-template");
assert_eq!(
loaded.metadata.description,
Some("Test description".to_string())
);
assert_eq!(loaded.metadata.version, "2.1.0");
}
#[tokio::test]
async fn test_file_template_storage_load_without_metadata() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
let workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
tokio::fs::create_dir_all(temp_dir.path()).await.unwrap();
let template_yaml = serde_yaml::to_string(&workflow).unwrap();
let template_path = temp_dir.path().join("test-template.yml");
tokio::fs::write(&template_path, template_yaml)
.await
.unwrap();
let loaded = storage.load("test-template").await.unwrap();
assert_eq!(loaded.name, "test-template");
assert_eq!(loaded.metadata.version, "1.0.0"); assert_eq!(loaded.metadata.description, None); }
#[tokio::test]
async fn test_file_template_storage_load_missing_template() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
let result = storage.load("nonexistent-template").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to read template file"));
}
#[tokio::test]
async fn test_file_template_storage_load_invalid_yaml() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
tokio::fs::create_dir_all(temp_dir.path()).await.unwrap();
let template_path = temp_dir.path().join("invalid-template.yml");
tokio::fs::write(&template_path, "{ invalid yaml: [ unclosed")
.await
.unwrap();
let result = storage.load("invalid-template").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to parse template YAML"));
}
#[tokio::test]
async fn test_file_template_storage_load_invalid_metadata_json() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
let workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
tokio::fs::create_dir_all(temp_dir.path()).await.unwrap();
let template_yaml = serde_yaml::to_string(&workflow).unwrap();
let template_path = temp_dir.path().join("test-template.yml");
tokio::fs::write(&template_path, template_yaml)
.await
.unwrap();
let metadata_path = temp_dir.path().join("test-template.meta.json");
tokio::fs::write(&metadata_path, "{ invalid json: [ unclosed")
.await
.unwrap();
let result = storage.load("test-template").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to parse metadata JSON"));
}
#[tokio::test]
async fn test_file_template_storage_load_corrupted_metadata() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let storage = FileTemplateStorage::new(temp_dir.path().to_path_buf());
let workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
tokio::fs::create_dir_all(temp_dir.path()).await.unwrap();
let template_yaml = serde_yaml::to_string(&workflow).unwrap();
let template_path = temp_dir.path().join("test-template.yml");
tokio::fs::write(&template_path, template_yaml)
.await
.unwrap();
let metadata_path = temp_dir.path().join("test-template.meta.json");
tokio::fs::write(&metadata_path, r#"{"invalid": "structure"}"#)
.await
.unwrap();
let result = storage.load("test-template").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to parse metadata JSON"));
}
}