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(
54 template: &str,
55 workspace: &Path,
56 skills: &[Skill],
57 tools: &[ToolSpec],
58) -> Result<String, TemplateError> {
59 render_system_with_sections(
60 template,
61 workspace,
62 skills,
63 tools,
64 None,
65 None,
66 &[],
67 None,
68 None,
69 )
70}
71
72#[expect(
81 clippy::too_many_arguments,
82 reason = "one parameter per prompt section keeps the render's inputs flat"
83)]
84pub fn render_system_with_sections(
85 template: &str,
86 workspace: &Path,
87 skills: &[Skill],
88 tools: &[ToolSpec],
89 environment: Option<&str>,
90 user: Option<&str>,
91 mcp: &[McpServerLine],
92 vision: Option<&str>,
93 jobs: Option<&str>,
94) -> Result<String, TemplateError> {
95 let context = serde_json::json!({
96 "workspace": workspace.display().to_string(),
97 "skills": skills,
98 "tools": tools,
99 "environment": environment,
100 "user": user,
101 "mcp": mcp,
102 "vision": vision,
103 "jobs": jobs,
104 });
105 render_template(template, &context)
106}
107
108fn json(value: Value) -> Result<String, TemplateError> {
110 serde_json::to_string(&value).map_err(|err| {
111 TemplateError::new(
112 minijinja::ErrorKind::InvalidOperation,
113 "cannot serialize to JSON",
114 )
115 .with_source(err)
116 })
117}
118
119pub fn render_template(
124 template: &str,
125 context: &serde_json::Value,
126) -> Result<String, TemplateError> {
127 let mut env = Environment::new();
128 env.set_syntax(
131 minijinja::syntax::SyntaxConfig::builder()
132 .trim_blocks(true)
133 .lstrip_blocks(true)
134 .build()?,
135 );
136 env.add_filter("json", json);
143 env.add_template("system", template)?;
144 Ok(env
145 .get_template("system")?
146 .render(Value::from(minijinja::value::Serde(context)))?
147 .trim_end()
148 .to_owned()
149 + "\n")
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use serde_json::json;
156
157 fn skill(name: &str, description: &str) -> Skill {
158 Skill {
159 name: name.into(),
160 description: description.into(),
161 license: None,
162 path: format!("/cfg/skills/{name}/SKILL.md").into(),
163 body: "the rules".into(),
164 }
165 }
166
167 fn tool(name: &str, description: &str) -> ToolSpec {
168 ToolSpec::new(
169 name,
170 description,
171 json!({
172 "type": "object",
173 "properties": { "command": { "type": "string" } },
174 "required": ["command"]
175 }),
176 )
177 }
178
179 #[test]
180 fn render_fills_the_workspace_and_leaves_other_braces_alone() {
181 let out = render_system(
182 "work in {{ workspace }}, keep {mystery}",
183 Path::new("/ws"),
184 &[],
185 &[],
186 )
187 .unwrap();
188 assert_eq!(out, "work in /ws, keep {mystery}\n");
189 }
190
191 #[test]
192 fn a_for_loop_renders_its_body_once_per_skill() {
193 let out = render_system(
194 "{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
195 Path::new("/ws"),
196 &[skill("a", "da"), skill("b", "db")],
197 &[],
198 )
199 .unwrap();
200 assert_eq!(
201 out,
202 "- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
203 );
204 }
205
206 #[test]
207 fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
208 let out = render_system(
209 "{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
210 Path::new("/ws"),
211 &[],
212 &[tool("t", "dt")],
213 )
214 .unwrap();
215 assert_eq!(
216 out,
217 "- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
218 );
219 }
220
221 #[test]
226 fn the_json_filter_leaves_html_characters_alone() {
227 let spec = ToolSpec::new(
228 "t",
229 "the session's tool",
230 json!({
231 "type": "object",
232 "properties": {
233 "model": { "type": "string", "description": "a session's <provider>/<model>" }
234 }
235 }),
236 );
237 let out = render_system(
238 "{{ tools[0].parameters | json }}",
239 Path::new("/ws"),
240 &[],
241 std::slice::from_ref(&spec),
242 )
243 .unwrap();
244 assert!(out.contains(r#""a session's <provider>/<model>""#), "{out}");
245 assert!(!out.contains("\\u0027"), "{out}");
246 assert!(!out.contains("\\u003c"), "{out}");
247
248 let escaped = render_system(
249 "{{ tools[0].parameters | tojson }}",
250 Path::new("/ws"),
251 &[],
252 &[spec],
253 )
254 .unwrap();
255 assert!(escaped.contains("\\u0027"), "{escaped}");
256 }
257
258 #[test]
259 fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
260 let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
261 let lines = vec![McpServerLine {
262 name: "atlassian".into(),
263 server: "atlassian".into(),
264 description: "Jira and Confluence".into(),
265 }];
266 let out = render_system_with_sections(
267 template,
268 Path::new("/ws"),
269 &[],
270 &[],
271 None,
272 None,
273 &lines,
274 None,
275 None,
276 )
277 .unwrap();
278 assert_eq!(
279 out,
280 "MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
281 );
282 let out = render_system_with_sections(
283 template,
284 Path::new("/ws"),
285 &[],
286 &[],
287 None,
288 None,
289 &[],
290 None,
291 None,
292 )
293 .unwrap();
294 assert!(!out.contains("atlassian"), "{out:?}");
298 assert!(!out.contains("MCP:"), "{out:?}");
299 }
300
301 #[test]
302 fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
303 let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
304 let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
305 assert_eq!(
306 with,
307 "Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
308 );
309 let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
310 assert_eq!(without, "\n");
311 }
312
313 #[test]
314 fn a_misspelled_variable_renders_empty_not_an_error() {
315 let out = render_system(
316 "{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
317 Path::new("/ws"),
318 &[skill("s", "d")],
319 &[],
320 )
321 .unwrap();
322 assert_eq!(out, "s\n");
323 }
324
325 #[test]
326 fn a_template_error_surfaces_instead_of_rendering_prose() {
327 let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
328 .expect_err("an unclosed block is a loud error, not prose");
329 assert!(err.to_string().contains("system"), "{err}");
330 }
331
332 #[test]
333 fn a_template_without_the_block_presents_no_skills() {
334 let out = render_system(
335 "base in {{ workspace }}\n",
336 Path::new("/ws"),
337 &[skill("s", "d")],
338 &[],
339 )
340 .unwrap();
341 assert_eq!(out, "base in /ws\n");
342 }
343
344 #[test]
345 fn the_sections_render_where_the_template_places_them() {
346 let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
347 let out = render_system_with_sections(
348 template,
349 Path::new("/ws"),
350 &[],
351 &[],
352 Some("- OS: linux"),
353 Some("prefer short answers"),
354 &[],
355 None,
356 None,
357 )
358 .unwrap();
359 assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
360 }
361
362 #[test]
363 fn absent_sections_render_nothing_like_undefined_variables() {
364 let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
369 let without = render_system_with_sections(
370 template,
371 Path::new("/ws"),
372 &[],
373 &[],
374 None,
375 None,
376 &[],
377 None,
378 None,
379 )
380 .unwrap();
381 assert_eq!(without, "open\nclose\n");
382 assert_eq!(
383 render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
384 without,
385 "render_system is the sections-less form of the same render"
386 );
387 }
388
389 #[test]
390 fn render_template_takes_the_callers_own_variables() {
391 let out = render_template(
392 "hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
393 &json!({ "who": "claw", "tag": "" }),
394 )
395 .unwrap();
396 assert_eq!(out, "hi claw\n");
397 }
398
399 #[test]
400 fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
401 let out = render_template(
402 "{{ section }}",
403 &json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
404 )
405 .unwrap();
406 assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
407 }
408}