1use std::collections::HashSet;
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use regex::Regex;
23
24#[derive(Debug, Clone, Default)]
26pub struct TemplateContext {
27 pub target: Option<String>,
29 pub module: Option<String>,
31 pub file: Option<String>,
33 pub branch: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum TemplateWarning {
40 UnknownVariable { name: String, field: Option<String> },
42 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#[derive(Debug, Clone, Default)]
71pub struct TemplateValidation {
72 pub warnings: Vec<TemplateWarning>,
74 pub uses_branch: bool,
76}
77
78impl TemplateValidation {
79 pub fn has_unknown_variables(&self) -> bool {
81 self.warnings
82 .iter()
83 .any(|w| matches!(w, TemplateWarning::UnknownVariable { .. }))
84 }
85
86 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
102const KNOWN_VARIABLES: &[&str] = &["target", "module", "file", "branch"];
104
105fn extract_variables(input: &str) -> HashSet<String> {
109 let mut variables = HashSet::new();
110 let re = match Regex::new(r"\{\{(\w+)\}\}") {
114 Ok(re) => re,
115 Err(_) => return variables, };
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
126fn uses_branch_variable(input: &str) -> bool {
128 input.contains("{{branch}}")
129}
130
131pub 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 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 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
192pub 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
237pub 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
243fn derive_module_name(path: &str) -> String {
250 let path_obj = Path::new(path);
251
252 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 let mut components: Vec<String> = Vec::new();
260
261 for component in path_obj.components() {
263 let comp_str = component.as_os_str().to_string_lossy().to_string();
264
265 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 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 !components.is_empty() {
291 components.push(file_stem);
292 components.join("::")
293 } else {
294 file_stem
295 }
296}
297
298fn detect_git_branch(repo_root: &Path) -> Result<Option<String>> {
300 let head_path = repo_root.join(".git/HEAD");
302
303 if !head_path.exists() {
304 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 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 Ok(None)
337 } else if head_ref.is_empty() {
338 Err(anyhow::anyhow!("HEAD file is empty"))
339 } else {
340 Err(anyhow::anyhow!("invalid HEAD content: {}", head_ref))
342 }
343}
344
345pub 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
374pub 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 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 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 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 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 let (context, warnings) = detect_context_with_warnings(None, repo_root, false);
692
693 assert!(context.branch.is_none());
694 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}