Skip to main content

humanize_cli_core/
template.rs

1//! Template loading and single-pass variable rendering for Humanize.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6/// Errors that can occur while loading or rendering templates.
7#[derive(Debug, thiserror::Error)]
8pub enum TemplateError {
9    #[error("Template not found: {0}")]
10    NotFound(String),
11
12    #[error("Invalid template path: {0}")]
13    InvalidPath(String),
14
15    #[error("IO error: {0}")]
16    Io(#[from] std::io::Error),
17}
18
19/// Resolve the prompt-template directory from a plugin root.
20pub fn template_dir(plugin_root: &Path) -> PathBuf {
21    plugin_root.join("prompt-template")
22}
23
24/// Load a template file from a template root.
25pub fn load_template(template_root: &Path, template_name: &str) -> Result<String, TemplateError> {
26    let path = template_root.join(template_name);
27
28    if path.is_absolute() && !path.starts_with(template_root) {
29        return Err(TemplateError::InvalidPath(template_name.to_string()));
30    }
31
32    if !path.exists() {
33        return Err(TemplateError::NotFound(path.display().to_string()));
34    }
35
36    Ok(std::fs::read_to_string(path)?)
37}
38
39/// Render a template using single-pass `{{VAR}}` substitution.
40///
41/// Missing variables are left intact, matching the shell template loader.
42pub fn render_template(template: &str, vars: &HashMap<String, String>) -> String {
43    let mut out = String::with_capacity(template.len());
44    let mut i = 0;
45
46    while i < template.len() {
47        let remaining = &template[i..];
48        if !remaining.starts_with("{{") {
49            let ch = remaining.chars().next().unwrap();
50            out.push(ch);
51            i += ch.len_utf8();
52            continue;
53        }
54
55        let after_open = i + 2;
56        if let Some(close_rel) = template[after_open..].find("}}") {
57            let close = after_open + close_rel;
58            let key = &template[after_open..close];
59            if let Some(value) = vars.get(key) {
60                out.push_str(value);
61            } else {
62                out.push_str(&template[i..close + 2]);
63            }
64            i = close + 2;
65        } else {
66            out.push_str("{{");
67            i += 2;
68        }
69    }
70
71    out
72}
73
74/// Load and render a template file in one step.
75pub fn load_and_render(
76    template_root: &Path,
77    template_name: &str,
78    vars: &HashMap<String, String>,
79) -> Result<String, TemplateError> {
80    let template = load_template(template_root, template_name)?;
81    Ok(render_template(&template, vars))
82}
83
84/// Load and render a template with a fallback string.
85pub fn load_and_render_safe(
86    template_root: &Path,
87    template_name: &str,
88    fallback: &str,
89    vars: &HashMap<String, String>,
90) -> String {
91    match load_and_render(template_root, template_name, vars) {
92        Ok(rendered) if !rendered.is_empty() => rendered,
93        _ => render_template(fallback, vars),
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn render_template_replaces_known_variables_and_keeps_unknown() {
103        let mut vars = HashMap::new();
104        vars.insert("PLAN_FILE".to_string(), "docs/plan.md".to_string());
105
106        let rendered = render_template("Plan: {{PLAN_FILE}} {{UNKNOWN}}", &vars);
107        assert_eq!(rendered, "Plan: docs/plan.md {{UNKNOWN}}");
108    }
109
110    #[test]
111    fn render_template_is_single_pass() {
112        let mut vars = HashMap::new();
113        vars.insert("A".to_string(), "{{B}}".to_string());
114        vars.insert("B".to_string(), "expanded".to_string());
115
116        let rendered = render_template("Value: {{A}}", &vars);
117        assert_eq!(rendered, "Value: {{B}}");
118    }
119
120    #[test]
121    fn load_and_render_safe_falls_back_when_template_missing() {
122        let tmp = tempfile::tempdir().unwrap();
123        let mut vars = HashMap::new();
124        vars.insert("FIELD_NAME".to_string(), "plan_tracked".to_string());
125
126        let rendered = load_and_render_safe(
127            tmp.path(),
128            "block/schema-outdated.md",
129            "Missing {{FIELD_NAME}}",
130            &vars,
131        );
132        assert_eq!(rendered, "Missing plan_tracked");
133    }
134}