Skip to main content

ralph/template/
variables.rs

1//! Template variable substitution for dynamic task fields.
2//!
3//! Responsibilities:
4//! - Define supported template variables ({{target}}, {{module}}, {{file}}, {{branch}}).
5//! - Substitute variables in template strings with context-aware values.
6//! - Auto-detect context from git and filesystem.
7//! - Validate templates and report warnings for unknown variables.
8//!
9//! Not handled here:
10//! - Template loading (see `loader.rs`).
11//! - Template merging (see `merge.rs`).
12//!
13//! Invariants/assumptions:
14//! - Variable syntax is {{variable_name}}.
15//! - Unknown variables are left as-is by default (not an error).
16//! - Use strict mode to fail on unknown variables.
17
18use std::collections::HashSet;
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use regex::Regex;
23
24/// Context for template variable substitution
25#[derive(Debug, Clone, Default)]
26pub struct TemplateContext {
27    /// The target file/path provided by user
28    pub target: Option<String>,
29    /// Module name derived from target (e.g., "src/cli/task.rs" -> "cli::task")
30    pub module: Option<String>,
31    /// Filename only (e.g., "src/cli/task.rs" -> "task.rs")
32    pub file: Option<String>,
33    /// Current git branch name
34    pub branch: Option<String>,
35}
36
37/// Warning types for template validation
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum TemplateWarning {
40    /// Unknown template variable found (variable name, optional field context)
41    UnknownVariable { name: String, field: Option<String> },
42    /// Git branch detection failed (error message)
43    GitBranchDetectionFailed { error: String },
44}
45
46impl std::fmt::Display for TemplateWarning {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            TemplateWarning::UnknownVariable { name, field: None } => {
50                write!(f, "Unknown template variable: {{{{{}}}}}", name)
51            }
52            TemplateWarning::UnknownVariable {
53                name,
54                field: Some(field),
55            } => {
56                write!(
57                    f,
58                    "Unknown template variable in {}: {{{{{}}}}}",
59                    field, name
60                )
61            }
62            TemplateWarning::GitBranchDetectionFailed { error } => {
63                write!(f, "Git branch detection failed: {}", error)
64            }
65        }
66    }
67}
68
69/// Result of template validation
70#[derive(Debug, Clone, Default)]
71pub struct TemplateValidation {
72    /// Warnings collected during validation
73    pub warnings: Vec<TemplateWarning>,
74    /// Whether the template uses {{branch}} variable
75    pub uses_branch: bool,
76}
77
78impl TemplateValidation {
79    /// Check if there are any unknown variable warnings
80    pub fn has_unknown_variables(&self) -> bool {
81        self.warnings
82            .iter()
83            .any(|w| matches!(w, TemplateWarning::UnknownVariable { .. }))
84    }
85
86    /// Get list of unknown variable names (deduplicated)
87    pub fn unknown_variable_names(&self) -> Vec<String> {
88        let mut names: Vec<String> = self
89            .warnings
90            .iter()
91            .filter_map(|w| match w {
92                TemplateWarning::UnknownVariable { name, .. } => Some(name.clone()),
93                _ => None,
94            })
95            .collect();
96        names.sort();
97        names.dedup();
98        names
99    }
100}
101
102/// The set of known/supported template variables
103const KNOWN_VARIABLES: &[&str] = &["target", "module", "file", "branch"];
104
105/// Extract template variable occurrences from a string
106///
107/// Returns a set of variable names found in the input (without braces).
108fn extract_variables(input: &str) -> HashSet<String> {
109    let mut variables = HashSet::new();
110    // Use lazy_static or thread_local for regex if performance is critical,
111    // but for template loading (not hot path), we can compile on demand.
112    // This function is called infrequently during template loading.
113    let re = match Regex::new(r"\{\{(\w+)\}\}") {
114        Ok(re) => re,
115        Err(_) => return variables, // Should never happen with static pattern
116    };
117
118    for cap in re.captures_iter(input) {
119        if let Some(matched) = cap.get(1) {
120            variables.insert(matched.as_str().to_string());
121        }
122    }
123    variables
124}
125
126/// Check if the input contains the {{branch}} variable
127fn uses_branch_variable(input: &str) -> bool {
128    input.contains("{{branch}}")
129}
130
131/// Validate a template task and collect warnings
132///
133/// This scans all string fields in the task for:
134/// - Unknown template variables (not in KNOWN_VARIABLES)
135/// - Presence of {{branch}} variable (to determine if git detection is needed)
136pub fn validate_task_template(task: &crate::contracts::Task) -> TemplateValidation {
137    let mut validation = TemplateValidation::default();
138    let mut all_variables: HashSet<String> = HashSet::new();
139
140    // Collect variables from all string fields
141    let fields = [
142        ("title", task.title.clone()),
143        ("request", task.request.clone().unwrap_or_default()),
144    ];
145
146    for (field_name, value) in fields.iter() {
147        if uses_branch_variable(value) {
148            validation.uses_branch = true;
149        }
150        let vars = extract_variables(value);
151        for var in &vars {
152            if !KNOWN_VARIABLES.contains(&var.as_str()) {
153                validation.warnings.push(TemplateWarning::UnknownVariable {
154                    name: var.clone(),
155                    field: Some(field_name.to_string()),
156                });
157            }
158            all_variables.insert(var.clone());
159        }
160    }
161
162    // Check array fields
163    let array_fields: [(&str, &[String]); 5] = [
164        ("tags", &task.tags),
165        ("scope", &task.scope),
166        ("evidence", &task.evidence),
167        ("plan", &task.plan),
168        ("notes", &task.notes),
169    ];
170
171    for (field_name, values) in array_fields.iter() {
172        for value in *values {
173            if uses_branch_variable(value) {
174                validation.uses_branch = true;
175            }
176            let vars = extract_variables(value);
177            for var in &vars {
178                if !KNOWN_VARIABLES.contains(&var.as_str()) {
179                    validation.warnings.push(TemplateWarning::UnknownVariable {
180                        name: var.clone(),
181                        field: Some(field_name.to_string()),
182                    });
183                }
184                all_variables.insert(var.clone());
185            }
186        }
187    }
188
189    validation
190}
191
192/// Detect context from target path and git repository
193///
194/// Returns the context and any warnings (e.g., git branch detection failures).
195/// Only attempts git branch detection if the template uses {{branch}}.
196pub fn detect_context_with_warnings(
197    target: Option<&str>,
198    repo_root: &Path,
199    needs_branch: bool,
200) -> (TemplateContext, Vec<TemplateWarning>) {
201    let mut warnings = Vec::new();
202    let target_opt = target.map(|s| s.to_string());
203
204    let file = target_opt.as_ref().map(|t| {
205        Path::new(t)
206            .file_name()
207            .map(|n| n.to_string_lossy().to_string())
208            .unwrap_or_else(|| t.clone())
209    });
210
211    let module = target_opt.as_ref().map(|t| derive_module_name(t));
212
213    let branch = if needs_branch {
214        match detect_git_branch(repo_root) {
215            Ok(branch_opt) => branch_opt,
216            Err(e) => {
217                warnings.push(TemplateWarning::GitBranchDetectionFailed {
218                    error: e.to_string(),
219                });
220                None
221            }
222        }
223    } else {
224        None
225    };
226
227    let context = TemplateContext {
228        target: target_opt,
229        file,
230        module,
231        branch,
232    };
233
234    (context, warnings)
235}
236
237/// Detect context from target path and git repository (legacy, ignores warnings)
238pub fn detect_context(target: Option<&str>, repo_root: &Path) -> TemplateContext {
239    let (context, _) = detect_context_with_warnings(target, repo_root, true);
240    context
241}
242
243/// Derive a module name from a file path
244///
245/// Examples:
246/// - "src/cli/task.rs" -> "cli::task"
247/// - "crates/ralph/src/main.rs" -> "ralph::main"
248/// - "lib/utils.js" -> "utils"
249fn derive_module_name(path: &str) -> String {
250    let path_obj = Path::new(path);
251
252    // Get the file stem (filename without extension)
253    let file_stem = path_obj
254        .file_stem()
255        .map(|s| s.to_string_lossy().to_string())
256        .unwrap_or_else(|| path.to_string());
257
258    // Collect path components that might be module names
259    let mut components: Vec<String> = Vec::new();
260
261    // Walk through parent directories looking for meaningful names
262    for component in path_obj.components() {
263        let comp_str = component.as_os_str().to_string_lossy().to_string();
264
265        // Skip common non-module directories
266        if comp_str == "src"
267            || comp_str == "lib"
268            || comp_str == "bin"
269            || comp_str == "tests"
270            || comp_str == "examples"
271            || comp_str == "crates"
272        {
273            continue;
274        }
275
276        // Skip the filename itself (we use file_stem separately)
277        if comp_str
278            == path_obj
279                .file_name()
280                .map(|n| n.to_string_lossy())
281                .unwrap_or_default()
282        {
283            continue;
284        }
285
286        components.push(comp_str);
287    }
288
289    // If we found meaningful components, combine with file stem
290    if !components.is_empty() {
291        components.push(file_stem);
292        components.join("::")
293    } else {
294        file_stem
295    }
296}
297
298/// Detect the current git branch name
299fn detect_git_branch(repo_root: &Path) -> Result<Option<String>> {
300    // Try to read from git HEAD
301    let head_path = repo_root.join(".git/HEAD");
302
303    if !head_path.exists() {
304        // Try to find .git in parent directories using git command
305        let output = std::process::Command::new("git")
306            .args(["rev-parse", "--abbrev-ref", "HEAD"])
307            .current_dir(repo_root)
308            .output()
309            .context("failed to execute git command")?;
310
311        if output.status.success() {
312            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
313            if branch != "HEAD" {
314                return Ok(Some(branch));
315            }
316        } else {
317            let stderr = String::from_utf8_lossy(&output.stderr);
318            return Err(anyhow::anyhow!("git rev-parse failed: {}", stderr.trim()));
319        }
320        return Ok(None);
321    }
322
323    let head_content = std::fs::read_to_string(&head_path)
324        .with_context(|| format!("failed to read {:?}", head_path))?;
325    let head_ref = head_content.trim();
326
327    // HEAD content is like: "ref: refs/heads/main"
328    if head_ref.starts_with("ref: refs/heads/") {
329        let branch = head_ref
330            .strip_prefix("ref: refs/heads/")
331            .unwrap_or(head_ref)
332            .to_string();
333        Ok(Some(branch))
334    } else if head_ref.len() == 40 && head_ref.chars().all(|c| c.is_ascii_hexdigit()) {
335        // Detached HEAD state (40-character hex commit SHA)
336        Ok(None)
337    } else if head_ref.is_empty() {
338        Err(anyhow::anyhow!("HEAD file is empty"))
339    } else {
340        // Invalid HEAD content
341        Err(anyhow::anyhow!("invalid HEAD content: {}", head_ref))
342    }
343}
344
345/// Substitute variables in a template string
346///
347/// Supported variables:
348/// - {{target}} - The target file/path provided by user
349/// - {{module}} - Module name derived from target
350/// - {{file}} - Filename only
351/// - {{branch}} - Current git branch name
352pub fn substitute_variables(input: &str, context: &TemplateContext) -> String {
353    let mut result = input.to_string();
354
355    if let Some(target) = &context.target {
356        result = result.replace("{{target}}", target);
357    }
358
359    if let Some(module) = &context.module {
360        result = result.replace("{{module}}", module);
361    }
362
363    if let Some(file) = &context.file {
364        result = result.replace("{{file}}", file);
365    }
366
367    if let Some(branch) = &context.branch {
368        result = result.replace("{{branch}}", branch);
369    }
370
371    result
372}
373
374/// Substitute variables in all string fields of a Task
375pub fn substitute_variables_in_task(task: &mut crate::contracts::Task, context: &TemplateContext) {
376    task.title = substitute_variables(&task.title, context);
377
378    for tag in &mut task.tags {
379        *tag = substitute_variables(tag, context);
380    }
381
382    for scope in &mut task.scope {
383        *scope = substitute_variables(scope, context);
384    }
385
386    for evidence in &mut task.evidence {
387        *evidence = substitute_variables(evidence, context);
388    }
389
390    for plan in &mut task.plan {
391        *plan = substitute_variables(plan, context);
392    }
393
394    for note in &mut task.notes {
395        *note = substitute_variables(note, context);
396    }
397
398    if let Some(request) = &mut task.request {
399        *request = substitute_variables(request, context);
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_substitute_variables_all_vars() {
409        let context = TemplateContext {
410            target: Some("src/cli/task.rs".to_string()),
411            module: Some("cli::task".to_string()),
412            file: Some("task.rs".to_string()),
413            branch: Some("main".to_string()),
414        };
415
416        let input =
417            "Add tests for {{target}} in module {{module}} (file: {{file}}) on branch {{branch}}";
418        let result = substitute_variables(input, &context);
419
420        assert_eq!(
421            result,
422            "Add tests for src/cli/task.rs in module cli::task (file: task.rs) on branch main"
423        );
424    }
425
426    #[test]
427    fn test_substitute_variables_partial() {
428        let context = TemplateContext {
429            target: Some("src/main.rs".to_string()),
430            module: None,
431            file: Some("main.rs".to_string()),
432            branch: None,
433        };
434
435        let input = "Fix {{target}} - {{file}} - {{unknown}}";
436        let result = substitute_variables(input, &context);
437
438        assert_eq!(result, "Fix src/main.rs - main.rs - {{unknown}}");
439    }
440
441    #[test]
442    fn test_substitute_variables_empty_context() {
443        let context = TemplateContext::default();
444
445        let input = "Test {{target}} {{module}}";
446        let result = substitute_variables(input, &context);
447
448        // Variables with no value are left as-is
449        assert_eq!(result, "Test {{target}} {{module}}");
450    }
451
452    #[test]
453    fn test_derive_module_name_simple() {
454        assert_eq!(derive_module_name("src/main.rs"), "main");
455        assert_eq!(derive_module_name("src/cli/task.rs"), "cli::task");
456        assert_eq!(derive_module_name("lib/utils.js"), "utils");
457    }
458
459    #[test]
460    fn test_derive_module_name_nested() {
461        assert_eq!(
462            derive_module_name("crates/ralph/src/template/builtin.rs"),
463            "ralph::template::builtin"
464        );
465        assert_eq!(
466            derive_module_name("src/commands/task/build.rs"),
467            "commands::task::build"
468        );
469    }
470
471    #[test]
472    fn test_derive_module_name_no_extension() {
473        assert_eq!(derive_module_name("src/cli"), "cli");
474        assert_eq!(derive_module_name("src"), "src");
475    }
476
477    #[test]
478    fn test_detect_context_with_target() {
479        let temp_dir = tempfile::TempDir::new().unwrap();
480        let repo_root = temp_dir.path();
481
482        // Initialize a git repo
483        std::process::Command::new("git")
484            .args(["init"])
485            .current_dir(repo_root)
486            .output()
487            .expect("Failed to init git repo");
488
489        let context = detect_context(Some("src/cli/task.rs"), repo_root);
490
491        assert_eq!(context.target, Some("src/cli/task.rs".to_string()));
492        assert_eq!(context.file, Some("task.rs".to_string()));
493        assert_eq!(context.module, Some("cli::task".to_string()));
494        // Branch should be detected (usually "main" or "master" for new repos)
495        assert!(context.branch.is_some());
496    }
497
498    #[test]
499    fn test_detect_context_without_target() {
500        let temp_dir = tempfile::TempDir::new().unwrap();
501        let repo_root = temp_dir.path();
502
503        let context = detect_context(None, repo_root);
504
505        assert_eq!(context.target, None);
506        assert_eq!(context.file, None);
507        assert_eq!(context.module, None);
508    }
509
510    #[test]
511    fn test_substitute_variables_in_task() {
512        let mut task = crate::contracts::Task {
513            id: "test".to_string(),
514            title: "Add tests for {{target}}".to_string(),
515            description: None,
516            status: crate::contracts::TaskStatus::Todo,
517            priority: crate::contracts::TaskPriority::High,
518            tags: vec!["test".to_string(), "{{module}}".to_string()],
519            scope: vec!["{{target}}".to_string()],
520            evidence: vec!["Need tests for {{file}}".to_string()],
521            plan: vec![
522                "Analyze {{target}}".to_string(),
523                "Test {{module}}".to_string(),
524            ],
525            notes: vec!["Branch: {{branch}}".to_string()],
526            request: Some("Add tests for {{target}}".to_string()),
527            agent: None,
528            created_at: None,
529            updated_at: None,
530            completed_at: None,
531            started_at: None,
532            scheduled_start: None,
533            depends_on: vec![],
534            blocks: vec![],
535            relates_to: vec![],
536            duplicates: None,
537            custom_fields: std::collections::HashMap::new(),
538            parent_id: None,
539            estimated_minutes: None,
540            actual_minutes: None,
541        };
542
543        let context = TemplateContext {
544            target: Some("src/main.rs".to_string()),
545            module: Some("main".to_string()),
546            file: Some("main.rs".to_string()),
547            branch: Some("feature-branch".to_string()),
548        };
549
550        substitute_variables_in_task(&mut task, &context);
551
552        assert_eq!(task.title, "Add tests for src/main.rs");
553        assert_eq!(task.tags, vec!["test", "main"]);
554        assert_eq!(task.scope, vec!["src/main.rs"]);
555        assert_eq!(task.evidence, vec!["Need tests for main.rs"]);
556        assert_eq!(task.plan, vec!["Analyze src/main.rs", "Test main"]);
557        assert_eq!(task.notes, vec!["Branch: feature-branch"]);
558        assert_eq!(task.request, Some("Add tests for src/main.rs".to_string()));
559    }
560
561    #[test]
562    fn test_extract_variables() {
563        let input = "{{target}} and {{module}} and {{unknown}}";
564        let vars = extract_variables(input);
565        assert!(vars.contains("target"));
566        assert!(vars.contains("module"));
567        assert!(vars.contains("unknown"));
568        assert!(!vars.contains("file"));
569    }
570
571    #[test]
572    fn test_extract_variables_empty() {
573        let input = "no variables here";
574        let vars = extract_variables(input);
575        assert!(vars.is_empty());
576    }
577
578    #[test]
579    fn test_validate_task_template_unknown_variables() {
580        let task = crate::contracts::Task {
581            id: "test".to_string(),
582            title: "Fix {{target}} and {{unknown_var}}".to_string(),
583            description: None,
584            status: crate::contracts::TaskStatus::Todo,
585            priority: crate::contracts::TaskPriority::High,
586            tags: vec!["{{another_unknown}}".to_string()],
587            scope: vec![],
588            evidence: vec![],
589            plan: vec![],
590            notes: vec![],
591            request: Some("Check {{unknown_var}}".to_string()),
592            agent: None,
593            created_at: None,
594            updated_at: None,
595            completed_at: None,
596            started_at: None,
597            scheduled_start: None,
598            depends_on: vec![],
599            blocks: vec![],
600            relates_to: vec![],
601            duplicates: None,
602            custom_fields: std::collections::HashMap::new(),
603            parent_id: None,
604            estimated_minutes: None,
605            actual_minutes: None,
606        };
607
608        let validation = validate_task_template(&task);
609
610        // Should have warnings for unknown_var and another_unknown
611        assert!(validation.has_unknown_variables());
612        let unknown_names = validation.unknown_variable_names();
613        assert!(unknown_names.contains(&"unknown_var".to_string()));
614        assert!(unknown_names.contains(&"another_unknown".to_string()));
615    }
616
617    #[test]
618    fn test_validate_task_template_uses_branch() {
619        let task = crate::contracts::Task {
620            id: "test".to_string(),
621            title: "Fix on {{branch}}".to_string(),
622            description: None,
623            status: crate::contracts::TaskStatus::Todo,
624            priority: crate::contracts::TaskPriority::High,
625            tags: vec![],
626            scope: vec![],
627            evidence: vec![],
628            plan: vec![],
629            notes: vec![],
630            request: None,
631            agent: None,
632            created_at: None,
633            updated_at: None,
634            completed_at: None,
635            started_at: None,
636            scheduled_start: None,
637            depends_on: vec![],
638            blocks: vec![],
639            relates_to: vec![],
640            duplicates: None,
641            custom_fields: std::collections::HashMap::new(),
642            parent_id: None,
643            estimated_minutes: None,
644            actual_minutes: None,
645        };
646
647        let validation = validate_task_template(&task);
648        assert!(validation.uses_branch);
649    }
650
651    #[test]
652    fn test_validate_task_template_no_branch() {
653        let task = crate::contracts::Task {
654            id: "test".to_string(),
655            title: "Fix {{target}}".to_string(),
656            description: None,
657            status: crate::contracts::TaskStatus::Todo,
658            priority: crate::contracts::TaskPriority::High,
659            tags: vec![],
660            scope: vec![],
661            evidence: vec![],
662            plan: vec![],
663            notes: vec![],
664            request: None,
665            agent: None,
666            created_at: None,
667            updated_at: None,
668            completed_at: None,
669            started_at: None,
670            scheduled_start: None,
671            depends_on: vec![],
672            blocks: vec![],
673            relates_to: vec![],
674            duplicates: None,
675            custom_fields: std::collections::HashMap::new(),
676            parent_id: None,
677            estimated_minutes: None,
678            actual_minutes: None,
679        };
680
681        let validation = validate_task_template(&task);
682        assert!(!validation.uses_branch);
683    }
684
685    #[test]
686    fn test_detect_context_skips_git_when_not_needed() {
687        let temp_dir = tempfile::TempDir::new().unwrap();
688        let repo_root = temp_dir.path();
689
690        // Not a git repo, but we don't need branch
691        let (context, warnings) = detect_context_with_warnings(None, repo_root, false);
692
693        assert!(context.branch.is_none());
694        // Should have no warnings since we didn't try git detection
695        assert!(warnings.is_empty());
696    }
697
698    #[test]
699    fn test_template_warning_display() {
700        let w1 = TemplateWarning::UnknownVariable {
701            name: "foo".to_string(),
702            field: None,
703        };
704        assert_eq!(w1.to_string(), "Unknown template variable: {{foo}}");
705
706        let w2 = TemplateWarning::UnknownVariable {
707            name: "bar".to_string(),
708            field: Some("title".to_string()),
709        };
710        assert_eq!(
711            w2.to_string(),
712            "Unknown template variable in title: {{bar}}"
713        );
714
715        let w3 = TemplateWarning::GitBranchDetectionFailed {
716            error: "not a git repo".to_string(),
717        };
718        assert_eq!(
719            w3.to_string(),
720            "Git branch detection failed: not a git repo"
721        );
722    }
723}