1use std::path::Path;
15
16use minijinja::{Environment, Value};
17use rhema_core::ToolSpec;
18
19use crate::skills::Skill;
20
21pub use minijinja::Error as TemplateError;
25
26#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
31pub struct McpServerLine {
32 pub name: String,
35 pub server: String,
37 pub description: String,
39}
40
41pub fn render_system(
53 template: &str,
54 workspace: &Path,
55 skills: &[Skill],
56 tools: &[ToolSpec],
57) -> Result<String, TemplateError> {
58 render_system_with_sections(template, workspace, skills, tools, None, None, &[], None)
59}
60
61#[expect(
68 clippy::too_many_arguments,
69 reason = "one parameter per prompt section keeps the render's inputs flat"
70)]
71pub fn render_system_with_sections(
72 template: &str,
73 workspace: &Path,
74 skills: &[Skill],
75 tools: &[ToolSpec],
76 environment: Option<&str>,
77 user: Option<&str>,
78 mcp: &[McpServerLine],
79 vision: Option<&str>,
80) -> Result<String, TemplateError> {
81 let context = serde_json::json!({
82 "workspace": workspace.display().to_string(),
83 "skills": skills,
84 "tools": tools,
85 "environment": environment,
86 "user": user,
87 "mcp": mcp,
88 "vision": vision,
89 });
90 render_template(template, &context)
91}
92
93pub fn render_template(
98 template: &str,
99 context: &serde_json::Value,
100) -> Result<String, TemplateError> {
101 let mut env = Environment::new();
102 env.set_syntax(
105 minijinja::syntax::SyntaxConfig::builder()
106 .trim_blocks(true)
107 .lstrip_blocks(true)
108 .build()?,
109 );
110 env.add_template("system", template)?;
111 Ok(env
112 .get_template("system")?
113 .render(Value::from(minijinja::value::Serde(context)))?
114 .trim_end()
115 .to_owned()
116 + "\n")
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use serde_json::json;
123
124 fn skill(name: &str, description: &str) -> Skill {
125 Skill {
126 name: name.into(),
127 description: description.into(),
128 license: None,
129 path: format!("/cfg/skills/{name}/SKILL.md").into(),
130 body: "the rules".into(),
131 }
132 }
133
134 fn tool(name: &str, description: &str) -> ToolSpec {
135 ToolSpec::new(
136 name,
137 description,
138 json!({
139 "type": "object",
140 "properties": { "command": { "type": "string" } },
141 "required": ["command"]
142 }),
143 )
144 }
145
146 #[test]
147 fn render_fills_the_workspace_and_leaves_other_braces_alone() {
148 let out = render_system(
149 "work in {{ workspace }}, keep {mystery}",
150 Path::new("/ws"),
151 &[],
152 &[],
153 )
154 .unwrap();
155 assert_eq!(out, "work in /ws, keep {mystery}\n");
156 }
157
158 #[test]
159 fn a_for_loop_renders_its_body_once_per_skill() {
160 let out = render_system(
161 "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
162 Path::new("/ws"),
163 &[skill("a", "da"), skill("b", "db")],
164 &[],
165 )
166 .unwrap();
167 assert_eq!(
168 out,
169 "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
170 );
171 }
172
173 #[test]
174 fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
175 let out = render_system(
176 "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
177 Path::new("/ws"),
178 &[],
179 &[tool("t", "dt")],
180 )
181 .unwrap();
182 assert_eq!(
183 out,
184 "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
185 );
186 }
187
188 #[test]
189 fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
190 let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
191 let lines = vec![McpServerLine {
192 name: "atlassian".into(),
193 server: "atlassian".into(),
194 description: "Jira and Confluence".into(),
195 }];
196 let out = render_system_with_sections(
197 template,
198 Path::new("/ws"),
199 &[],
200 &[],
201 None,
202 None,
203 &lines,
204 None,
205 )
206 .unwrap();
207 assert_eq!(
208 out,
209 "MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
210 );
211 let out = render_system_with_sections(
212 template,
213 Path::new("/ws"),
214 &[],
215 &[],
216 None,
217 None,
218 &[],
219 None,
220 )
221 .unwrap();
222 assert!(!out.contains("atlassian"), "{out:?}");
226 assert!(!out.contains("MCP:"), "{out:?}");
227 }
228
229 #[test]
230 fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
231 let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
232 let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
233 assert_eq!(
234 with,
235 "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
236 );
237 let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
238 assert_eq!(without, "\n");
239 }
240
241 #[test]
242 fn a_misspelled_variable_renders_empty_not_an_error() {
243 let out = render_system(
244 "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
245 Path::new("/ws"),
246 &[skill("s", "d")],
247 &[],
248 )
249 .unwrap();
250 assert_eq!(out, "s\n");
251 }
252
253 #[test]
254 fn a_template_error_surfaces_instead_of_rendering_prose() {
255 let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
256 .expect_err("an unclosed block is a loud error, not prose");
257 assert!(err.to_string().contains("system"), "{err}");
258 }
259
260 #[test]
261 fn a_template_without_the_block_presents_no_skills() {
262 let out = render_system(
263 "base in {{ workspace }}\n",
264 Path::new("/ws"),
265 &[skill("s", "d")],
266 &[],
267 )
268 .unwrap();
269 assert_eq!(out, "base in /ws\n");
270 }
271
272 #[test]
273 fn the_sections_render_where_the_template_places_them() {
274 let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
275 let out = render_system_with_sections(
276 template,
277 Path::new("/ws"),
278 &[],
279 &[],
280 Some("- OS: linux"),
281 Some("prefer short answers"),
282 &[],
283 None,
284 )
285 .unwrap();
286 assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
287 }
288
289 #[test]
290 fn absent_sections_render_nothing_like_undefined_variables() {
291 let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
296 let without = render_system_with_sections(
297 template,
298 Path::new("/ws"),
299 &[],
300 &[],
301 None,
302 None,
303 &[],
304 None,
305 )
306 .unwrap();
307 assert_eq!(without, "open\nclose\n");
308 assert_eq!(
309 render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
310 without,
311 "render_system is the sections-less form of the same render"
312 );
313 }
314
315 #[test]
316 fn render_template_takes_the_callers_own_variables() {
317 let out = render_template(
318 "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
319 &json!({ "who": "claw", "tag": "" }),
320 )
321 .unwrap();
322 assert_eq!(out, "hi claw\n");
323 }
324
325 #[test]
326 fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
327 let out = render_template(
328 "{{ section }}",
329 &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
330 )
331 .unwrap();
332 assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
333 }
334}