use std::collections::HashMap;
use std::sync::Arc;
use rand::Rng;
use serde_json::Value;
use tracing::instrument;
use crate::request_template::definition::TemplateDef;
use crate::request_template::error::TemplateError;
use crate::request_template::generator::GeneratorContext;
use crate::request_template::{ENV_PLACEHOLDER_PREFIX, PlaceholderRef, parse_placeholder};
enum Segment {
Static(Arc<str>),
Placeholder(String),
}
pub struct CompiledTemplate {
segments: Vec<Segment>,
}
impl CompiledTemplate {
pub fn compile(body: &Value) -> Result<Self, TemplateError> {
let mut segments = Vec::new();
compile_value(body, &mut segments)?;
Ok(Self { segments })
}
pub fn render(
&self,
ctx: &GeneratorContext,
rng: &mut impl Rng,
) -> Result<String, TemplateError> {
let mut buf = String::new();
for segment in &self.segments {
match segment {
Segment::Static(s) => buf.push_str(s),
Segment::Placeholder(name) => {
if let Some(precomputed) = ctx.resolved.get(name) {
buf.push_str(precomputed);
} else {
let val = ctx.generate_by_name(name, rng);
buf.push_str(
&serde_json::to_string(&val).map_err(TemplateError::Serialization)?,
);
}
}
}
}
Ok(buf)
}
}
fn compile_value(value: &Value, out: &mut Vec<Segment>) -> Result<(), TemplateError> {
match value {
Value::String(s) => {
if let Some(ph) = parse_placeholder(s) {
out.push(Segment::Placeholder(ph.name));
} else {
let serialized = serde_json::to_string(s).map_err(TemplateError::Serialization)?;
out.push(Segment::Static(Arc::from(serialized.as_str())));
}
}
Value::Object(map) => {
out.push(Segment::Static(Arc::from("{")));
let mut first = true;
for (key, val) in map {
if !first {
out.push(Segment::Static(Arc::from(",")));
}
first = false;
let key_json = serde_json::to_string(key).map_err(TemplateError::Serialization)?;
out.push(Segment::Static(Arc::from(format!("{key_json}:").as_str())));
compile_value(val, out)?;
}
out.push(Segment::Static(Arc::from("}")));
}
Value::Array(arr) => {
out.push(Segment::Static(Arc::from("[")));
let mut first = true;
for val in arr {
if !first {
out.push(Segment::Static(Arc::from(",")));
}
first = false;
compile_value(val, out)?;
}
out.push(Segment::Static(Arc::from("]")));
}
_ => {
let serialized = serde_json::to_string(value).map_err(TemplateError::Serialization)?;
out.push(Segment::Static(Arc::from(serialized.as_str())));
}
}
Ok(())
}
pub trait PlaceholderHandler {
fn matches(&self, ph: &PlaceholderRef) -> bool;
fn resolve(
&self,
body: &Value,
ctx: &GeneratorContext,
) -> Result<HashMap<String, Arc<str>>, TemplateError>;
fn collect_names(&self, body: &Value) -> Vec<String> {
let mut names = Vec::new();
self.walk(body, &mut names);
names.sort();
names.dedup();
names
}
fn walk(&self, value: &Value, names: &mut Vec<String>) {
match value {
Value::String(s) => {
if let Some(ph) = parse_placeholder(s)
&& self.matches(&ph)
{
names.push(ph.name);
}
}
Value::Object(map) => map.values().for_each(|v| self.walk(v, names)),
Value::Array(arr) => arr.iter().for_each(|v| self.walk(v, names)),
_ => {}
}
}
}
#[derive(Debug)]
pub struct GlobalPlaceholderHandler;
impl PlaceholderHandler for GlobalPlaceholderHandler {
fn matches(&self, ph: &PlaceholderRef) -> bool {
ph.global && !ph.name.starts_with(ENV_PLACEHOLDER_PREFIX)
}
fn resolve(
&self,
body: &Value,
ctx: &GeneratorContext,
) -> Result<HashMap<String, Arc<str>>, TemplateError> {
let names = self.collect_names(body);
let mut rng = rand::rng();
names
.into_iter()
.map(|n| {
let val = ctx.generate_by_name(&n, &mut rng);
let serialized =
serde_json::to_string(&val).map_err(TemplateError::Serialization)?;
Ok((n, Arc::from(serialized.as_str())))
})
.collect()
}
}
#[derive(Debug)]
pub struct EnvPlaceholderHandler;
impl PlaceholderHandler for EnvPlaceholderHandler {
fn matches(&self, ph: &PlaceholderRef) -> bool {
ph.name.starts_with(ENV_PLACEHOLDER_PREFIX)
}
fn resolve(
&self,
body: &Value,
_ctx: &GeneratorContext,
) -> Result<HashMap<String, Arc<str>>, TemplateError> {
let names = self.collect_names(body);
resolve_env_vars(&names)
}
}
fn resolve_env_vars(names: &[String]) -> Result<HashMap<String, Arc<str>>, TemplateError> {
let mut map = HashMap::new();
for name in names {
let var_name = &name[ENV_PLACEHOLDER_PREFIX.len()..];
if var_name.is_empty() {
return Err(TemplateError::InvalidEnvVarName(name.to_string()));
}
match std::env::var(var_name) {
Ok(val) => {
let serialized =
serde_json::to_string(&val).map_err(TemplateError::Serialization)?;
map.insert(name.clone(), Arc::from(serialized.as_str()));
}
Err(_) => return Err(TemplateError::MissingEnvVar(var_name.to_string())),
}
}
Ok(map)
}
pub fn resolve_string_placeholders(
input: &str,
ctx: &GeneratorContext,
rng: &mut impl Rng,
) -> String {
if !input.contains("{{") {
return input.to_string();
}
let mut output = String::with_capacity(input.len());
let mut remaining = input;
while let Some(open) = remaining.find("{{") {
output.push_str(&remaining[..open]);
let after_open = &remaining[open + 2..];
match after_open.find("}}") {
Some(close_offset) => {
let placeholder_body = &after_open[..close_offset];
let wrapped = format!("{{{{{placeholder_body}}}}}");
let resolved_value = if let Some(ph) = parse_placeholder(&wrapped) {
if let Some(serialized) = ctx.resolved.get(&ph.name) {
match serde_json::from_str::<Value>(serialized) {
Ok(Value::String(s)) => s,
Ok(other) => other.to_string(),
Err(_) => serialized.to_string(),
}
} else {
let val = ctx.generate_by_name(&ph.name, rng);
match val {
Value::String(s) => s,
other => other.to_string(),
}
}
} else {
String::new()
};
output.push_str(&resolved_value);
remaining = &after_open[close_offset + 2..];
}
None => {
output.push_str("{{");
remaining = after_open;
}
}
}
output.push_str(remaining);
output
}
#[instrument(name = "lmn.template.validate_placeholders", skip(body, defs), fields(def_count = defs.len()))]
pub fn validate_placeholders(
body: &Value,
defs: &HashMap<String, TemplateDef>,
) -> Result<(), TemplateError> {
walk_strings(body, &mut |s| {
if let Some(ph) = parse_placeholder(s) {
if ph.name.starts_with(ENV_PLACEHOLDER_PREFIX) {
return Ok(()); }
if !defs.contains_key(&ph.name) {
return Err(TemplateError::UnknownPlaceholder(ph.name));
}
}
Ok(())
})
}
fn walk_strings<F>(value: &Value, f: &mut F) -> Result<(), TemplateError>
where
F: FnMut(&str) -> Result<(), TemplateError>,
{
match value {
Value::String(s) => f(s),
Value::Object(map) => {
for v in map.values() {
walk_strings(v, f)?;
}
Ok(())
}
Value::Array(arr) => {
for v in arr {
walk_strings(v, f)?;
}
Ok(())
}
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request_template::definition::{FloatDef, FloatStrategy, TemplateDef};
use crate::request_template::generator::GeneratorContext;
fn make_ctx_with_float(name: &str, value: f64) -> GeneratorContext {
let mut defs = HashMap::new();
defs.insert(
name.to_string(),
TemplateDef::Float(FloatDef {
strategy: FloatStrategy::Exact(value),
decimals: 2,
}),
);
GeneratorContext::new(defs)
}
fn make_ctx_with_choice(name: &str, choices: Vec<String>) -> GeneratorContext {
use crate::request_template::definition::{StringDef, StringStrategy};
let mut defs = HashMap::new();
defs.insert(
name.to_string(),
TemplateDef::String(StringDef {
strategy: StringStrategy::Choice(choices),
}),
);
GeneratorContext::new(defs)
}
#[test]
fn no_placeholder_returns_input_unchanged() {
let ctx = GeneratorContext::new(HashMap::new());
let result = resolve_string_placeholders("plain-header-value", &ctx, &mut rand::rng());
assert_eq!(result, "plain-header-value");
}
#[test]
fn resolves_choice_placeholder_without_quotes() {
let ctx = make_ctx_with_choice("user_id", vec!["alice".to_string()]);
let result = resolve_string_placeholders("user-{{user_id}}", &ctx, &mut rand::rng());
assert_eq!(result, "user-alice");
}
#[test]
fn resolves_float_placeholder() {
let ctx = make_ctx_with_float("amount", 9.99);
let result = resolve_string_placeholders("val={{amount}}", &ctx, &mut rand::rng());
assert_eq!(result, "val=9.99");
}
#[test]
fn resolves_multiple_placeholders_in_string() {
use crate::request_template::definition::{StringDef, StringStrategy};
let mut defs = HashMap::new();
defs.insert(
"a".to_string(),
TemplateDef::String(StringDef {
strategy: StringStrategy::Choice(vec!["foo".to_string()]),
}),
);
defs.insert(
"b".to_string(),
TemplateDef::String(StringDef {
strategy: StringStrategy::Choice(vec!["bar".to_string()]),
}),
);
let ctx = GeneratorContext::new(defs);
let result = resolve_string_placeholders("{{a}}-{{b}}", &ctx, &mut rand::rng());
assert_eq!(result, "foo-bar");
}
#[test]
fn unknown_placeholder_resolves_to_null_string() {
let ctx = GeneratorContext::new(HashMap::new());
let result =
resolve_string_placeholders("prefix-{{unknown}}-suffix", &ctx, &mut rand::rng());
assert_eq!(result, "prefix-null-suffix");
}
#[test]
fn unclosed_braces_preserved_literally() {
let ctx = GeneratorContext::new(HashMap::new());
let result = resolve_string_placeholders("{{unclosed", &ctx, &mut rand::rng());
assert_eq!(result, "{{unclosed");
}
#[test]
fn validate_placeholders_skips_env_prefixed_names() {
let body = Value::Object({
let mut m = serde_json::Map::new();
m.insert(
"token".to_string(),
Value::String("{{ENV:MY_VAR}}".to_string()),
);
m
});
let defs = HashMap::new();
assert!(validate_placeholders(&body, &defs).is_ok());
}
#[test]
fn compile_static_string_emits_json_quoted() {
let compiled = CompiledTemplate::compile(&serde_json::json!("hello")).unwrap();
assert_eq!(compiled.segments.len(), 1);
if let Segment::Static(s) = &compiled.segments[0] {
assert_eq!(s.as_ref(), "\"hello\"");
} else {
panic!("expected Static segment");
}
}
#[test]
fn compile_placeholder_string_emits_placeholder() {
let compiled = CompiledTemplate::compile(&serde_json::json!("{{val}}")).unwrap();
assert_eq!(compiled.segments.len(), 1);
if let Segment::Placeholder(name) = &compiled.segments[0] {
assert_eq!(name, "val");
} else {
panic!("expected Placeholder segment");
}
}
#[test]
fn compile_object_emits_braces_and_key() {
let compiled = CompiledTemplate::compile(&serde_json::json!({ "k": "v" })).unwrap();
assert!(compiled.segments.len() >= 3);
}
#[test]
fn compile_empty_object_roundtrips() {
let compiled = CompiledTemplate::compile(&serde_json::json!({})).unwrap();
let ctx = GeneratorContext::new(HashMap::new());
let result = compiled.render(&ctx, &mut rand::rng()).unwrap();
assert_eq!(result, "{}");
}
#[test]
fn compile_empty_array_roundtrips() {
let compiled = CompiledTemplate::compile(&serde_json::json!([])).unwrap();
let ctx = GeneratorContext::new(HashMap::new());
let result = compiled.render(&ctx, &mut rand::rng()).unwrap();
assert_eq!(result, "[]");
}
#[test]
fn compile_array_with_placeholder_renders_correctly() {
let ctx = make_ctx_with_float("val", 1.0);
let compiled =
CompiledTemplate::compile(&serde_json::json!(["static", "{{val}}"])).unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert_eq!(result[0], serde_json::json!("static"));
assert!(result[1].is_number());
}
#[test]
fn compile_special_chars_in_string_are_escaped() {
let compiled =
CompiledTemplate::compile(&serde_json::json!({"key": "hello \"world\"\nnewline"}))
.unwrap();
let ctx = GeneratorContext::new(HashMap::new());
let output = compiled.render(&ctx, &mut rand::rng()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["key"], serde_json::json!("hello \"world\"\nnewline"));
}
#[test]
fn compile_deeply_nested_renders_correctly() {
let ctx = make_ctx_with_float("price", 5.0);
let compiled =
CompiledTemplate::compile(&serde_json::json!({ "a": { "b": { "c": "{{price}}" } } }))
.unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert!(result["a"]["b"]["c"].is_number());
}
#[test]
fn render_substitutes_placeholder() {
let ctx = make_ctx_with_float("val", 42.0);
let compiled =
CompiledTemplate::compile(&serde_json::json!({ "field": "{{val}}" })).unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert!(result["field"].is_number());
}
#[test]
fn render_leaves_plain_string_unchanged() {
let ctx = GeneratorContext::new(HashMap::new());
let compiled = CompiledTemplate::compile(&serde_json::json!({ "field": "plain" })).unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert_eq!(result["field"], serde_json::json!("plain"));
}
#[test]
fn render_handles_nested_objects() {
let ctx = make_ctx_with_float("price", 10.0);
let compiled =
CompiledTemplate::compile(&serde_json::json!({ "order": { "price": "{{price}}" } }))
.unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert!(result["order"]["price"].is_number());
}
#[test]
fn render_uses_preresolved_value() {
let ctx = GeneratorContext::new(HashMap::new())
.with_resolved([("x".to_string(), Arc::from("99"))].into_iter().collect());
let compiled = CompiledTemplate::compile(&serde_json::json!({ "field": "{{x}}" })).unwrap();
let result: serde_json::Value =
serde_json::from_str(&compiled.render(&ctx, &mut rand::rng()).unwrap()).unwrap();
assert_eq!(result["field"], serde_json::json!(99));
}
#[test]
fn resolve_string_uses_preresolved_env_value() {
let ctx = GeneratorContext::new(HashMap::new()).with_resolved(
[("ENV:TOKEN".to_string(), Arc::from("\"mysecret\""))]
.into_iter()
.collect(),
);
let result = resolve_string_placeholders("Bearer {{ENV:TOKEN}}", &ctx, &mut rand::rng());
assert_eq!(result, "Bearer mysecret");
}
#[test]
fn global_handler_finds_global_placeholders() {
use serde_json::json;
let body = json!({ "a": "{{x:global}}", "b": "{{y}}", "c": "{{x:global}}" });
let handler = GlobalPlaceholderHandler;
let names = handler.collect_names(&body);
assert_eq!(names, vec!["x"]);
}
#[test]
fn global_handler_returns_empty_when_none() {
use serde_json::json;
let body = json!({ "a": "{{x}}", "b": "plain" });
let handler = GlobalPlaceholderHandler;
assert!(handler.collect_names(&body).is_empty());
}
}