1use std::path::{Path, PathBuf};
20
21use anyhow::{Result, bail};
22
23use crate::contracts::Task;
24use crate::template::builtin::{get_builtin_template, get_template_description};
25use crate::template::variables::{
26 TemplateContext, TemplateWarning, detect_context_with_warnings, substitute_variables_in_task,
27 validate_task_template,
28};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TemplateSource {
33 Custom(PathBuf),
35 Builtin(String),
37}
38
39#[derive(Debug, Clone)]
41pub struct TemplateInfo {
42 pub name: String,
43 pub source: TemplateSource,
44 pub description: String,
45}
46
47#[derive(Debug, thiserror::Error)]
49pub enum TemplateError {
50 #[error("Template not found: {0}")]
51 NotFound(String),
52 #[error("Failed to read template file: {0}")]
53 ReadError(String),
54 #[error("Invalid template JSON: {0}")]
55 InvalidJson(String),
56 #[error("Template validation failed: {0}")]
57 ValidationError(String),
58}
59
60pub fn load_template(name: &str, project_root: &Path) -> Result<(Task, TemplateSource)> {
64 let custom_path = project_root
66 .join(".ralph/templates")
67 .join(format!("{}.json", name));
68 if custom_path.exists() {
69 let content = std::fs::read_to_string(&custom_path)
70 .map_err(|e| TemplateError::ReadError(e.to_string()))?;
71 let task: Task = serde_json::from_str(&content)
72 .map_err(|e| TemplateError::InvalidJson(e.to_string()))?;
73
74 let validation = validate_task_template(&task);
76 if validation.has_unknown_variables() {
77 let unknowns = validation.unknown_variable_names();
78 log::warn!(
79 "Template '{}' contains unknown variables: {}",
80 name,
81 unknowns.join(", ")
82 );
83 }
84
85 return Ok((task, TemplateSource::Custom(custom_path)));
86 }
87
88 if let Some(template_json) = get_builtin_template(name) {
90 let task: Task = serde_json::from_str(template_json)
91 .map_err(|e| TemplateError::InvalidJson(e.to_string()))?;
92 return Ok((task, TemplateSource::Builtin(name.to_string())));
93 }
94
95 Err(TemplateError::NotFound(name.to_string()).into())
96}
97
98pub fn list_templates(project_root: &Path) -> Vec<TemplateInfo> {
102 let mut templates = Vec::new();
103 let mut seen_names = std::collections::HashSet::new();
104
105 let custom_dir = project_root.join(".ralph/templates");
107 if let Ok(entries) = std::fs::read_dir(&custom_dir) {
108 for entry in entries.flatten() {
109 let path = entry.path();
110 if path.extension().is_some_and(|ext| ext == "json")
111 && let Some(name) = path.file_stem()
112 {
113 let name = name.to_string_lossy().to_string();
114 seen_names.insert(name.clone());
115
116 let description = if let Ok(content) = std::fs::read_to_string(&path) {
118 if let Ok(task) = serde_json::from_str::<Task>(&content) {
119 task.plan
121 .first()
122 .cloned()
123 .unwrap_or_else(|| "Custom template".to_string())
124 } else {
125 "Custom template".to_string()
126 }
127 } else {
128 "Custom template".to_string()
129 };
130
131 templates.push(TemplateInfo {
132 name,
133 source: TemplateSource::Custom(path),
134 description,
135 });
136 }
137 }
138 }
139
140 for name in crate::template::builtin::list_builtin_templates() {
142 if !seen_names.contains(name) {
143 templates.push(TemplateInfo {
144 name: name.to_string(),
145 source: TemplateSource::Builtin(name.to_string()),
146 description: get_template_description(name).to_string(),
147 });
148 }
149 }
150
151 templates.sort_by(|a, b| a.name.cmp(&b.name));
153
154 templates
155}
156
157pub fn template_exists(name: &str, project_root: &Path) -> bool {
159 let custom_path = project_root
160 .join(".ralph/templates")
161 .join(format!("{}.json", name));
162 custom_path.exists() || get_builtin_template(name).is_some()
163}
164
165#[derive(Debug, Clone)]
167pub struct LoadedTemplate {
168 pub task: Task,
170 pub source: TemplateSource,
172 pub warnings: Vec<TemplateWarning>,
174}
175
176pub fn load_template_with_context(
184 name: &str,
185 project_root: &Path,
186 target: Option<&str>,
187 strict: bool,
188) -> Result<LoadedTemplate> {
189 let (mut task, source) = load_template(name, project_root)?;
191
192 let validation = validate_task_template(&task);
194
195 if strict && validation.has_unknown_variables() {
197 let unknowns = validation.unknown_variable_names();
198 bail!(TemplateError::ValidationError(format!(
199 "Template '{}' contains unknown variables: {}",
200 name,
201 unknowns.join(", ")
202 )));
203 }
204
205 let (context, mut warnings) =
207 detect_context_with_warnings(target, project_root, validation.uses_branch);
208
209 warnings.extend(validation.warnings);
211
212 substitute_variables_in_task(&mut task, &context);
214
215 Ok(LoadedTemplate {
216 task,
217 source,
218 warnings,
219 })
220}
221
222pub fn load_template_with_context_legacy(
227 name: &str,
228 project_root: &Path,
229 target: Option<&str>,
230) -> Result<(Task, TemplateSource)> {
231 let loaded = load_template_with_context(name, project_root, target, false)?;
232 Ok((loaded.task, loaded.source))
233}
234
235pub fn get_template_context(target: Option<&str>, project_root: &Path) -> TemplateContext {
237 let (context, _) = detect_context_with_warnings(target, project_root, true);
238 context
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::io::Write;
245 use tempfile::TempDir;
246
247 fn create_test_project() -> TempDir {
248 TempDir::new().expect("Failed to create temp dir")
249 }
250
251 #[test]
252 fn test_load_builtin_template() {
253 let temp_dir = create_test_project();
254 let result = load_template("bug", temp_dir.path());
255 assert!(result.is_ok());
256
257 let (task, source) = result.unwrap();
258 assert_eq!(task.priority, crate::contracts::TaskPriority::High);
259 assert!(matches!(source, TemplateSource::Builtin(s) if s == "bug"));
260 }
261
262 #[test]
263 fn test_load_custom_template() {
264 let temp_dir = create_test_project();
265 let templates_dir = temp_dir.path().join(".ralph/templates");
266 std::fs::create_dir_all(&templates_dir).unwrap();
267
268 let custom_template = r#"{
269 "id": "",
270 "title": "",
271 "status": "todo",
272 "priority": "critical",
273 "tags": ["custom", "test"],
274 "plan": ["Step 1", "Step 2"]
275 }"#;
276
277 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
278 file.write_all(custom_template.as_bytes()).unwrap();
279
280 let result = load_template("custom", temp_dir.path());
281 assert!(result.is_ok());
282
283 let (task, source) = result.unwrap();
284 assert_eq!(task.priority, crate::contracts::TaskPriority::Critical);
285 assert!(matches!(source, TemplateSource::Custom(_)));
286 }
287
288 #[test]
289 fn test_custom_overrides_builtin() {
290 let temp_dir = create_test_project();
291 let templates_dir = temp_dir.path().join(".ralph/templates");
292 std::fs::create_dir_all(&templates_dir).unwrap();
293
294 let custom_template = r#"{
296 "id": "",
297 "title": "",
298 "status": "todo",
299 "priority": "low",
300 "tags": ["custom-bug"]
301 }"#;
302
303 let mut file = std::fs::File::create(templates_dir.join("bug.json")).unwrap();
304 file.write_all(custom_template.as_bytes()).unwrap();
305
306 let result = load_template("bug", temp_dir.path());
307 assert!(result.is_ok());
308
309 let (task, source) = result.unwrap();
310 assert_eq!(task.priority, crate::contracts::TaskPriority::Low);
311 assert!(matches!(source, TemplateSource::Custom(_)));
312 }
313
314 #[test]
315 fn test_load_nonexistent_template() {
316 let temp_dir = create_test_project();
317 let result = load_template("nonexistent", temp_dir.path());
318 assert!(result.is_err());
319 let err_msg = result.unwrap_err().to_string();
320 assert!(err_msg.contains("not found") || err_msg.contains("NotFound"));
321 }
322
323 #[test]
324 fn test_list_templates() {
325 let temp_dir = create_test_project();
326 let templates_dir = temp_dir.path().join(".ralph/templates");
327 std::fs::create_dir_all(&templates_dir).unwrap();
328
329 let custom_template = r#"{"title": "", "priority": "low"}"#;
331 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
332 file.write_all(custom_template.as_bytes()).unwrap();
333
334 let templates = list_templates(temp_dir.path());
335
336 assert_eq!(templates.len(), 11);
338
339 assert!(templates.iter().any(|t| t.name == "custom"));
341
342 assert!(templates.iter().any(|t| t.name == "bug"));
344 assert!(templates.iter().any(|t| t.name == "feature"));
345 }
346
347 #[test]
348 fn test_template_exists() {
349 let temp_dir = create_test_project();
350
351 assert!(template_exists("bug", temp_dir.path()));
353 assert!(template_exists("feature", temp_dir.path()));
354
355 assert!(!template_exists("nonexistent", temp_dir.path()));
357
358 let templates_dir = temp_dir.path().join(".ralph/templates");
360 std::fs::create_dir_all(&templates_dir).unwrap();
361 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
362 file.write_all(b"{}").unwrap();
363
364 assert!(template_exists("custom", temp_dir.path()));
365 }
366
367 #[test]
368 fn test_load_template_with_context_substitutes_variables() {
369 let temp_dir = create_test_project();
370
371 let templates_dir = temp_dir.path().join(".ralph/templates");
373 std::fs::create_dir_all(&templates_dir).unwrap();
374
375 let custom_template = r#"{
376 "id": "",
377 "title": "Fix {{target}}",
378 "status": "todo",
379 "priority": "high",
380 "tags": ["bug", "{{module}}"],
381 "scope": ["{{target}}"],
382 "plan": ["Analyze {{file}}"],
383 "evidence": ["Issue in {{target}}"]
384 }"#;
385
386 let mut file = std::fs::File::create(templates_dir.join("bug.json")).unwrap();
387 file.write_all(custom_template.as_bytes()).unwrap();
388
389 let result =
390 load_template_with_context("bug", temp_dir.path(), Some("src/cli/task.rs"), false);
391 assert!(result.is_ok());
392
393 let loaded = result.unwrap();
394 assert_eq!(loaded.task.title, "Fix src/cli/task.rs");
395 assert!(loaded.task.tags.contains(&"bug".to_string()));
396 assert!(loaded.task.tags.contains(&"cli::task".to_string()));
397 assert!(loaded.task.scope.contains(&"src/cli/task.rs".to_string()));
398 assert!(loaded.task.plan.contains(&"Analyze task.rs".to_string()));
399 assert!(
400 loaded
401 .task
402 .evidence
403 .contains(&"Issue in src/cli/task.rs".to_string())
404 );
405 }
406
407 #[test]
408 fn test_load_template_with_context_no_target() {
409 let temp_dir = create_test_project();
410
411 let result = load_template_with_context("bug", temp_dir.path(), None, false);
412 assert!(result.is_ok());
413
414 let loaded = result.unwrap();
415 assert!(loaded.task.title.contains("{{target}}") || loaded.task.title.is_empty());
417 }
418
419 #[test]
420 fn test_load_template_with_context_returns_warnings() {
421 let temp_dir = create_test_project();
422
423 let templates_dir = temp_dir.path().join(".ralph/templates");
425 std::fs::create_dir_all(&templates_dir).unwrap();
426
427 let custom_template = r#"{
428 "id": "",
429 "title": "Fix {{target}} with {{unknown_var}}",
430 "status": "todo",
431 "priority": "high",
432 "tags": ["bug"]
433 }"#;
434
435 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
436 file.write_all(custom_template.as_bytes()).unwrap();
437
438 let result =
439 load_template_with_context("custom", temp_dir.path(), Some("src/main.rs"), false);
440 assert!(result.is_ok());
441
442 let loaded = result.unwrap();
443 assert!(!loaded.warnings.is_empty());
445 assert!(loaded.warnings.iter().any(|w| matches!(
446 w,
447 TemplateWarning::UnknownVariable { name, .. } if name == "unknown_var"
448 )));
449 }
450
451 #[test]
452 fn test_load_template_strict_mode_fails_on_unknown() {
453 let temp_dir = create_test_project();
454
455 let templates_dir = temp_dir.path().join(".ralph/templates");
457 std::fs::create_dir_all(&templates_dir).unwrap();
458
459 let custom_template = r#"{
460 "id": "",
461 "title": "Fix {{unknown_var}}",
462 "status": "todo",
463 "priority": "high",
464 "tags": ["bug"]
465 }"#;
466
467 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
468 file.write_all(custom_template.as_bytes()).unwrap();
469
470 let result =
472 load_template_with_context("custom", temp_dir.path(), Some("src/main.rs"), true);
473 assert!(result.is_err());
474 let err_msg = result.unwrap_err().to_string();
475 assert!(err_msg.contains("unknown_var"));
476 }
477
478 #[test]
479 fn test_load_template_strict_mode_succeeds_when_no_unknown() {
480 let temp_dir = create_test_project();
481
482 let result = load_template_with_context("bug", temp_dir.path(), Some("src/main.rs"), true);
484 assert!(result.is_ok());
485 }
486
487 #[test]
488 fn test_load_template_with_context_git_warning() {
489 let temp_dir = create_test_project();
490
491 let templates_dir = temp_dir.path().join(".ralph/templates");
493 std::fs::create_dir_all(&templates_dir).unwrap();
494
495 let custom_template = r#"{
496 "id": "",
497 "title": "Fix on branch {{branch}}",
498 "status": "todo",
499 "priority": "high",
500 "tags": ["bug"]
501 }"#;
502
503 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
504 file.write_all(custom_template.as_bytes()).unwrap();
505
506 std::fs::create_dir_all(temp_dir.path().join(".git")).unwrap();
509 std::fs::write(
510 temp_dir.path().join(".git/HEAD"),
511 "invalid: refs/heads/nonexistent",
512 )
513 .unwrap();
514
515 let result = load_template_with_context("custom", temp_dir.path(), None, false);
517 assert!(result.is_ok());
518
519 let loaded = result.unwrap();
520 assert!(
522 loaded
523 .warnings
524 .iter()
525 .any(|w| matches!(w, TemplateWarning::GitBranchDetectionFailed { .. }))
526 );
527 }
528
529 #[test]
530 fn test_load_template_with_context_no_git_warning_when_no_branch_var() {
531 let temp_dir = create_test_project();
532
533 let templates_dir = temp_dir.path().join(".ralph/templates");
535 std::fs::create_dir_all(&templates_dir).unwrap();
536
537 let custom_template = r#"{
538 "id": "",
539 "title": "Fix {{target}}",
540 "status": "todo",
541 "priority": "high",
542 "tags": ["bug"]
543 }"#;
544
545 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
546 file.write_all(custom_template.as_bytes()).unwrap();
547
548 let result =
550 load_template_with_context("custom", temp_dir.path(), Some("src/main.rs"), false);
551 assert!(result.is_ok());
552
553 let loaded = result.unwrap();
554 assert!(
556 !loaded
557 .warnings
558 .iter()
559 .any(|w| matches!(w, TemplateWarning::GitBranchDetectionFailed { .. }))
560 );
561 }
562
563 #[test]
564 fn test_load_custom_template_with_unknown_variable_logs_warning() {
565 let temp_dir = create_test_project();
566 let templates_dir = temp_dir.path().join(".ralph/templates");
567 std::fs::create_dir_all(&templates_dir).unwrap();
568
569 let custom_template = r#"{
570 "id": "",
571 "title": "Fix {{typo_target}}",
572 "status": "todo",
573 "priority": "high"
574 }"#;
575
576 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
577 file.write_all(custom_template.as_bytes()).unwrap();
578
579 let result = load_template("custom", temp_dir.path());
581 assert!(result.is_ok());
582
583 let (task, _) = result.unwrap();
584 assert_eq!(task.title, "Fix {{typo_target}}"); }
586
587 #[test]
588 fn test_load_custom_template_with_known_variables_succeeds() {
589 let temp_dir = create_test_project();
590 let templates_dir = temp_dir.path().join(".ralph/templates");
591 std::fs::create_dir_all(&templates_dir).unwrap();
592
593 let custom_template = r#"{
594 "id": "",
595 "title": "Fix {{target}} in {{file}}",
596 "status": "todo",
597 "priority": "high"
598 }"#;
599
600 let mut file = std::fs::File::create(templates_dir.join("custom.json")).unwrap();
601 file.write_all(custom_template.as_bytes()).unwrap();
602
603 let result = load_template("custom", temp_dir.path());
605 assert!(result.is_ok());
606 }
607}