use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanTemplate {
pub template_id: String,
pub name: String,
pub description: String,
pub content: String,
pub category: Option<String>,
pub tags: Vec<String>,
pub variables: Vec<String>,
pub source_plan_id: Option<String>,
pub usage_count: u32,
pub created_at: i64,
pub last_used_at: Option<i64>,
}
impl PlanTemplate {
pub fn new(name: String, description: String, content: String) -> Self {
let now = chrono::Utc::now().timestamp();
let template_id = uuid::Uuid::new_v4().to_string();
let variables = Self::extract_variables(&content);
Self {
template_id,
name,
description,
content,
category: None,
tags: vec![],
variables,
source_plan_id: None,
usage_count: 0,
created_at: now,
last_used_at: None,
}
}
pub fn from_plan(
name: String,
description: String,
plan_content: String,
plan_id: String,
) -> Self {
let mut template = Self::new(name, description, plan_content);
template.source_plan_id = Some(plan_id);
template
}
pub fn with_category(mut self, category: String) -> Self {
self.category = Some(category);
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
fn extract_variables(content: &str) -> Vec<String> {
use regex::Regex;
use std::sync::LazyLock;
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}").expect("valid regex"));
let re = &*RE;
let mut vars: Vec<String> = re
.captures_iter(content)
.map(|cap| cap[1].to_string())
.collect();
vars.sort();
vars.dedup();
vars
}
pub fn instantiate(&self, substitutions: &std::collections::HashMap<String, String>) -> String {
let mut result = self.content.clone();
for (var, value) in substitutions {
let placeholder = format!("{{{{{}}}}}", var);
result = result.replace(&placeholder, value);
}
result
}
pub fn mark_used(&mut self) {
self.usage_count += 1;
self.last_used_at = Some(chrono::Utc::now().timestamp());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct TemplateData {
templates: Vec<PlanTemplate>,
}
pub struct TemplateStore {
file_path: PathBuf,
}
impl TemplateStore {
pub fn new(data_dir: impl AsRef<Path>) -> Result<Self> {
let data_dir = data_dir.as_ref();
std::fs::create_dir_all(data_dir)?;
let file_path = data_dir.join("templates.json");
Ok(Self { file_path })
}
fn load(&self) -> Result<TemplateData> {
if !self.file_path.exists() {
return Ok(TemplateData::default());
}
let content =
std::fs::read_to_string(&self.file_path).context("Failed to read templates file")?;
let data: TemplateData =
serde_json::from_str(&content).context("Failed to parse templates file")?;
Ok(data)
}
fn save_data(&self, data: &TemplateData) -> Result<()> {
let content =
serde_json::to_string_pretty(data).context("Failed to serialize templates")?;
std::fs::write(&self.file_path, content).context("Failed to write templates file")?;
Ok(())
}
pub fn save(&self, template: &PlanTemplate) -> Result<()> {
let mut data = self.load()?;
data.templates
.retain(|t| t.template_id != template.template_id);
data.templates.push(template.clone());
self.save_data(&data)
}
pub fn get(&self, template_id: &str) -> Result<Option<PlanTemplate>> {
let data = self.load()?;
Ok(data
.templates
.into_iter()
.find(|t| t.template_id == template_id))
}
pub fn get_by_name(&self, name: &str) -> Result<Option<PlanTemplate>> {
let data = self.load()?;
let name_lower = name.to_lowercase();
Ok(data.templates.into_iter().find(|t| {
t.name.to_lowercase().contains(&name_lower) || t.template_id.starts_with(name)
}))
}
pub fn list(&self) -> Result<Vec<PlanTemplate>> {
let data = self.load()?;
let mut templates = data.templates;
templates.sort_by(|a, b| {
b.usage_count
.cmp(&a.usage_count)
.then_with(|| a.name.cmp(&b.name))
});
Ok(templates)
}
pub fn list_by_category(&self, category: &str) -> Result<Vec<PlanTemplate>> {
let all = self.list()?;
Ok(all
.into_iter()
.filter(|t| t.category.as_deref() == Some(category))
.collect())
}
pub fn search(&self, query: &str) -> Result<Vec<PlanTemplate>> {
let all = self.list()?;
let query_lower = query.to_lowercase();
Ok(all
.into_iter()
.filter(|t| {
t.name.to_lowercase().contains(&query_lower)
|| t.description.to_lowercase().contains(&query_lower)
|| t.tags
.iter()
.any(|tag| tag.to_lowercase().contains(&query_lower))
})
.collect())
}
pub fn delete(&self, template_id: &str) -> Result<bool> {
let mut data = self.load()?;
let original_len = data.templates.len();
data.templates.retain(|t| t.template_id != template_id);
let deleted = data.templates.len() < original_len;
if deleted {
self.save_data(&data)?;
}
Ok(deleted)
}
pub fn update(&self, template: &PlanTemplate) -> Result<()> {
self.save(template)
}
pub fn mark_used(&self, template_id: &str) -> Result<()> {
if let Some(mut template) = self.get(template_id)? {
template.mark_used();
self.save(&template)?;
}
Ok(())
}
}
impl TemplateStore {
#[cfg(feature = "native")]
pub fn with_default_dir() -> Result<Self> {
let data_dir = dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".brainwires"));
let data_dir = data_dir.join("brainwires");
Self::new(data_dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_template_creation() {
let template = PlanTemplate::new(
"Feature Implementation".to_string(),
"Template for implementing new features".to_string(),
"1. Create {{component}} component\n2. Add tests for {{feature}}".to_string(),
);
assert!(!template.template_id.is_empty());
assert_eq!(template.name, "Feature Implementation");
assert_eq!(template.variables.len(), 2);
assert!(template.variables.contains(&"component".to_string()));
assert!(template.variables.contains(&"feature".to_string()));
}
#[test]
fn test_template_instantiation() {
let template = PlanTemplate::new(
"Test".to_string(),
"Test template".to_string(),
"Implement {{feature}} in {{module}}".to_string(),
);
let mut subs = HashMap::new();
subs.insert("feature".to_string(), "authentication".to_string());
subs.insert("module".to_string(), "auth".to_string());
let result = template.instantiate(&subs);
assert_eq!(result, "Implement authentication in auth");
}
#[test]
fn test_extract_variables() {
let content = "{{var1}} and {{var2}} and {{var1}} again";
let vars = PlanTemplate::extract_variables(content);
assert_eq!(vars.len(), 2);
assert!(vars.contains(&"var1".to_string()));
assert!(vars.contains(&"var2".to_string()));
}
#[test]
fn test_from_plan() {
let template = PlanTemplate::from_plan(
"My Template".to_string(),
"Description".to_string(),
"Content".to_string(),
"plan-123".to_string(),
);
assert_eq!(template.source_plan_id, Some("plan-123".to_string()));
}
#[test]
fn test_mark_used() {
let mut template = PlanTemplate::new(
"Test".to_string(),
"Test".to_string(),
"Content".to_string(),
);
assert_eq!(template.usage_count, 0);
assert!(template.last_used_at.is_none());
template.mark_used();
assert_eq!(template.usage_count, 1);
assert!(template.last_used_at.is_some());
}
#[test]
fn test_with_category_and_tags() {
let template = PlanTemplate::new(
"Test".to_string(),
"Test".to_string(),
"Content".to_string(),
)
.with_category("feature".to_string())
.with_tags(vec!["rust".to_string(), "api".to_string()]);
assert_eq!(template.category, Some("feature".to_string()));
assert_eq!(template.tags.len(), 2);
}
}