use serde_json::Value;
const SKIP_KEYS: &[&str] = &["markup", "source"];
#[derive(Debug, Clone)]
pub enum Segment {
Literal(String),
Expr(String),
}
#[derive(Debug, Clone)]
pub struct ExprField {
pub ptr: String,
pub segments: Vec<Segment>,
pub is_whole: bool,
}
pub fn scan(config: &Value) -> Vec<ExprField> {
let mut out = Vec::new();
scan_value(config, "", &mut out);
out
}
fn escape_ptr_token(key: &str) -> String {
key.replace('~', "~0").replace('/', "~1")
}
fn scan_value(val: &Value, ptr: &str, out: &mut Vec<ExprField>) {
match val {
Value::Object(map) => {
for (k, v) in map {
if SKIP_KEYS.contains(&k.as_str()) {
continue;
}
let child = format!("{}/{}", ptr, escape_ptr_token(k));
scan_value(v, &child, out);
}
}
Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let child = format!("{}/{}", ptr, i);
scan_value(v, &child, out);
}
}
Value::String(s) => {
if let Some(segments) = parse_template(s) {
let is_whole = segments.len() == 1 && matches!(&segments[0], Segment::Expr(_));
out.push(ExprField {
ptr: ptr.to_string(),
segments,
is_whole,
});
}
}
_ => {}
}
}
fn parse_template(s: &str) -> Option<Vec<Segment>> {
if !s.contains("{{") {
return None;
}
let mut segments: Vec<Segment> = Vec::new();
let mut remaining = s;
let mut found_any = false;
while let Some(open) = remaining.find("{{") {
let before = &remaining[..open];
if !before.is_empty() {
segments.push(Segment::Literal(before.to_string()));
}
let after_open = &remaining[open + 2..];
if let Some(close) = after_open.find("}}") {
let expr = after_open[..close].trim().to_string();
if !expr.is_empty() {
segments.push(Segment::Expr(expr));
found_any = true;
} else {
segments.push(Segment::Literal("{{}}".to_string()));
}
remaining = &after_open[close + 2..];
} else {
segments.push(Segment::Literal(remaining[open..].to_string()));
remaining = "";
break;
}
}
if !remaining.is_empty() {
segments.push(Segment::Literal(remaining.to_string()));
}
if found_any { Some(segments) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn whole_expr() {
let config = json!({ "url": "{{ $input.endpoint }}" });
let fields = scan(&config);
assert_eq!(fields.len(), 1);
assert!(fields[0].is_whole);
assert_eq!(fields[0].ptr, "/url");
}
#[test]
fn interpolated_expr() {
let config = json!({ "url": "https://api.example.com/{{ $input.id }}/details" });
let fields = scan(&config);
assert_eq!(fields.len(), 1);
assert!(!fields[0].is_whole);
assert_eq!(fields[0].segments.len(), 3);
}
#[test]
fn no_exprs() {
let config = json!({ "url": "https://static.example.com/path" });
let fields = scan(&config);
assert!(fields.is_empty());
}
#[test]
fn skip_source_key() {
let config = json!({ "source": "return {{ bad }};" });
let fields = scan(&config);
assert!(fields.is_empty(), "source key must be skipped");
}
#[test]
fn nested_field() {
let config = json!({ "body": { "name": "{{ $trigger.auth.sub }}" } });
let fields = scan(&config);
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].ptr, "/body/name");
}
}