use std::path::Path;
use minijinja::{Environment, Value};
use rhema_core::ToolSpec;
use crate::skills::Skill;
pub use minijinja::Error as TemplateError;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct McpServerLine {
pub name: String,
pub server: String,
pub description: String,
}
pub fn render_system(
template: &str,
workspace: &Path,
skills: &[Skill],
tools: &[ToolSpec],
) -> Result<String, TemplateError> {
render_system_with_sections(
template,
workspace,
skills,
tools,
None,
None,
&[],
None,
None,
)
}
#[expect(
clippy::too_many_arguments,
reason = "one parameter per prompt section keeps the render's inputs flat"
)]
pub fn render_system_with_sections(
template: &str,
workspace: &Path,
skills: &[Skill],
tools: &[ToolSpec],
environment: Option<&str>,
user: Option<&str>,
mcp: &[McpServerLine],
vision: Option<&str>,
jobs: Option<&str>,
) -> Result<String, TemplateError> {
let context = serde_json::json!({
"workspace": workspace.display().to_string(),
"skills": skills,
"tools": tools,
"environment": environment,
"user": user,
"mcp": mcp,
"vision": vision,
"jobs": jobs,
});
render_template(template, &context)
}
fn json(value: Value) -> Result<String, TemplateError> {
serde_json::to_string(&value).map_err(|err| {
TemplateError::new(
minijinja::ErrorKind::InvalidOperation,
"cannot serialize to JSON",
)
.with_source(err)
})
}
pub fn render_template(
template: &str,
context: &serde_json::Value,
) -> Result<String, TemplateError> {
let mut env = Environment::new();
env.set_syntax(
minijinja::syntax::SyntaxConfig::builder()
.trim_blocks(true)
.lstrip_blocks(true)
.build()?,
);
env.add_filter("json", json);
env.add_template("system", template)?;
Ok(env
.get_template("system")?
.render(Value::from(minijinja::value::Serde(context)))?
.trim_end()
.to_owned()
+ "\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn skill(name: &str, description: &str) -> Skill {
Skill {
name: name.into(),
description: description.into(),
license: None,
path: format!("/cfg/skills/{name}/SKILL.md").into(),
body: "the rules".into(),
}
}
fn tool(name: &str, description: &str) -> ToolSpec {
ToolSpec::new(
name,
description,
json!({
"type": "object",
"properties": { "command": { "type": "string" } },
"required": ["command"]
}),
)
}
#[test]
fn render_fills_the_workspace_and_leaves_other_braces_alone() {
let out = render_system(
"work in {{ workspace }}, keep {mystery}",
Path::new("/ws"),
&[],
&[],
)
.unwrap();
assert_eq!(out, "work in /ws, keep {mystery}\n");
}
#[test]
fn a_for_loop_renders_its_body_once_per_skill() {
let out = render_system(
"{% for skill in skills %}- {{ skill.name }}: {{ skill.description }} — cat {{ skill.path }}\n{% endfor %}",
Path::new("/ws"),
&[skill("a", "da"), skill("b", "db")],
&[],
)
.unwrap();
assert_eq!(
out,
"- a: da — cat /cfg/skills/a/SKILL.md\n- b: db — cat /cfg/skills/b/SKILL.md\n"
);
}
#[test]
fn a_for_loop_renders_its_body_once_per_tool_with_the_schema() {
let out = render_system(
"{% for tool in tools %}- {{ tool.name }}: {{ tool.description }} — {{ tool.parameters | tojson }}\n{% endfor %}",
Path::new("/ws"),
&[],
&[tool("t", "dt")],
)
.unwrap();
assert_eq!(
out,
"- t: dt — {\"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"], \"type\": \"object\"}\n"
);
}
#[test]
fn the_json_filter_leaves_html_characters_alone() {
let spec = ToolSpec::new(
"t",
"the session's tool",
json!({
"type": "object",
"properties": {
"model": { "type": "string", "description": "a session's <provider>/<model>" }
}
}),
);
let out = render_system(
"{{ tools[0].parameters | json }}",
Path::new("/ws"),
&[],
std::slice::from_ref(&spec),
)
.unwrap();
assert!(out.contains(r#""a session's <provider>/<model>""#), "{out}");
assert!(!out.contains("\\u0027"), "{out}");
assert!(!out.contains("\\u003c"), "{out}");
let escaped = render_system(
"{{ tools[0].parameters | tojson }}",
Path::new("/ws"),
&[],
&[spec],
)
.unwrap();
assert!(escaped.contains("\\u0027"), "{escaped}");
}
#[test]
fn the_mcp_section_renders_lines_and_vanishes_when_empty() {
let template = "{% if mcp %}MCP:\n{% for server in mcp %}- {{ server.name }} (server: {{ server.server }}): {{ server.description }}\n{% endfor %}{% endif %}";
let lines = vec![McpServerLine {
name: "atlassian".into(),
server: "atlassian".into(),
description: "Jira and Confluence".into(),
}];
let out = render_system_with_sections(
template,
Path::new("/ws"),
&[],
&[],
None,
None,
&lines,
None,
None,
)
.unwrap();
assert_eq!(
out,
"MCP:\n- atlassian (server: atlassian): Jira and Confluence\n"
);
let out = render_system_with_sections(
template,
Path::new("/ws"),
&[],
&[],
None,
None,
&[],
None,
None,
)
.unwrap();
assert!(!out.contains("atlassian"), "{out:?}");
assert!(!out.contains("MCP:"), "{out:?}");
}
#[test]
fn an_if_around_the_skills_section_holds_a_vanishing_heading() {
let template = "{% if skills %}Skills (name / trigger / file):\n{% for skill in skills %}{{ skill.name }} / {{ skill.description }} / {{ skill.path }}\n{% endfor %}{% endif %}";
let with = render_system(template, Path::new("/ws"), &[skill("s", "d")], &[]).unwrap();
assert_eq!(
with,
"Skills (name / trigger / file):\ns / d / /cfg/skills/s/SKILL.md\n"
);
let without = render_system(template, Path::new("/ws"), &[], &[]).unwrap();
assert_eq!(without, "\n");
}
#[test]
fn a_misspelled_variable_renders_empty_not_an_error() {
let out = render_system(
"{% for skill in skills %}{{ skill.nope }}{{ skill.name }}{% endfor %}",
Path::new("/ws"),
&[skill("s", "d")],
&[],
)
.unwrap();
assert_eq!(out, "s\n");
}
#[test]
fn a_template_error_surfaces_instead_of_rendering_prose() {
let err = render_system("{% if skills %} never closed", Path::new("/ws"), &[], &[])
.expect_err("an unclosed block is a loud error, not prose");
assert!(err.to_string().contains("system"), "{err}");
}
#[test]
fn a_template_without_the_block_presents_no_skills() {
let out = render_system(
"base in {{ workspace }}\n",
Path::new("/ws"),
&[skill("s", "d")],
&[],
)
.unwrap();
assert_eq!(out, "base in /ws\n");
}
#[test]
fn the_sections_render_where_the_template_places_them() {
let template = "open\n{% if user %}{{ user }}\n{% endif %}{% if environment %}{{ environment }}\n{% endif %}close";
let out = render_system_with_sections(
template,
Path::new("/ws"),
&[],
&[],
Some("- OS: linux"),
Some("prefer short answers"),
&[],
None,
None,
)
.unwrap();
assert_eq!(out, "open\nprefer short answers\n- OS: linux\nclose\n");
}
#[test]
fn absent_sections_render_nothing_like_undefined_variables() {
let template = "open\n{% if user %}{{ user }}\n{% endif %}close";
let without = render_system_with_sections(
template,
Path::new("/ws"),
&[],
&[],
None,
None,
&[],
None,
None,
)
.unwrap();
assert_eq!(without, "open\nclose\n");
assert_eq!(
render_system(template, Path::new("/ws"), &[], &[]).unwrap(),
without,
"render_system is the sections-less form of the same render"
);
}
#[test]
fn render_template_takes_the_callers_own_variables() {
let out = render_template(
"hi {{ who }}{% if tag %} ({{ tag }}){% endif %}",
&json!({ "who": "claw", "tag": "" }),
)
.unwrap();
assert_eq!(out, "hi claw\n");
}
#[test]
fn a_values_own_text_is_inserted_verbatim_never_reparsed() {
let out = render_template(
"{{ section }}",
&json!({ "section": "- braces stay: {{ keep }} and {soul}\n" }),
)
.unwrap();
assert_eq!(out, "- braces stay: {{ keep }} and {soul}\n");
}
}