system.md carries {{ label }}-style variables and an {% if not archetype %} block in place of the config:repo / config:archetype marker pairs, so prompt.rs drops its line filter, marker parsers and string replaces. The rendered prompt is byte for byte the old one for a custom agent, an archetype agent and an empty archetype name, with and without hive, swarm, docs and operators. hive-template now drops a block tag's own line.
101 lines
3.2 KiB
Rust
101 lines
3.2 KiB
Rust
//! The one template language the workspace renders with: Jinja, through
|
|
//! `minijinja`, with an unknown variable an error rather than an empty string.
|
|
//!
|
|
//! A template is `{{ name }}` for a value and `{% if cond %}...{% endif %}` for
|
|
//! a block; a lone `{` or `}` is plain text. Callers pass their context as any
|
|
//! `Serialize` value and get back the text or a message naming what is wrong.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use minijinja::{Environment, UndefinedBehavior};
|
|
use serde::Serialize;
|
|
|
|
/// An environment that refuses a name the context does not hold, keeps the
|
|
/// template's trailing newline, and drops a block tag's own line (`{% if %}` on a
|
|
/// line by itself leaves no blank line behind).
|
|
fn environment() -> Environment<'static> {
|
|
let mut env = Environment::new();
|
|
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
|
env.set_keep_trailing_newline(true);
|
|
env.set_trim_blocks(true);
|
|
env.set_lstrip_blocks(true);
|
|
env
|
|
}
|
|
|
|
/// `template` rendered against `context`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// The template does not parse, or it names a variable `context` does not hold.
|
|
pub fn render(template: &str, context: &impl Serialize) -> Result<String, String> {
|
|
environment()
|
|
.render_str(template, context)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// The variables `template` reads that it does not define itself, such as `repo`
|
|
/// for `{{ repo }}`. Lets a caller refuse a template that names an unknown field
|
|
/// before any event renders it.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// The template does not parse.
|
|
pub fn variables(template: &str) -> Result<BTreeSet<String>, String> {
|
|
let env = environment();
|
|
let parsed = env.template_from_str(template).map_err(|e| e.to_string())?;
|
|
Ok(parsed.undeclared_variables(false).into_iter().collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_value_and_a_block_render() {
|
|
let ctx = json!({ "repo": "o/r", "role": "agent" });
|
|
let out = render(
|
|
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
|
|
&ctx,
|
|
);
|
|
assert_eq!(out.as_deref(), Ok("o/r (agent)"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_block_tag_on_its_own_line_leaves_no_line_behind() {
|
|
let out = render(
|
|
"a\n{% if on %}\nb\n{% else %}\nc\n{% endif %}\nd\n",
|
|
&json!({ "on": false }),
|
|
);
|
|
assert_eq!(out.as_deref(), Ok("a\nc\nd\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn single_braces_are_plain_text() {
|
|
assert_eq!(
|
|
render("{not a name} and { open", &json!({})).as_deref(),
|
|
Ok("{not a name} and { open")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_variable_is_an_error() {
|
|
assert!(render("{{ numbr }}", &json!({ "number": "7" })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn variables_lists_what_the_template_reads_not_what_it_defines() {
|
|
let found = variables("{{ repo }}#{{ number }}{% set x = 1 %}{{ x }}").expect("parses");
|
|
assert_eq!(
|
|
found.into_iter().collect::<Vec<_>>(),
|
|
["number", "repo"].map(String::from)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_template_that_does_not_parse_is_refused() {
|
|
assert!(variables("{{ repo ").is_err());
|
|
assert!(render("{% if %}", &json!({})).is_err());
|
|
}
|
|
}
|