use serde_json::Value;
use super::diagnostic::Diagnostic;
use super::shared::SharedDefinitions;
pub struct Cx<'a> {
pub shared: &'a SharedDefinitions,
pub origin: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Residue {
pub pass: &'static str,
pub noun: &'static str,
pub key: &'static str,
pub target: String,
pub path: String,
}
impl Residue {
pub fn syntax(&self) -> String {
format!(
"{{{}: {}}}",
Value::from(self.key),
Value::from(&*self.target)
)
}
pub fn describe(&self) -> String {
match self.key {
"use" => format!("a reference to fragment '{}'", self.target),
_ => format!("a reference to '{}'", self.target),
}
}
}
pub trait Pass: Send + Sync {
fn id(&self) -> &'static str;
fn noun(&self) -> &'static str;
fn residue(&self, doc: &Value, root: &str) -> Vec<Residue>;
fn apply(&self, doc: &mut Value, cx: &Cx<'_>, findings: &mut Vec<Diagnostic>);
}
pub fn passes() -> &'static [&'static dyn Pass] {
static FRAGMENTS: Fragments = Fragments;
static VALUES: Values = Values;
static PASSES: &[&dyn Pass] = &[&FRAGMENTS, &VALUES];
PASSES
}
pub fn compile(doc: &mut Value, cx: &Cx<'_>, findings: &mut Vec<Diagnostic>) -> Vec<&'static str> {
let mut applied = Vec::new();
for pass in passes() {
let fired = !pass.residue(doc, "").is_empty();
pass.apply(doc, cx, findings);
if fired {
applied.push(pass.id());
}
}
applied
}
pub fn residue(doc: &Value, root: &str) -> Vec<Residue> {
passes()
.iter()
.flat_map(|pass| pass.residue(doc, root))
.collect()
}
struct Fragments;
impl Fragments {
const ID: &'static str = "shared.fragments";
const NOUN: &'static str = "a task-fragment reference";
}
impl Pass for Fragments {
fn id(&self) -> &'static str {
Self::ID
}
fn noun(&self) -> &'static str {
Self::NOUN
}
fn residue(&self, doc: &Value, root: &str) -> Vec<Residue> {
let mut out = Vec::new();
if root == "tasks" {
steps(doc, root, &mut out);
} else if let Some(tasks) = doc.get("tasks") {
let at = if root.is_empty() {
"tasks".to_string()
} else {
format!("{root}.tasks")
};
steps(tasks, &at, &mut out);
}
out
}
fn apply(&self, doc: &mut Value, cx: &Cx<'_>, findings: &mut Vec<Diagnostic>) {
if let Some(tasks) = doc.get_mut("tasks").and_then(Value::as_array_mut) {
let expanded = cx.shared.expand_tasks(tasks, cx.origin, findings);
*tasks = expanded;
}
}
}
fn steps(tasks: &Value, path: &str, out: &mut Vec<Residue>) {
let Some(items) = tasks.as_array() else {
return;
};
for (i, item) in items.iter().enumerate() {
let at = format!("{path}[{i}]");
if let Some(name) = item.get("use").and_then(Value::as_str) {
out.push(Residue {
pass: Fragments::ID,
noun: Fragments::NOUN,
key: "use",
target: name.to_string(),
path: at,
});
continue;
}
if crate::engine::is_group(item)
&& let Some(inner) = item.get("tasks")
{
steps(inner, &format!("{at}.tasks"), out);
}
}
}
struct Values;
impl Values {
const ID: &'static str = "shared.values";
const NOUN: &'static str = "a shared-value reference";
}
impl Pass for Values {
fn id(&self) -> &'static str {
Self::ID
}
fn noun(&self) -> &'static str {
Self::NOUN
}
fn residue(&self, doc: &Value, root: &str) -> Vec<Residue> {
let mut out = Vec::new();
spliceable(doc, root, &mut out);
out
}
fn apply(&self, doc: &mut Value, cx: &Cx<'_>, findings: &mut Vec<Diagnostic>) {
cx.shared.splice(doc, cx.origin, findings);
}
}
fn spliceable(value: &Value, path: &str, out: &mut Vec<Residue>) {
match value {
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
spliceable(item, &format!("{path}[{i}]"), out);
}
}
Value::Object(map) => {
if let Some(target) = map.get("$from").and_then(Value::as_str) {
out.push(Residue {
pass: Values::ID,
noun: Values::NOUN,
key: "$from",
target: target.to_string(),
path: path.to_string(),
});
}
for (key, v) in map {
let at = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
spliceable(v, &at, out);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn catalog() -> SharedDefinitions {
let mut shared = SharedDefinitions::default();
let mut findings = Vec::new();
shared.merge(
&json!({
"constants": { "db": { "connector": "mongo", "database": "app" } },
"errors": { "NOT_FOUND": { "status": 404, "body": "nope" } },
"fragments": { "guard": {
"params": { "msg": { "default": "denied" } },
"tasks": [ { "id": "deny", "name": "Deny", "function": { "name": "map",
"input": { "mappings": [
{ "path": "data.msg", "logic": { "$param": "msg" } } ] } } } ] } }
}),
"catalog.json",
&mut findings,
);
assert!(findings.is_empty(), "{findings:?}");
shared
}
fn sugared() -> Value {
json!({
"workflow_id": "w", "name": "w",
"tasks": [
{ "id": "_g", "use": "guard", "with": { "msg": "no" } },
{ "id": "read", "name": "Read", "function": { "name": "mongo_read",
"input": { "$from": "constants.db", "collection": "users" } } },
{ "id": "group", "condition": true, "tasks": [
{ "id": "_g2", "use": "guard" },
{ "id": "err", "name": "Err", "function": { "name": "map",
"input": { "mappings": [
{ "path": "data.out", "logic": { "$from": "errors.NOT_FOUND" } } ] } } } ] }
]
})
}
#[test]
fn residue_names_every_reference_with_its_coordinate() {
let found = residue(&sugared(), "");
let seen: Vec<(&str, String)> = found
.iter()
.map(|r| (r.key, r.path.clone()))
.collect::<Vec<_>>();
assert_eq!(
seen,
vec![
("use", "tasks[0]".to_string()),
("use", "tasks[2].tasks[0]".to_string()),
("$from", "tasks[1].function.input".to_string()),
(
"$from",
"tasks[2].tasks[1].function.input.mappings[0].logic".to_string()
),
],
"fragments are reported before values, each at the coordinate the author typed"
);
}
#[test]
fn a_task_array_held_on_its_own_roots_at_tasks() {
let doc = sugared();
let found = residue(&doc["tasks"], "tasks");
assert_eq!(
found.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
vec![
"tasks[0]",
"tasks[2].tasks[0]",
"tasks[1].function.input",
"tasks[2].tasks[1].function.input.mappings[0].logic",
],
"the admin API holds `tasks` alone and must get the same coordinates"
);
}
#[test]
fn use_outside_a_step_is_an_ordinary_field() {
let doc = json!({
"name": "w",
"tasks": [ { "id": "t", "name": "T", "function": { "name": "http_call",
"input": { "body": { "use": "cache", "tasks": [ { "use": "nested" } ] } } } } ]
});
assert_eq!(residue(&doc, ""), vec![]);
}
#[test]
fn a_non_string_from_is_not_a_reference() {
let doc = json!({ "tasks": [ { "function": { "input": { "$from": 5 } } } ] });
assert_eq!(residue(&doc, ""), vec![]);
}
#[test]
fn compiling_reports_the_passes_that_fired_and_leaves_nothing_behind() {
let shared = catalog();
let mut doc = sugared();
let mut findings = Vec::new();
let applied = compile(
&mut doc,
&Cx {
shared: &shared,
origin: "wf.json",
},
&mut findings,
);
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(applied, vec!["shared.fragments", "shared.values"]);
assert_eq!(
residue(&doc, ""),
vec![],
"a compiled document is canonical — this is what the runtime relies on"
);
assert_eq!(doc["tasks"][1]["function"]["input"]["connector"], "mongo");
assert_eq!(doc["tasks"][1]["function"]["input"]["collection"], "users");
assert_eq!(doc["tasks"][0]["id"], "_g.deny");
}
#[test]
fn compiling_is_idempotent() {
let shared = catalog();
let cx = Cx {
shared: &shared,
origin: "wf.json",
};
let mut once = sugared();
let mut findings = Vec::new();
compile(&mut once, &cx, &mut findings);
let mut twice = once.clone();
let applied = compile(&mut twice, &cx, &mut findings);
assert_eq!(once, twice);
assert!(
applied.is_empty(),
"a canonical document must fire no pass at all"
);
}
#[test]
fn nothing_survives_a_reference_that_does_not_resolve() {
let shared = SharedDefinitions::default();
let mut doc = sugared();
let mut findings = Vec::new();
compile(
&mut doc,
&Cx {
shared: &shared,
origin: "wf.json",
},
&mut findings,
);
assert!(findings.iter().any(|f| f.is_error()));
assert_eq!(residue(&doc, ""), vec![]);
}
#[test]
fn every_pass_has_a_distinct_stable_id() {
let ids: Vec<&str> = passes().iter().map(|p| p.id()).collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "pass ids must be unique: {ids:?}");
assert!(passes().iter().all(|p| !p.noun().is_empty()));
}
}