diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 3563dd9..ca7dd2f 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -20,6 +20,7 @@ mod task_execution; mod ui_writer_impl; mod utils; mod g3_status; +mod template; mod completion; use anyhow::Result; diff --git a/crates/g3-cli/src/project_files.rs b/crates/g3-cli/src/project_files.rs index 65975ee..19be8b4 100644 --- a/crates/g3-cli/src/project_files.rs +++ b/crates/g3-cli/src/project_files.rs @@ -5,6 +5,8 @@ use std::path::Path; use tracing::error; +use crate::template::process_template; + /// Read AGENTS.md configuration from the workspace directory. /// Returns formatted content with emoji prefix, or None if not found. pub fn read_agents_config(workspace_dir: &Path) -> Option { @@ -97,7 +99,10 @@ pub fn read_include_prompt(path: Option<&std::path::Path>) -> Option { } match std::fs::read_to_string(path) { - Ok(content) => Some(format!("📎 Included Prompt (from {}):\n{}", path.display(), content)), + Ok(content) => { + let processed = process_template(&content); + Some(format!("📎 Included Prompt (from {}):\n{}", path.display(), processed)) + } Err(e) => { tracing::error!("Failed to read include prompt file {}: {}", path.display(), e); None @@ -380,4 +385,23 @@ mod tests { // Cleanup let _ = std::fs::remove_file(&temp_file); } + + #[test] + fn test_read_include_prompt_with_template_variables() { + // Create a temp file with template variables + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("test_include_prompt_template.md"); + std::fs::write(&temp_file, "Today is {{today}} and {{unknown}} stays").unwrap(); + + let result = read_include_prompt(Some(&temp_file)); + assert!(result.is_some()); + let content = result.unwrap(); + + // {{today}} should be replaced with a date, {{unknown}} should remain + assert!(!content.contains("{{today}}")); + assert!(content.contains("{{unknown}}")); + + // Cleanup + let _ = std::fs::remove_file(&temp_file); + } } diff --git a/crates/g3-cli/src/template.rs b/crates/g3-cli/src/template.rs new file mode 100644 index 0000000..6038567 --- /dev/null +++ b/crates/g3-cli/src/template.rs @@ -0,0 +1,140 @@ +//! Template variable injection for included prompt files. +//! +//! Supports `{{var}}` syntax for variable substitution. +//! Currently supported variables: +//! - `today`: Current date in ISO format (YYYY-MM-DD) + +use chrono::Local; +use regex::Regex; +use std::collections::HashSet; + +/// Process template variables in the given content. +/// +/// Replaces `{{var}}` patterns with their values. +/// Warns about unknown variables and leaves them unchanged. +pub fn process_template(content: &str) -> String { + // Regex to match {{variable_name}} + let re = Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}").unwrap(); + + // Track unknown variables to warn only once per variable + let mut unknown_vars: HashSet = HashSet::new(); + + let result = re.replace_all(content, |caps: ®ex::Captures| { + let var_name = &caps[1]; + match resolve_variable(var_name) { + Some(value) => value, + None => { + if unknown_vars.insert(var_name.to_string()) { + tracing::warn!("Unknown template variable: {{{{{}}}}}", var_name); + } + // Leave unknown variables unchanged + caps[0].to_string() + } + } + }); + + result.into_owned() +} + +/// Resolve a template variable to its value. +fn resolve_variable(name: &str) -> Option { + match name { + "today" => Some(Local::now().format("%Y-%m-%d").to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_today_variable() { + let input = "Today is {{today}}"; + let result = process_template(input); + + // Should contain a date in YYYY-MM-DD format + assert!(!result.contains("{{today}}")); + assert!(result.starts_with("Today is ")); + + // Verify date format (YYYY-MM-DD) + let date_part = &result["Today is ".len()..]; + assert_eq!(date_part.len(), 10); + assert_eq!(&date_part[4..5], "-"); + assert_eq!(&date_part[7..8], "-"); + } + + #[test] + fn test_multiple_today_variables() { + let input = "Start: {{today}}, End: {{today}}"; + let result = process_template(input); + + // Both should be replaced + assert!(!result.contains("{{today}}")); + assert!(result.contains("Start: ")); + assert!(result.contains(", End: ")); + } + + #[test] + fn test_unknown_variable_unchanged() { + let input = "Hello {{unknown_var}}!"; + let result = process_template(input); + + // Unknown variable should remain unchanged + assert_eq!(result, "Hello {{unknown_var}}!"); + } + + #[test] + fn test_mixed_known_and_unknown() { + let input = "Date: {{today}}, Name: {{name}}"; + let result = process_template(input); + + // today should be replaced, name should remain + assert!(!result.contains("{{today}}")); + assert!(result.contains("{{name}}")); + } + + #[test] + fn test_no_variables() { + let input = "No variables here"; + let result = process_template(input); + + assert_eq!(result, "No variables here"); + } + + #[test] + fn test_empty_braces() { + let input = "Empty {{}} braces"; + let result = process_template(input); + + // Empty braces don't match the pattern, should remain unchanged + assert_eq!(result, "Empty {{}} braces"); + } + + #[test] + fn test_single_braces_ignored() { + let input = "Single {today} braces"; + let result = process_template(input); + + // Single braces should not be processed + assert_eq!(result, "Single {today} braces"); + } + + #[test] + fn test_variable_with_underscores() { + let input = "{{my_custom_var}}"; + let result = process_template(input); + + // Unknown but valid variable name, should remain unchanged + assert_eq!(result, "{{my_custom_var}}"); + } + + #[test] + fn test_variable_with_numbers() { + let input = "{{var123}}"; + let result = process_template(input); + + // Unknown but valid variable name, should remain unchanged + assert_eq!(result, "{{var123}}"); + } +}