use super::style;
use crate::definitions::json::{Member, Node};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Workflow,
Channel,
Connector,
SharedDoc,
NamedValues,
FragmentMap,
Fragment,
CaseFile,
Artifact,
ArtifactMeta,
TaskList,
Task,
Group,
UseStep,
FunctionHeader,
Input(InputKind),
EntryList(EntryKind),
Mapping,
ValidationRule,
LoopObject,
Operator(OperatorShape),
OperatorArgs(OperatorShape),
ScalarArray,
PathMap,
Generic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputKind {
Map,
Validation,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
Mapping,
ValidationRule,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperatorShape {
Unary,
Leaf,
Compound,
}
const ENTITY_SHAPE_DEPTH: usize = 2;
const READ_OPERATORS: &[&str] = &["var", "val", "secret", "missing", "missing_some"];
pub fn root_role(node: &Node) -> Role {
if let Some(shape) = operator_shape(node) {
return Role::Operator(shape);
}
if let Some(items) = node.as_array()
&& !items.is_empty()
&& items.iter().all(|i| is_step_like(&i.node))
{
return Role::TaskList;
}
entity_role(node).unwrap_or(Role::Generic)
}
fn is_step_like(node: &Node) -> bool {
let Some(members) = node.as_object() else {
return false;
};
let has = |k: &str| members.iter().any(|m| m.key.node == k);
has("id") && (has("function") || has("tasks") || has("use"))
}
pub fn child_role(parent: Role, key: Option<&str>, child: &Node, depth: usize) -> Role {
if let Some(shape) = operator_shape(child) {
return Role::Operator(shape);
}
match parent {
Role::Workflow => match key {
Some("tasks") => Role::TaskList,
Some("loop") if child.as_object().is_some() => Role::LoopObject,
_ => generic_or_scalar_array(child),
},
Role::Fragment | Role::Group => match key {
Some("tasks") => Role::TaskList,
_ => generic_or_scalar_array(child),
},
Role::TaskList => step_role(child),
Role::Task => match key {
Some("function") if child.as_object().is_some() => Role::FunctionHeader,
_ => generic_or_scalar_array(child),
},
Role::FunctionHeader => match key {
Some("input") if child.as_object().is_some() => {
Role::Input(InputKind::Other)
}
_ => generic_or_scalar_array(child),
},
Role::Input(InputKind::Map) => match key {
Some("mappings") if child.as_array().is_some() => Role::EntryList(EntryKind::Mapping),
_ => generic_or_scalar_array(child),
},
Role::Input(InputKind::Validation) => match key {
Some("rules") if child.as_array().is_some() => {
Role::EntryList(EntryKind::ValidationRule)
}
_ => generic_or_scalar_array(child),
},
Role::EntryList(EntryKind::Mapping) if child.as_object().is_some() => Role::Mapping,
Role::EntryList(EntryKind::ValidationRule) if child.as_object().is_some() => {
Role::ValidationRule
}
Role::SharedDoc => match key {
Some("fragments") if child.as_object().is_some() => Role::FragmentMap,
Some(_) if child.as_object().is_some() => Role::NamedValues,
_ => generic_or_scalar_array(child),
},
Role::FragmentMap if child.as_object().is_some() => Role::Fragment,
Role::Artifact => match key {
Some("package") if child.as_object().is_some() => Role::ArtifactMeta,
Some("connectors" | "workflows" | "channels") if child.as_array().is_some() => {
Role::Generic
}
_ => generic_or_scalar_array(child),
},
Role::Generic if depth <= ENTITY_SHAPE_DEPTH => {
entity_role(child).unwrap_or_else(|| generic_or_scalar_array(child))
}
_ => generic_or_scalar_array(child),
}
}
pub fn input_role(function_name: Option<&str>) -> Role {
Role::Input(match function_name {
Some("map") => InputKind::Map,
Some("validation" | "validate") => InputKind::Validation,
_ => InputKind::Other,
})
}
pub fn input_key_order(function_name: Option<&str>) -> Option<Vec<&'static str>> {
let name = function_name?;
if let Some(schema) = crate::engine::functions::schema::registry()
.iter()
.find(|s| s.name == name)
{
return Some(schema.input_fields.iter().map(|f| f.name).collect());
}
style::BUILTIN_INPUT_KEYS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, keys)| keys.to_vec())
}
pub fn key_order(role: Role) -> Option<&'static [&'static str]> {
Some(match role {
Role::Workflow => style::WORKFLOW_KEYS,
Role::Task => style::TASK_KEYS,
Role::Group => style::GROUP_KEYS,
Role::UseStep => style::USE_STEP_KEYS,
Role::FunctionHeader => style::FUNCTION_KEYS,
Role::Mapping => style::MAPPING_KEYS,
Role::ValidationRule => style::VALIDATION_RULE_KEYS,
Role::LoopObject => style::LOOP_KEYS,
Role::Channel => style::CHANNEL_KEYS,
Role::Connector => style::CONNECTOR_KEYS,
Role::SharedDoc => style::SHARED_DOC_KEYS,
Role::Fragment => style::FRAGMENT_KEYS,
Role::CaseFile => style::CASE_KEYS,
Role::Artifact => style::ARTIFACT_KEYS,
Role::ArtifactMeta => style::ARTIFACT_META_KEYS,
_ => return None,
})
}
fn entity_role(node: &Node) -> Option<Role> {
let members = node.as_object()?;
let has = |k: &str| members.iter().any(|m| m.key.node == k);
if has("tasks") {
return Some(Role::Workflow);
}
if has("connector_type") {
return Some(Role::Connector);
}
if has("channel_type") || has("protocol") {
return Some(Role::Channel);
}
if has("constants") || has("errors") || has("fragments") {
return Some(Role::SharedDoc);
}
if has("workflow") && has("input") && has("expect") {
return Some(Role::CaseFile);
}
if has("package") && has("workflows") {
return Some(Role::Artifact);
}
None
}
fn step_role(step: &Node) -> Role {
let Some(members) = step.as_object() else {
return generic_or_scalar_array(step);
};
let has = |k: &str| members.iter().any(|m| m.key.node == k);
if has("tasks") {
Role::Group
} else if has("use") {
Role::UseStep
} else {
Role::Task
}
}
fn generic_or_scalar_array(node: &Node) -> Role {
match node {
Node::Array(items) if items.iter().all(|i| i.node.is_scalar()) => Role::ScalarArray,
Node::Object(members)
if members.len() > 1 && members.iter().all(|m| m.key.node.contains('.')) =>
{
Role::PathMap
}
_ => Role::Generic,
}
}
pub fn operator_shape(node: &Node) -> Option<OperatorShape> {
let [member] = node.as_object()? else {
return None;
};
let op = member.key.node.as_str();
if !crate::engine::operators::is_operator(op) {
return None;
}
let arg = &member.value.node;
if READ_OPERATORS.contains(&op) && is_scalar_or_scalar_array(arg) {
return Some(OperatorShape::Unary);
}
Some(match arg {
Node::Array(items) => match items.as_slice() {
[single] if is_leafish(&single.node) => OperatorShape::Unary,
_ if items.iter().all(|i| is_leafish(&i.node)) => OperatorShape::Leaf,
_ => OperatorShape::Compound,
},
Node::Object(_) if is_read_node(arg) => OperatorShape::Unary,
Node::Object(_) => match operator_shape(arg) {
Some(OperatorShape::Unary | OperatorShape::Leaf) => OperatorShape::Leaf,
Some(OperatorShape::Compound) => OperatorShape::Compound,
None if is_atom(arg) => OperatorShape::Unary,
None => OperatorShape::Compound,
},
_ => OperatorShape::Unary,
})
}
fn is_atom(node: &Node) -> bool {
match node {
Node::Object(members) => {
matches!(members.as_slice(), [m] if is_scalar_or_scalar_array(&m.value.node))
}
other => is_scalar_or_scalar_array(other),
}
}
fn is_scalar_or_scalar_array(node: &Node) -> bool {
match node {
Node::Array(items) => {
items.len() <= style::STYLE.max_scalar_inline
&& items.iter().all(|i| i.node.is_scalar())
}
other => other.is_scalar(),
}
}
fn is_leafish(node: &Node) -> bool {
is_atom(node) || operator_shape(node) == Some(OperatorShape::Unary)
}
fn is_read_node(node: &Node) -> bool {
matches!(node.as_object(), Some([m]) if READ_OPERATORS.contains(&m.key.node.as_str()))
}
pub fn order_members<'a>(members: &'a [Member], order: Option<&[&str]>) -> Vec<&'a Member> {
let mut out: Vec<&Member> = Vec::with_capacity(members.len());
let mut taken = vec![false; members.len()];
let mut take = |pred: &dyn Fn(&str) -> bool, out: &mut Vec<&'a Member>| {
for (i, m) in members.iter().enumerate() {
if !taken[i] && pred(&m.key.node) {
taken[i] = true;
out.push(m);
}
}
};
take(&|k| k == style::FROM_KEY, &mut out);
if let Some(order) = order {
for key in order {
take(&|k| k == *key, &mut out);
}
}
take(&|_| true, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::definitions::json::Document;
fn node(text: &str) -> Node {
Document::parse(text)
.expect("test input is valid")
.root
.node
}
#[test]
fn operator_shapes() {
let shape = |t: &str| operator_shape(&node(t));
assert_eq!(shape(r#"{"var": "data.x"}"#), Some(OperatorShape::Unary));
assert_eq!(
shape(r#"{"var": ["data.x", 0]}"#),
Some(OperatorShape::Unary)
);
assert_eq!(shape(r#"{"secret": "hmac"}"#), Some(OperatorShape::Unary));
assert_eq!(
shape(r#"{"!": {"var": "data.ok"}}"#),
Some(OperatorShape::Unary)
);
assert_eq!(
shape(r#"{"length": [{"var": "data.items"}]}"#),
Some(OperatorShape::Unary)
);
assert_eq!(shape(r#"{"now": []}"#), Some(OperatorShape::Leaf));
assert_eq!(
shape(r#"{">=": [{"var": "data.amount"}, 500]}"#),
Some(OperatorShape::Leaf)
);
assert_eq!(
shape(r#"{"!": {"!!": {"var": "x"}}}"#),
Some(OperatorShape::Leaf)
);
assert_eq!(
shape(r#"{"and": [{">=": [{"var": "a"}, 1]}, true]}"#),
Some(OperatorShape::Compound)
);
assert_eq!(
shape(r#"{"var": {"cat": ["a", "b"]}}"#),
Some(OperatorShape::Leaf),
"a read whose path is computed is laid out by its argument"
);
assert_eq!(
shape(r#"{"in": [{"var": "data.tier"}, ["vip", "premium"]]}"#),
Some(OperatorShape::Leaf),
"a short array literal is an atom"
);
assert_eq!(
shape(r#"{"==": [{"field": "id"}, {"param": "customer_id"}]}"#),
Some(OperatorShape::Leaf),
"query-dialect atoms are atoms"
);
assert_eq!(
shape(r#"{"!": {"in": [{"var": "x"}, [1, 7, 42]]}}"#),
Some(OperatorShape::Leaf),
"a unary wrapper around a leaf is a leaf"
);
assert_eq!(
shape(r#"{"!": {"and": [{"var": "a"}, {"in": [{"var": "b"}, [1]]}]}}"#),
Some(OperatorShape::Compound),
"a unary wrapper around a compound is compound"
);
assert_eq!(
shape(r#"{"in": [{"var": "x"}, [1, 2, 3, 4, 5, 6, 7, 8, 9]]}"#),
Some(OperatorShape::Compound),
"an array literal past the scalar cap is not an atom"
);
assert_eq!(shape(r#"{"path": "x"}"#), None, "not an operator");
assert_eq!(shape(r#"{"var": "x", "extra": 1}"#), None, "two keys");
assert_eq!(shape(r#"{}"#), None);
}
#[test]
fn documents_are_classified_by_shape() {
assert_eq!(
root_role(&node(r#"{"name": "w", "tasks": []}"#)),
Role::Workflow
);
assert_eq!(
root_role(&node(r#"{"name": "c", "connector_type": "http"}"#)),
Role::Connector
);
assert_eq!(
root_role(&node(r#"{"name": "c", "protocol": "http"}"#)),
Role::Channel
);
assert_eq!(root_role(&node(r#"{"constants": {}}"#)), Role::SharedDoc);
assert_eq!(
root_role(&node(
r#"{"workflow": "w.json", "input": {}, "expect": {}}"#
)),
Role::CaseFile
);
assert_eq!(
root_role(&node(r#"{"package": {}, "workflows": []}"#)),
Role::Artifact
);
assert_eq!(root_role(&node(r#"{"hello": 1}"#)), Role::Generic);
assert_eq!(
root_role(&node(
r#"[{"id": "t", "function": {}}, {"id": "g", "tasks": []}]"#
)),
Role::TaskList,
"a bare array of steps, as an editor sends one"
);
assert_eq!(root_role(&node(r#"[{"id": "t"}]"#)), Role::Generic);
assert_eq!(root_role(&node("[]")), Role::Generic);
assert_eq!(
child_role(
Role::Generic,
Some("expect"),
&node(r#"{"data.a": 1, "data.b": 2}"#),
1
),
Role::PathMap
);
assert_eq!(
child_role(Role::Generic, Some("expect"), &node(r#"{"data.a": 1}"#), 1),
Role::Generic,
"one entry is not a checklist"
);
assert_eq!(
root_role(&node(r#"{"var": "x"}"#)),
Role::Operator(OperatorShape::Unary)
);
}
#[test]
fn steps_split_three_ways() {
let list = Role::TaskList;
assert_eq!(
child_role(list, None, &node(r#"{"id": "g", "tasks": []}"#), 2),
Role::Group
);
assert_eq!(
child_role(list, None, &node(r#"{"id": "u", "use": "guard"}"#), 2),
Role::UseStep
);
assert_eq!(
child_role(list, None, &node(r#"{"id": "t"}"#), 2),
Role::Task
);
}
#[test]
fn entities_inside_bulk_and_artifact_files_are_recognised_but_payloads_are_not() {
let wf = node(r#"{"name": "w", "tasks": []}"#);
assert_eq!(
child_role(Role::Generic, None, &wf, 1),
Role::Workflow,
"bulk file"
);
assert_eq!(
child_role(Role::Generic, None, &wf, 2),
Role::Workflow,
"artifact"
);
assert_eq!(
child_role(Role::Generic, Some("body"), &wf, 6),
Role::Generic,
"a payload with a `tasks` key is data"
);
}
#[test]
fn members_reorder_with_from_first_and_unknown_keys_last_in_author_order() {
let n = node(r#"{"zeta": 1, "name": "n", "$from": "c.x", "id": "i", "alpha": 2}"#);
let ordered: Vec<&str> = order_members(
n.as_object().expect("test input is valid"),
Some(style::TASK_KEYS),
)
.iter()
.map(|m| m.key.node.as_str())
.collect();
assert_eq!(ordered, ["$from", "id", "name", "zeta", "alpha"]);
}
}