Skip to main content

ralph/template/
loader.rs

1//! Template loading with override support.
2//!
3//! Responsibilities:
4//! - Load templates from `.ralph/templates/{name}.json` first.
5//! - Fall back to built-in templates if no custom template exists.
6//! - List all available templates (built-in + custom).
7//! - Validate templates and return warnings for unknown variables.
8//!
9//! Not handled here:
10//! - Template content validation beyond JSON parsing.
11//! - Template merging with user options (see `merge.rs`).
12//!
13//! Invariants/assumptions:
14//! - Custom templates override built-ins with the same name.
15//! - Template files must have `.json` extension.
16//! - Template names are case-sensitive.
17//! - Variable validation is performed (unknowns produce warnings; strict mode fails).
18
19use 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/// Source of a loaded template
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TemplateSource {
33    /// Custom template from .ralph/templates/
34    Custom(PathBuf),
35    /// Built-in embedded template (stores the name, not the content)
36    Builtin(String),
37}
38
39/// Metadata for a template (used for listing)
40#[derive(Debug, Clone)]
41pub struct TemplateInfo {
42    pub name: String,
43    pub source: TemplateSource,
44    pub description: String,
45}
46
47/// Error type for template operations
48#[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
60/// Load a template by name
61///
62/// Checks `.ralph/templates/{name}.json` first, then falls back to built-in templates.
63pub fn load_template(name: &str, project_root: &Path) -> Result<(Task, TemplateSource)> {
64    // Check for custom template first
65    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        // Validate template variables (unknowns produce warnings via log, not errors)
75        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    // Fall back to built-in
89    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
98/// List all available templates (built-in + custom)
99///
100/// Custom templates override built-ins with the same name.
101pub 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    // Add custom templates first (so they take precedence in listing)
106    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                // Try to read description from template if possible
117                let description = if let Ok(content) = std::fs::read_to_string(&path) {
118                    if let Ok(task) = serde_json::from_str::<Task>(&content) {
119                        // Use first plan item as description if available
120                        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    // Add built-ins that aren't overridden
141    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    // Sort by name for consistent ordering
152    templates.sort_by(|a, b| a.name.cmp(&b.name));
153
154    templates
155}
156
157/// Check if a template exists (either custom or built-in)
158pub 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/// Result of loading a template with context
166#[derive(Debug, Clone)]
167pub struct LoadedTemplate {
168    /// The task with variables substituted
169    pub task: Task,
170    /// The source of the template
171    pub source: TemplateSource,
172    /// Warnings collected during validation and context detection
173    pub warnings: Vec<TemplateWarning>,
174}
175
176/// Load a template by name with variable substitution
177///
178/// Checks `.ralph/templates/{name}.json` first, then falls back to built-in templates.
179/// Substitutes template variables ({{target}}, {{module}}, {{file}}, {{branch}}) with
180/// context-aware values.
181///
182/// If `strict` is true and unknown variables are present, returns an error.
183pub fn load_template_with_context(
184    name: &str,
185    project_root: &Path,
186    target: Option<&str>,
187    strict: bool,
188) -> Result<LoadedTemplate> {
189    // Load the base template
190    let (mut task, source) = load_template(name, project_root)?;
191
192    // Validate the template before substitution
193    let validation = validate_task_template(&task);
194
195    // In strict mode, fail on unknown variables
196    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    // Detect context, only requesting branch if needed
206    let (context, mut warnings) =
207        detect_context_with_warnings(target, project_root, validation.uses_branch);
208
209    // Add validation warnings to context warnings
210    warnings.extend(validation.warnings);
211
212    // Substitute variables in all string fields
213    substitute_variables_in_task(&mut task, &context);
214
215    Ok(LoadedTemplate {
216        task,
217        source,
218        warnings,
219    })
220}
221
222/// Load a template by name with variable substitution (legacy, non-strict)
223///
224/// This is a convenience function for backward compatibility.
225/// Use `load_template_with_context` for full control.
226pub 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
235/// Get the template context for inspection
236pub 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        // Create a custom "bug" template that overrides the built-in
295        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        // Create a custom template
330        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        // Should have 10 built-ins + 1 custom = 11 total
337        assert_eq!(templates.len(), 11);
338
339        // Custom should be in the list
340        assert!(templates.iter().any(|t| t.name == "custom"));
341
342        // Built-ins should be in the list
343        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        // Built-in should exist
352        assert!(template_exists("bug", temp_dir.path()));
353        assert!(template_exists("feature", temp_dir.path()));
354
355        // Nonexistent should not exist
356        assert!(!template_exists("nonexistent", temp_dir.path()));
357
358        // Custom should exist after creation
359        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        // Create a custom template with variables
372        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        // Variables should be left as-is when no target is provided
416        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        // Create a custom template with unknown variables
424        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        // Should have warnings for unknown variables
444        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        // Create a custom template with unknown variables
456        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        // In strict mode, should fail
471        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        // Use built-in bug template which shouldn't have unknown variables
483        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        // Create a template that uses {{branch}}
492        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        // Create a .git directory with an invalid HEAD to force git detection to fail
507        // This simulates a corrupted/broken git repo
508        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        // Git detection should fail with invalid HEAD, producing a warning
516        let result = load_template_with_context("custom", temp_dir.path(), None, false);
517        assert!(result.is_ok());
518
519        let loaded = result.unwrap();
520        // Should have warnings for git branch detection failure
521        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        // Create a template that does NOT use {{branch}}
534        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        // Not a git repo, but shouldn't get git warning since we don't use {{branch}}
549        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        // Should NOT have git branch detection warnings
555        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        // Should succeed but log warning
580        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}}"); // Variable preserved as-is
585    }
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        // Should succeed with no warnings
604        let result = load_template("custom", temp_dir.path());
605        assert!(result.is_ok());
606    }
607}