vectorcraft/crates/engine/src/steps.rs

139 lines
5.5 KiB
Rust

//! References between the steps of a batch (`vectorcraft-cli run`, MCP `command_batch`): a
//! parameter string that is exactly `$N` or `$N.path` takes that value from the result of step `N`
//! (0-based, in the order the steps ran), and `$last…` from the step before. A path goes into
//! objects by key and into arrays by index: `$1.id`, `$2.ids.0`, `$last.ids[0]`. `$$` at the start
//! of a string stands for one `$` and is never a reference (text such as `$$1.99`). A string that
//! looks like a reference but names no value is an error, so a step never runs with a reference
//! left in it.
use serde_json::Value;
/// Most nesting [`resolve`] walks into: deeper parameters are left as they are.
const MAX_DEPTH: usize = 64;
/// `params` with every step reference replaced from `results` (the results of the steps run so
/// far, in order); an error names a reference that resolves to nothing.
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
resolve_at(params, results, 0)
}
fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result<Value, String> {
if depth > MAX_DEPTH {
return Ok(v.clone());
}
Ok(match v {
Value::String(s) => match s.strip_prefix("$$") {
Some(rest) => Value::String(format!("${rest}")),
None => match parse(s) {
Some((step, path)) => lookup(s, step, &path, results)?,
None => v.clone(),
},
},
Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1)).collect::<Result<_, _>>()?),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1)?))).collect::<Result<_, String>>()?),
_ => v.clone(),
})
}
/// Which step a reference names.
#[derive(Debug, PartialEq)]
enum Step {
Index(usize),
Last,
}
/// A path segment: an object key or an array index (`.0` is either, as the value decides).
#[derive(Debug, PartialEq)]
enum Seg {
Key(String),
Index(usize),
}
/// `$N…` or `$last…` → the step and the path after it; `None` for any other string.
fn parse(s: &str) -> Option<(Step, Vec<Seg>)> {
let rest = s.strip_prefix('$')?;
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let (step, mut rest) = if digits > 0 {
let (n, rest) = rest.split_at_checked(digits)?;
(Step::Index(n.parse().ok()?), rest)
} else {
(Step::Last, rest.strip_prefix("last")?)
};
let mut path = vec![];
while !rest.is_empty() {
if let Some(r) = rest.strip_prefix('.') {
let len = r.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
let (key, r) = r.split_at_checked(len)?;
if key.is_empty() {
return None;
}
path.push(Seg::Key(key.to_string()));
rest = r;
} else {
let (n, r) = rest.strip_prefix('[')?.split_once(']')?;
path.push(Seg::Index(n.parse().ok()?));
rest = r;
}
}
Some((step, path))
}
fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result<Value, String> {
let (i, found) = match step {
Step::Index(i) => (i, results.get(i)),
Step::Last => (results.len().saturating_sub(1), results.last()),
};
let mut cur = found.ok_or_else(|| format!("`{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $)"))?;
for seg in path {
let next = match (seg, cur) {
(Seg::Key(k), Value::Object(o)) => o.get(k),
(Seg::Key(k), Value::Array(a)) => k.parse::<usize>().ok().and_then(|n| a.get(n)),
(Seg::Index(n), Value::Array(a)) => a.get(*n),
_ => None,
};
cur = next.ok_or_else(|| format!("`{text}`: step {i}'s result has no such value (write $$ for a literal $)"))?;
}
Ok(cur.clone())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn references_take_values_from_earlier_results() {
let results = [json!({"index": 0}), json!({"id": 2}), json!({"ids": [5, 6]})];
let p = json!({"id": "$1.id", "ids": ["$2.ids.1", "$last.ids[0]"], "nested": {"all": "$2"}, "n": 3, "flag": true});
assert_eq!(resolve(&p, &results).unwrap(), json!({"id": 2, "ids": [6, 5], "nested": {"all": {"ids": [5, 6]}}, "n": 3, "flag": true}));
}
#[test]
fn plain_strings_and_escapes_are_left_alone() {
let results = [json!({"id": 2})];
let p = json!({"text": "costs $1", "price": "$$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"});
assert_eq!(
resolve(&p, &results).unwrap(),
json!({"text": "costs $1", "price": "$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"})
);
}
#[test]
fn a_reference_to_nothing_is_an_error() {
let results = [json!({"id": 2})];
for bad in ["$1", "$1.id", "$0.ids", "$0.id.x", "$last.nope", "$7[0]"] {
let e = resolve(&json!({"id": bad}), &results).unwrap_err();
assert!(e.contains(bad), "{bad}: {e}");
}
assert!(resolve(&json!({"id": "$last"}), &[]).is_err(), "no step before the first");
// Huge indexes and deep nesting don't panic.
assert!(resolve(&json!("$99999999999999999999999.id"), &results).is_ok_and(|v| v == json!("$99999999999999999999999.id")));
let mut deep = json!("$0.id");
for _ in 0..200 {
deep = json!([deep]);
}
assert!(resolve(&deep, &results).is_ok());
}
}