humanize_cli_core/
template.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6#[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
19pub fn template_dir(plugin_root: &Path) -> PathBuf {
21 plugin_root.join("prompt-template")
22}
23
24pub 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
39pub 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
74pub 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
84pub 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}