use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateMetadata {
pub id: String,
pub name: String,
pub description: Option<String>,
pub version: String,
pub author: Option<String>,
pub tags: Vec<String>,
pub category: Option<String>,
pub content: String,
pub example: Option<String>,
pub dependencies: Vec<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateVersion {
pub version: String,
pub content: String,
pub changelog: Option<String>,
pub prerelease: bool,
pub released_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateLibraryEntry {
pub id: String,
pub name: String,
pub description: Option<String>,
pub author: Option<String>,
pub tags: Vec<String>,
pub category: Option<String>,
pub versions: Vec<TemplateVersion>,
pub latest_version: String,
pub dependencies: Vec<String>,
pub example: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
pub struct TemplateLibrary {
storage_dir: PathBuf,
templates: HashMap<String, TemplateLibraryEntry>,
}
impl TemplateLibrary {
pub fn new(storage_dir: impl AsRef<Path>) -> Result<Self> {
let storage_dir = storage_dir.as_ref().to_path_buf();
std::fs::create_dir_all(&storage_dir).map_err(|e| {
Error::io_with_context(
format!("creating template library directory {}", storage_dir.display()),
e.to_string(),
)
})?;
let mut library = Self {
storage_dir,
templates: HashMap::new(),
};
library.load_templates()?;
Ok(library)
}
fn load_templates(&mut self) -> Result<()> {
let templates_dir = self.storage_dir.join("templates");
if !templates_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(&templates_dir)
.map_err(|e| Error::io_with_context("reading templates directory", e.to_string()))?
{
let entry = entry
.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
match self.load_template_file(&path) {
Ok(Some(template)) => {
let id = template.id.clone();
self.templates.insert(id, template);
}
Ok(None) => {
}
Err(e) => {
warn!("Failed to load template from {}: {}", path.display(), e);
}
}
}
}
info!("Loaded {} template(s) from library", self.templates.len());
Ok(())
}
fn load_template_file(&self, path: &Path) -> Result<Option<TemplateLibraryEntry>> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::io_with_context(
format!("reading template file {}", path.display()),
e.to_string(),
)
})?;
let template: TemplateLibraryEntry = serde_json::from_str(&content).map_err(|e| {
Error::config(format!("Failed to parse template file {}: {}", path.display(), e))
})?;
Ok(Some(template))
}
pub fn register_template(&mut self, metadata: TemplateMetadata) -> Result<()> {
let template_id = metadata.id.clone();
let entry = if let Some(existing) = self.templates.get_mut(&template_id) {
let version = TemplateVersion {
version: metadata.version.clone(),
content: metadata.content.clone(),
changelog: None,
prerelease: false,
released_at: chrono::Utc::now().to_rfc3339(),
};
existing.versions.push(version);
existing.versions.sort_by(|a, b| {
b.version.cmp(&a.version)
});
existing.latest_version = metadata.version.clone();
existing.updated_at = Some(chrono::Utc::now().to_rfc3339());
existing.clone()
} else {
let version = TemplateVersion {
version: metadata.version.clone(),
content: metadata.content.clone(),
changelog: None,
prerelease: false,
released_at: chrono::Utc::now().to_rfc3339(),
};
TemplateLibraryEntry {
id: metadata.id.clone(),
name: metadata.name.clone(),
description: metadata.description.clone(),
author: metadata.author.clone(),
tags: metadata.tags.clone(),
category: metadata.category.clone(),
versions: vec![version],
latest_version: metadata.version.clone(),
dependencies: metadata.dependencies.clone(),
example: metadata.example.clone(),
created_at: Some(chrono::Utc::now().to_rfc3339()),
updated_at: Some(chrono::Utc::now().to_rfc3339()),
}
};
self.save_template(&entry)?;
self.templates.insert(template_id, entry);
Ok(())
}
fn save_template(&self, template: &TemplateLibraryEntry) -> Result<()> {
let templates_dir = self.storage_dir.join("templates");
std::fs::create_dir_all(&templates_dir)
.map_err(|e| Error::io_with_context("creating templates directory", e.to_string()))?;
let file_path = templates_dir.join(format!("{}.json", template.id));
let json = serde_json::to_string_pretty(template)
.map_err(|e| Error::config(format!("Failed to serialize template: {}", e)))?;
std::fs::write(&file_path, json)
.map_err(|e| Error::io_with_context("writing template file", e.to_string()))?;
debug!("Saved template {} to {}", template.id, file_path.display());
Ok(())
}
pub fn get_template(&self, id: &str) -> Option<&TemplateLibraryEntry> {
self.templates.get(id)
}
pub fn get_template_version(&self, id: &str, version: &str) -> Option<String> {
self.templates
.get(id)
.and_then(|entry| entry.versions.iter().find(|v| v.version == version))
.map(|v| v.content.clone())
}
pub fn get_latest_template(&self, id: &str) -> Option<String> {
self.templates.get(id).map(|entry| {
entry.versions.first().map(|v| v.content.clone()).unwrap_or_else(|| {
self.get_template_version(id, &entry.latest_version).unwrap_or_default()
})
})
}
pub fn list_templates(&self) -> Vec<&TemplateLibraryEntry> {
self.templates.values().collect()
}
pub fn search_templates(&self, query: &str) -> Vec<&TemplateLibraryEntry> {
let query_lower = query.to_lowercase();
self.templates
.values()
.filter(|template| {
template.name.to_lowercase().contains(&query_lower)
|| template
.description
.as_ref()
.map(|d| d.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| template.tags.iter().any(|tag| tag.to_lowercase().contains(&query_lower))
|| template
.category
.as_ref()
.map(|c| c.to_lowercase().contains(&query_lower))
.unwrap_or(false)
})
.collect()
}
pub fn templates_by_category(&self, category: &str) -> Vec<&TemplateLibraryEntry> {
self.templates
.values()
.filter(|template| {
template
.category
.as_ref()
.map(|c| c.eq_ignore_ascii_case(category))
.unwrap_or(false)
})
.collect()
}
pub fn remove_template(&mut self, id: &str) -> Result<()> {
if self.templates.remove(id).is_some() {
let file_path = self.storage_dir.join("templates").join(format!("{}.json", id));
if file_path.exists() {
std::fs::remove_file(&file_path)
.map_err(|e| Error::io_with_context("removing template file", e.to_string()))?;
}
info!("Removed template: {}", id);
}
Ok(())
}
pub fn remove_template_version(&mut self, id: &str, version: &str) -> Result<()> {
if let Some(template) = self.templates.get_mut(id) {
template.versions.retain(|v| v.version != version);
if template.versions.is_empty() {
self.remove_template(id)?;
} else {
template.versions.sort_by(|a, b| b.version.cmp(&a.version));
template.latest_version =
template.versions.first().map(|v| v.version.clone()).unwrap_or_default();
template.updated_at = Some(chrono::Utc::now().to_rfc3339());
let template_clone = template.clone();
let _ = template;
self.save_template(&template_clone)?;
}
}
Ok(())
}
pub fn storage_dir(&self) -> &Path {
&self.storage_dir
}
}
pub struct TemplateMarketplace {
registry_url: String,
auth_token: Option<String>,
}
impl TemplateMarketplace {
pub fn new(registry_url: String, auth_token: Option<String>) -> Self {
Self {
registry_url,
auth_token,
}
}
pub async fn search(&self, query: &str) -> Result<Vec<TemplateLibraryEntry>> {
let encoded_query = urlencoding::encode(query);
let url = format!("{}/api/templates/search?q={}", self.registry_url, encoded_query);
let mut request = reqwest::Client::new().get(&url);
if let Some(ref token) = self.auth_token {
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|e| Error::internal(format!("Failed to search marketplace: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"Marketplace search failed with status: {}",
response.status()
)));
}
let templates: Vec<TemplateLibraryEntry> = response
.json()
.await
.map_err(|e| Error::internal(format!("Failed to parse marketplace response: {}", e)))?;
Ok(templates)
}
pub async fn get_template(
&self,
id: &str,
version: Option<&str>,
) -> Result<TemplateLibraryEntry> {
let url = if let Some(version) = version {
format!("{}/api/templates/{}/{}", self.registry_url, id, version)
} else {
format!("{}/api/templates/{}", self.registry_url, id)
};
let mut request = reqwest::Client::new().get(&url);
if let Some(ref token) = self.auth_token {
request = request.bearer_auth(token);
}
let response = request.send().await.map_err(|e| {
Error::internal(format!("Failed to fetch template from marketplace: {}", e))
})?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"Failed to fetch template: {}",
response.status()
)));
}
let template: TemplateLibraryEntry = response
.json()
.await
.map_err(|e| Error::config(format!("Failed to parse template: {}", e)))?;
Ok(template)
}
pub async fn list_featured(&self) -> Result<Vec<TemplateLibraryEntry>> {
let url = format!("{}/api/templates/featured", self.registry_url);
let mut request = reqwest::Client::new().get(&url);
if let Some(ref token) = self.auth_token {
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|e| Error::internal(format!("Failed to fetch featured templates: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"Failed to fetch featured templates: {}",
response.status()
)));
}
let templates: Vec<TemplateLibraryEntry> = response
.json()
.await
.map_err(|e| Error::config(format!("Failed to parse featured templates: {}", e)))?;
Ok(templates)
}
pub async fn list_by_category(&self, category: &str) -> Result<Vec<TemplateLibraryEntry>> {
let encoded_category = urlencoding::encode(category);
let url = format!("{}/api/templates/category/{}", self.registry_url, encoded_category);
let mut request = reqwest::Client::new().get(&url);
if let Some(ref token) = self.auth_token {
request = request.bearer_auth(token);
}
let response = request.send().await.map_err(|e| {
Error::internal(format!("Failed to fetch templates by category: {}", e))
})?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"Failed to fetch templates by category: {}",
response.status()
)));
}
let templates: Vec<TemplateLibraryEntry> = response
.json()
.await
.map_err(|e| Error::config(format!("Failed to parse templates: {}", e)))?;
Ok(templates)
}
}
pub struct TemplateLibraryManager {
library: TemplateLibrary,
marketplace: Option<TemplateMarketplace>,
}
impl TemplateLibraryManager {
pub fn new(storage_dir: impl AsRef<Path>) -> Result<Self> {
let library = TemplateLibrary::new(storage_dir)?;
Ok(Self {
library,
marketplace: None,
})
}
pub fn with_marketplace(mut self, registry_url: String, auth_token: Option<String>) -> Self {
self.marketplace = Some(TemplateMarketplace::new(registry_url, auth_token));
self
}
pub async fn install_from_marketplace(
&mut self,
id: &str,
version: Option<&str>,
) -> Result<()> {
let marketplace = self
.marketplace
.as_ref()
.ok_or_else(|| Error::config("Marketplace not configured"))?;
let template = marketplace.get_template(id, version).await?;
let latest_version = template
.versions
.first()
.ok_or_else(|| Error::not_found("template version", id))?;
let metadata = TemplateMetadata {
id: template.id.clone(),
name: template.name.clone(),
description: template.description.clone(),
version: latest_version.version.clone(),
author: template.author.clone(),
tags: template.tags.clone(),
category: template.category.clone(),
content: latest_version.content.clone(),
example: template.example.clone(),
dependencies: template.dependencies.clone(),
created_at: template.created_at.clone(),
updated_at: template.updated_at.clone(),
};
self.library.register_template(metadata)?;
info!("Installed template {} from marketplace", id);
Ok(())
}
pub fn library(&self) -> &TemplateLibrary {
&self.library
}
pub fn library_mut(&mut self) -> &mut TemplateLibrary {
&mut self.library
}
pub fn marketplace(&self) -> Option<&TemplateMarketplace> {
self.marketplace.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_template_metadata() {
let metadata = TemplateMetadata {
id: "user-profile".to_string(),
name: "User Profile Template".to_string(),
description: Some("Template for user profile data".to_string()),
version: "1.0.0".to_string(),
author: Some("Test Author".to_string()),
tags: vec!["user".to_string(), "profile".to_string()],
category: Some("user".to_string()),
content: "{{faker.name}} - {{faker.email}}".to_string(),
example: Some("John Doe - john@example.com".to_string()),
dependencies: Vec::new(),
created_at: None,
updated_at: None,
};
assert_eq!(metadata.id, "user-profile");
assert_eq!(metadata.version, "1.0.0");
}
#[tokio::test]
async fn test_template_library() {
let temp_dir = TempDir::new().unwrap();
let library = TemplateLibrary::new(temp_dir.path()).unwrap();
let metadata = TemplateMetadata {
id: "test-template".to_string(),
name: "Test Template".to_string(),
description: None,
version: "1.0.0".to_string(),
author: None,
tags: Vec::new(),
category: None,
content: "{{uuid}}".to_string(),
example: None,
dependencies: Vec::new(),
created_at: None,
updated_at: None,
};
let mut library = library;
library.register_template(metadata).unwrap();
let template = library.get_template("test-template");
assert!(template.is_some());
assert_eq!(template.unwrap().name, "Test Template");
}
}