use serde_json::Value;
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubstErrorKind {
Unclosed,
BadPath,
EmptySegment,
UnknownNamespace,
NotATable,
MissingKey,
NullValue,
NilReply,
NilItem,
Serialize,
}
#[derive(Debug)]
pub(crate) struct SubstitutionError {
kind: SubstErrorKind,
offset: usize,
message: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl SubstitutionError {
fn new(kind: SubstErrorKind, offset: usize, message: String) -> Self {
SubstitutionError {
kind,
offset,
message,
source: None,
}
}
fn with_source(
kind: SubstErrorKind,
offset: usize,
message: String,
source: Box<dyn std::error::Error + Send + Sync>,
) -> Self {
SubstitutionError {
kind,
offset,
message,
source: Some(source),
}
}
}
impl std::fmt::Display for SubstitutionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} [{:?} at byte {}]",
self.message, self.kind, self.offset
)
}
}
impl std::error::Error for SubstitutionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|s| s as &(dyn std::error::Error + 'static))
}
}
type SubstResult<T> = std::result::Result<T, SubstitutionError>;
pub(crate) fn substitute(
prose: &str,
args: &str,
reply: Option<&str>,
item: Option<&str>,
var: &Value,
sys: &Value,
) -> Result<String> {
Ok(substitute_inner(prose, args, reply, item, var, sys)?)
}
fn substitute_inner(
prose: &str,
args: &str,
reply: Option<&str>,
item: Option<&str>,
var: &Value,
sys: &Value,
) -> SubstResult<String> {
let mut out = String::with_capacity(prose.len());
let bytes = prose.as_bytes();
let mut i = 0;
while i < prose.len() {
if bytes[i] == b'\\' && i + 1 < prose.len() {
let next = bytes[i + 1];
if matches!(next, b'{' | b'}' | b'\\') {
out.push(next as char);
i += 2;
continue;
}
}
if bytes[i] == b'{' && i + 1 < prose.len() && bytes[i + 1] == b'{' {
let start = i;
let after = &prose[i + 2..];
let end = after.find("}}").ok_or_else(|| {
SubstitutionError::new(
SubstErrorKind::Unclosed,
start,
"unclosed '{{' in prose".to_string(),
)
})?;
let path = after[..end].trim();
out.push_str(&resolve(path, start, args, reply, item, var, sys)?);
i += 2 + end + 2;
continue;
}
let Some(ch) = prose[i..].chars().next() else {
break;
};
out.push(ch);
i += ch.len_utf8();
}
Ok(out)
}
fn resolve(
path: &str,
offset: usize,
args: &str,
reply: Option<&str>,
item: Option<&str>,
var: &Value,
sys: &Value,
) -> SubstResult<String> {
if path == "args" {
return Ok(args.to_string());
}
if path == "reply" {
return reply.map(String::from).ok_or_else(|| {
SubstitutionError::new(
SubstErrorKind::NilReply,
offset,
"{{ reply }} is nil (no prior section reply)".to_string(),
)
});
}
if path == "item" {
return item.map(String::from).ok_or_else(|| {
SubstitutionError::new(
SubstErrorKind::NilItem,
offset,
"{{ item }} is nil (not inside a fanout arm)".to_string(),
)
});
}
let Some((namespace, keys)) = path.split_once('.') else {
return Err(SubstitutionError::new(
SubstErrorKind::BadPath,
offset,
format!("bad path: {{{{ {} }}}}", path_preview(path)),
));
};
for segment in path.split('.') {
if segment.is_empty() || segment.trim() != segment {
return Err(SubstitutionError::new(
SubstErrorKind::EmptySegment,
offset,
format!(
"empty or padded path segment in {{{{ {} }}}}",
path_preview(path)
),
));
}
}
let root = match namespace {
"var" => var,
"sys" => sys,
"args" | "reply" | "item" => {
return Err(SubstitutionError::new(
SubstErrorKind::NotATable,
offset,
format!("{namespace} is a string, not a table"),
));
}
other => {
return Err(SubstitutionError::new(
SubstErrorKind::UnknownNamespace,
offset,
format!(
"unknown namespace '{}' in {{{{ {} }}}}",
path_preview(other),
path_preview(path)
),
));
}
};
let mut current = root;
for key in keys.split('.') {
current = current.get(key).ok_or_else(|| {
SubstitutionError::new(
SubstErrorKind::MissingKey,
offset,
format!("missing {{{{ {} }}}}", path_preview(path)),
)
})?;
}
render(current, path, offset)
}
fn path_preview(path: &str) -> String {
use std::fmt::Write as _;
const MAX_PREVIEW_CHARS: usize = 80;
let mut out = String::with_capacity(path.len().min(MAX_PREVIEW_CHARS));
for ch in path.chars().take(MAX_PREVIEW_CHARS) {
match ch {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{{{:04x}}}", u32::from(c));
}
c => out.push(c),
}
}
if path.chars().count() > MAX_PREVIEW_CHARS {
out.push_str("...");
}
out
}
fn render(value: &Value, path: &str, offset: usize) -> SubstResult<String> {
match value {
Value::Null => Err(SubstitutionError::new(
SubstErrorKind::NullValue,
offset,
format!("missing {{{{ {} }}}}", path_preview(path)),
)),
Value::String(s) => Ok(s.clone()),
Value::Bool(b) => Ok(b.to_string()),
Value::Number(n) => Ok(n.to_string()),
Value::Array(_) | Value::Object(_) => serde_json::to_string(value).map_err(|error| {
SubstitutionError::with_source(
SubstErrorKind::Serialize,
offset,
format!("could not serialize {{{{ {} }}}}", path_preview(path)),
Box::new(error),
)
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn run(prose: &str) -> Result<String> {
let var = json!({ "kind": "library", "count": 3, "row": { "a": 1 } });
let sys = json!({ "when": "2026-07-29T00:00:00Z", "id": 1 });
substitute(prose, "Acme Corp", None, None, &var, &sys)
}
fn err_of(prose: &str) -> SubstitutionError {
let var = json!({ "kind": "library", "row": { "a": 1 }, "arr": [1, 2] });
let sys = json!({ "id": 1 });
substitute_inner(prose, "Acme Corp", Some("r"), Some("i"), &var, &sys)
.expect_err("expected substitution failure")
}
#[test]
fn substitution_diagnostics_escape_and_bound_the_placeholder() {
let hostile = format!("var.{}", "x".repeat(500));
let preview = path_preview(&hostile);
assert!(
preview.chars().count() <= 83,
"preview must be bounded, got {} chars",
preview.chars().count()
);
assert!(preview.ends_with("..."), "over-long preview must be elided");
let with_controls = path_preview("var.a\nb\tc");
assert!(
!with_controls.contains('\n') && !with_controls.contains('\t'),
"control characters must be escaped, got: {with_controls}"
);
assert!(with_controls.contains("\\n") && with_controls.contains("\\t"));
}
#[test]
fn resolves_args() {
assert_eq!(run("hi {{ args }}!").unwrap(), "hi Acme Corp!");
}
#[test]
fn resolves_var_scalar() {
assert_eq!(run("a {{ var.kind }} paper").unwrap(), "a library paper");
assert_eq!(run("{{ var.count }}").unwrap(), "3");
}
#[test]
fn resolves_sys() {
assert_eq!(run("id {{ sys.id }}").unwrap(), "id 1");
assert_eq!(run("at {{ sys.when }}").unwrap(), "at 2026-07-29T00:00:00Z");
}
#[test]
fn table_renders_as_json() {
assert_eq!(run("{{ var.row }}").unwrap(), "{\"a\":1}");
}
#[test]
fn missing_key_is_error() {
assert!(run("{{ var.nope }}").is_err());
assert!(run("{{ ghost.x }}").is_err());
let sys_error = run("{{ sys.bogus }}").expect_err("unknown sys field must fail");
assert!(
sys_error.to_string().contains("missing {{ sys.bogus }}"),
"error was {sys_error}"
);
}
#[test]
fn no_placeholders_passthrough() {
assert_eq!(run("plain text").unwrap(), "plain text");
}
#[test]
fn unclosed_is_error() {
assert_eq!(err_of("open {{ args").kind, SubstErrorKind::Unclosed);
}
#[test]
fn escaped_delimiters_are_literal() {
assert_eq!(
run(r"literal \{{ args }} here").unwrap(),
"literal {{ args }} here"
);
assert_eq!(run(r"close \}} brace").unwrap(), "close }} brace");
assert_eq!(run(r"back \\ slash").unwrap(), r"back \ slash");
}
#[test]
fn escape_then_real_placeholder_adjacent() {
assert_eq!(run(r"\{{x}}{{ args }}").unwrap(), "{{x}}Acme Corp");
}
#[test]
fn lone_backslash_is_literal() {
assert_eq!(run(r"a\b").unwrap(), r"a\b");
assert_eq!(run("trailing\\").unwrap(), "trailing\\");
}
#[test]
fn replacement_produced_delimiters_are_not_resubstituted() {
let var = json!({ "payload": "{{ args }}" });
let sys = json!({});
let out = substitute("value: {{ var.payload }}", "SECRET", None, None, &var, &sys).unwrap();
assert_eq!(out, "value: {{ args }}");
}
#[test]
fn empty_or_padded_segments_are_rejected() {
for bad in ["var.", "var..x", "var. .x", "var.x.", "var. .x .y"] {
let prose = format!("{{{{ {bad} }}}}");
let e = err_of(&prose);
assert_eq!(
e.kind,
SubstErrorKind::EmptySegment,
"path {bad:?} must be an empty-segment error, got {:?}",
e.kind
);
}
}
#[test]
fn valid_nested_segment_still_resolves() {
assert_eq!(run("{{ var.row.a }}").unwrap(), "1");
}
#[test]
fn error_carries_kind_and_offset() {
let e = err_of("prefix {{ ghost.x }}");
assert_eq!(e.kind, SubstErrorKind::UnknownNamespace);
assert_eq!(e.offset, 7, "offset must point at the '{{{{'");
assert!(e.to_string().contains("ghost.x"));
}
#[test]
fn null_value_and_reply_item_kinds() {
let var = json!({ "n": Value::Null });
let sys = json!({});
let e = substitute_inner("{{ var.n }}", "", None, None, &var, &sys).unwrap_err();
assert_eq!(e.kind, SubstErrorKind::NullValue);
let e = substitute_inner("{{ reply }}", "", None, None, &var, &sys).unwrap_err();
assert_eq!(e.kind, SubstErrorKind::NilReply);
let e = substitute_inner("{{ item }}", "", None, None, &var, &sys).unwrap_err();
assert_eq!(e.kind, SubstErrorKind::NilItem);
}
#[test]
fn not_a_table_kind() {
let e = err_of("{{ reply.x }}");
assert_eq!(e.kind, SubstErrorKind::NotATable);
assert!(e.to_string().contains("not a table"));
}
#[test]
fn array_renders_as_json() {
let var = json!({ "arr": [1, 2, 3] });
let sys = json!({});
let out = substitute("{{ var.arr }}", "", None, None, &var, &sys).unwrap();
assert_eq!(out, "[1,2,3]");
}
#[test]
fn resolves_reply_when_present() {
let var = json!({});
let sys = json!({});
let out = substitute(
"prev: {{ reply }}",
"",
Some("model output"),
None,
&var,
&sys,
)
.unwrap();
assert_eq!(out, "prev: model output");
}
#[test]
fn reply_nil_is_error() {
let var = json!({});
let sys = json!({});
let err =
substitute("{{ reply }}", "", None, None, &var, &sys).expect_err("nil reply must fail");
assert!(
err.to_string().contains("nil"),
"error must mention nil: {err}"
);
}
#[test]
fn reply_dot_path_is_error() {
let var = json!({});
let sys = json!({});
let err = substitute("{{ reply.x }}", "", Some("text"), None, &var, &sys)
.expect_err("reply is a string, not a table");
assert!(
err.to_string().contains("not a table"),
"error must say not a table: {err}"
);
}
#[test]
fn resolves_item_when_present() {
let var = json!({});
let sys = json!({});
let out = substitute("topic: {{ item }}", "", None, Some("the angle"), &var, &sys).unwrap();
assert_eq!(out, "topic: the angle");
}
#[test]
fn item_nil_is_error() {
let var = json!({});
let sys = json!({});
let err =
substitute("{{ item }}", "", None, None, &var, &sys).expect_err("nil item must fail");
assert!(
err.to_string().contains("nil"),
"error must mention nil: {err}"
);
}
#[test]
fn item_dot_path_is_error() {
let var = json!({});
let sys = json!({});
let err = substitute("{{ item.x }}", "", None, Some("text"), &var, &sys)
.expect_err("item is a string, not a table");
assert!(
err.to_string().contains("not a table"),
"error must say not a table: {err}"
);
}
}