use anyhow::{Context, Result};
use std::collections::HashMap;
use std::env;
use std::process::Command;
pub struct TemplateProcessor {
custom_variables: HashMap<String, String>,
}
impl TemplateProcessor {
pub fn new() -> Self {
Self {
custom_variables: HashMap::new(),
}
}
pub fn process(&self, template: &str, input: &str) -> Result<String> {
if !template.contains('{') {
if input.is_empty() {
return Ok(template.to_string());
} else {
return Ok(format!("{}\n\n{}", template, input));
}
}
let mut result = template.to_string();
if !input.is_empty()
&& (result.contains("{input}")
|| result.contains("{INPUT}")
|| result.contains("{content}")
|| result.contains("{CONTENT}"))
{
result = self.process_input_variables(&result, input);
}
if result.contains("{date}")
|| result.contains("{time}")
|| result.contains("{datetime}")
|| result.contains("{timestamp}")
|| result.contains("{iso_date}")
|| result.contains("{user}")
|| result.contains("{hostname}")
|| result.contains("{uuid}")
{
result = self.process_system_variables(&result)?;
}
if result.contains("{cwd}") || result.contains("{pwd}") || result.contains("{git_") {
result = self.process_context_variables(&result)?;
}
if result.contains("{env:") {
result = self.process_environment_variables(&result)?;
}
if !self.custom_variables.is_empty() {
result = self.process_custom_variables(&result);
}
Ok(result)
}
pub fn set_custom_variable(&mut self, name: &str, value: &str) {
self.custom_variables
.insert(name.to_string(), value.to_string());
}
pub fn remove_custom_variable(&mut self, name: &str) {
self.custom_variables.remove(name);
}
pub fn get_custom_variables(&self) -> &HashMap<String, String> {
&self.custom_variables
}
fn process_input_variables(&self, template: &str, input: &str) -> String {
let mut result = template.to_string();
result = result.replace("{input}", input);
result = result.replace("{INPUT}", input);
result = result.replace("{content}", input);
result = result.replace("{CONTENT}", input);
result
}
fn process_system_variables(&self, template: &str) -> Result<String> {
let mut result = template.to_string();
let now = chrono::Utc::now();
let local = chrono::Local::now();
result = result.replace("{date}", &now.format("%Y-%m-%d").to_string());
result = result.replace("{time}", &local.format("%H:%M:%S").to_string());
result = result.replace("{datetime}", &local.format("%Y-%m-%d %H:%M:%S").to_string());
result = result.replace("{timestamp}", &now.timestamp().to_string());
result = result.replace("{iso_date}", &now.to_rfc3339());
result = result.replace(
"{user}",
&env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
);
result = result.replace("{hostname}", &gethostname::gethostname().to_string_lossy());
result = result.replace("{uuid}", &uuid::Uuid::new_v4().to_string());
Ok(result)
}
fn process_context_variables(&self, template: &str) -> Result<String> {
let mut result = template.to_string();
if let Ok(cwd) = env::current_dir() {
result = result.replace("{cwd}", &cwd.to_string_lossy());
if let Some(dir_name) = cwd.file_name() {
result = result.replace("{pwd}", &dir_name.to_string_lossy());
}
}
result = self.process_git_variables(&result)?;
Ok(result)
}
fn process_git_variables(&self, template: &str) -> Result<String> {
let mut result = template.to_string();
if let Ok(output) = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
{
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
result = result.replace("{git_branch}", &branch);
} else {
result = result.replace("{git_branch}", "");
}
} else {
result = result.replace("{git_branch}", "");
}
if let Ok(output) = Command::new("git").args(["status", "--porcelain"]).output() {
if output.status.success() {
let status = if output.stdout.is_empty() {
"clean"
} else {
"dirty"
};
result = result.replace("{git_status}", status);
} else {
result = result.replace("{git_status}", "");
}
} else {
result = result.replace("{git_status}", "");
}
if let Ok(output) = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
{
if output.status.success() {
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
result = result.replace("{git_hash}", &hash);
} else {
result = result.replace("{git_hash}", "");
}
} else {
result = result.replace("{git_hash}", "");
}
Ok(result)
}
fn process_environment_variables(&self, template: &str) -> Result<String> {
let mut result = template.to_string();
let env_regex = regex::Regex::new(r"\{env:([^}]+)\}").unwrap();
for caps in env_regex.captures_iter(template) {
let full_match = &caps[0];
let var_name = &caps[1];
let value = env::var(var_name).unwrap_or_else(|_| {
eprintln!("Warning: Environment variable '{}' not found", var_name);
String::new()
});
result = result.replace(full_match, &value);
}
Ok(result)
}
fn process_custom_variables(&self, template: &str) -> String {
let mut result = template.to_string();
for (name, value) in &self.custom_variables {
let placeholder = format!("{{{}}}", name);
result = result.replace(&placeholder, value);
}
result
}
pub fn load_config(&mut self, config_path: &std::path::Path) -> Result<()> {
if !config_path.exists() {
return Ok(()); }
let content = std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len() - 1]
} else {
value
};
self.custom_variables
.insert(key.to_string(), value.to_string());
}
}
Ok(())
}
pub fn save_config(&self, config_path: &std::path::Path) -> Result<()> {
let mut content = String::new();
content.push_str("# PromptHive Template Variables Configuration\n");
content.push_str("# Format: variable_name=value\n");
content.push_str("# Use quotes for values with spaces\n\n");
let mut vars: Vec<_> = self.custom_variables.iter().collect();
vars.sort_by_key(|(k, _)| *k);
for (name, value) in vars {
if value.contains(' ') || value.contains('\t') {
content.push_str(&format!("{}=\"{}\"\n", name, value));
} else {
content.push_str(&format!("{}={}\n", name, value));
}
}
std::fs::write(config_path, content)
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
Ok(())
}
pub fn list_available_variables(&self) -> Vec<(String, String)> {
let mut vars = vec![
(
"{date}".to_string(),
"Current date (YYYY-MM-DD)".to_string(),
),
("{time}".to_string(), "Current time (HH:MM:SS)".to_string()),
(
"{datetime}".to_string(),
"Current date and time".to_string(),
),
("{timestamp}".to_string(), "Unix timestamp".to_string()),
("{iso_date}".to_string(), "ISO 8601 date/time".to_string()),
("{user}".to_string(), "Current username".to_string()),
("{hostname}".to_string(), "System hostname".to_string()),
("{uuid}".to_string(), "Random UUID".to_string()),
(
"{cwd}".to_string(),
"Current working directory (full path)".to_string(),
),
("{pwd}".to_string(), "Current directory name".to_string()),
("{git_branch}".to_string(), "Current git branch".to_string()),
(
"{git_status}".to_string(),
"Git status (clean/dirty)".to_string(),
),
(
"{git_hash}".to_string(),
"Git commit hash (short)".to_string(),
),
("{input}".to_string(), "Input content".to_string()),
("{content}".to_string(), "Input content (alias)".to_string()),
(
"{env:VAR_NAME}".to_string(),
"Environment variable".to_string(),
),
];
for (name, value) in &self.custom_variables {
vars.push((format!("{{{}}}", name), format!("Custom: {}", value)));
}
vars.sort_by(|a, b| a.0.cmp(&b.0));
vars
}
}
impl Default for TemplateProcessor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_input_variables() {
let processor = TemplateProcessor::new();
let result = processor
.process("Process this: {input}", "test data")
.unwrap();
assert_eq!(result, "Process this: test data");
let result = processor.process("Content: {content}", "hello").unwrap();
assert_eq!(result, "Content: hello");
}
#[test]
fn test_system_variables() {
let processor = TemplateProcessor::new();
let result = processor.process("Today is {date}", "").unwrap();
assert!(result.contains("Today is 2"));
let result = processor.process("User: {user}", "").unwrap();
assert!(result.starts_with("User: "));
}
#[test]
fn test_environment_variables() {
let processor = TemplateProcessor::new();
env::set_var("TEST_VAR", "test_value");
let result = processor.process("Env: {env:TEST_VAR}", "").unwrap();
assert_eq!(result, "Env: test_value");
let result = processor.process("Missing: {env:NONEXISTENT}", "").unwrap();
assert_eq!(result, "Missing: ");
}
#[test]
fn test_custom_variables() {
let mut processor = TemplateProcessor::new();
processor.set_custom_variable("project", "PromptHive");
let result = processor.process("Working on {project}", "").unwrap();
assert_eq!(result, "Working on PromptHive");
}
#[test]
fn test_config_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("variables.conf");
let mut processor = TemplateProcessor::new();
processor.set_custom_variable("author", "John Doe");
processor.set_custom_variable("project", "Test Project");
processor.save_config(&config_path).unwrap();
let mut new_processor = TemplateProcessor::new();
new_processor.load_config(&config_path).unwrap();
assert_eq!(
new_processor.custom_variables.get("author").unwrap(),
"John Doe"
);
assert_eq!(
new_processor.custom_variables.get("project").unwrap(),
"Test Project"
);
}
#[test]
fn test_legacy_behavior() {
let processor = TemplateProcessor::new();
let result = processor.process("Simple prompt", "input data").unwrap();
assert_eq!(result, "Simple prompt\n\ninput data");
let result = processor.process("Process {input}", "input data").unwrap();
assert_eq!(result, "Process input data");
}
}