use std::collections::BTreeSet;
use serde_json::{Map, Value};
use super::json::{Document, Member, Node, Span, Spanned};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fix {
FoldRun {
members: Vec<String>,
group_id: String,
},
}
impl std::fmt::Display for Fix {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Fix::FoldRun { members, group_id } => write!(
f,
"folded {} into group `{group_id}`",
members
.iter()
.map(|m| format!("`{m}`"))
.collect::<Vec<_>>()
.join(", ")
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
NotAuthoredHere { members: Vec<String> },
ConditionNotOnStep { step: String },
IdCollision { id: String },
TooDeep,
NoSpans,
Verification,
Format(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::NotAuthoredHere { members } => write!(
f,
"steps {} are not written in this file — they come from a `use` fragment or \
another expansion, so the edit belongs there and would change every caller",
members
.iter()
.map(|m| format!("`{m}`"))
.collect::<Vec<_>>()
.join(", ")
),
Refusal::ConditionNotOnStep { step } => {
write!(f, "`{step}`'s condition is not written on the step itself")
}
Refusal::IdCollision { id } => write!(
f,
"the group would be named `{id}`, which is already a step id in this workflow"
),
Refusal::TooDeep => write!(
f,
"folding would nest groups deeper than the engine's limit of {}",
crate::engine::MAX_STEP_DEPTH
),
Refusal::NoSpans => write!(
f,
"the file cannot be read by the order-preserving parser (a duplicate key?) — \
`orion-server fmt` reports why"
),
Refusal::Verification => write!(
f,
"the edit did not compile to the folded workflow — a clippy bug; nothing was \
written, please report it with the file"
),
Refusal::Format(reason) => {
write!(f, "the edited file could not be formatted: {reason}")
}
}
}
}
#[derive(Debug, Default)]
pub struct FileOutcome {
pub text: Option<String>,
pub compiled: Option<Value>,
pub applied: Vec<Fix>,
pub refused: Vec<(Fix, Refusal)>,
}
pub fn apply(
source: Option<&Document>,
compiled: &Value,
fixes: &[Fix],
recompile: &dyn Fn(&Value) -> Option<Value>,
) -> FileOutcome {
let mut outcome = FileOutcome::default();
let Some(source) = source else {
outcome.refused = fixes
.iter()
.map(|fix| (fix.clone(), Refusal::NoSpans))
.collect();
return outcome;
};
let mut src = source.root.node.clone();
let mut folded = compiled.clone();
for fix in fixes {
let mut next_src = src.clone();
let mut next_folded = folded.clone();
let result =
fold_value(&mut next_folded, fix).and_then(|()| fold_source(&mut next_src, fix));
let result = result.and_then(|()| {
if recompile(&next_src.to_value()).as_ref() == Some(&next_folded) {
Ok(())
} else {
Err(Refusal::Verification)
}
});
match result {
Ok(()) => {
src = next_src;
folded = next_folded;
outcome.applied.push(fix.clone());
}
Err(refusal) => outcome.refused.push((fix.clone(), refusal)),
}
}
if outcome.applied.is_empty() {
return outcome;
}
let printed = super::fmt::format_document(&Document {
root: Spanned {
node: src,
span: Span { start: 0, end: 0 },
},
source: String::new(),
});
match super::fmt::format_str(&printed, "clippy --fix") {
Ok(super::fmt::Outcome::Unchanged) => outcome.text = Some(printed),
Ok(super::fmt::Outcome::Changed(text)) => outcome.text = Some(text),
Err(e) => {
let refused: Vec<(Fix, Refusal)> = std::mem::take(&mut outcome.applied)
.into_iter()
.map(|fix| (fix, Refusal::Format(e.to_string())))
.collect();
outcome.refused.extend(refused);
return outcome;
}
}
outcome.compiled = Some(folded);
outcome
}
pub fn fold_value(doc: &mut Value, fix: &Fix) -> Result<(), Refusal> {
let Fix::FoldRun { members, group_id } = fix;
let collides = STEP_LISTS
.iter()
.any(|at| step_ids(doc.pointer(at).unwrap_or(&Value::Null)).contains(group_id.as_str()));
if collides {
return Err(Refusal::IdCollision {
id: group_id.clone(),
});
}
let not_here = || Refusal::NotAuthoredHere {
members: members.clone(),
};
let at = STEP_LISTS
.iter()
.find(|at| {
doc.pointer(at)
.is_some_and(|list| locate_value(&mut list.clone(), members).is_some())
})
.ok_or_else(not_here)?;
let (list, start) = doc
.pointer_mut(at)
.and_then(|tasks| locate_value(tasks, members))
.ok_or_else(not_here)?;
let run: Vec<Value> = list.drain(start..start + members.len()).collect();
let condition = run[0].get("condition").cloned().unwrap_or(Value::Null);
let steps = run
.into_iter()
.map(|mut step| {
if let Some(obj) = step.as_object_mut() {
obj.remove("condition");
}
step
})
.collect();
let mut group = Map::new();
group.insert("id".to_string(), Value::String(group_id.clone()));
group.insert("condition".to_string(), condition);
group.insert("tasks".to_string(), Value::Array(steps));
list.insert(start, Value::Object(group));
if !crate::engine::walk_steps(doc.get("tasks").unwrap_or(&Value::Null), doc.get("loop"))
.too_deep
.is_empty()
{
return Err(Refusal::TooDeep);
}
Ok(())
}
const STEP_LISTS: [&str; 2] = ["/loop/setup", "/tasks"];
fn step_ids(tasks: &Value) -> BTreeSet<&str> {
let mut out = BTreeSet::new();
let mut stack = vec![tasks];
while let Some(list) = stack.pop() {
for step in list.as_array().into_iter().flatten() {
if let Some(id) = step.get("id").and_then(Value::as_str) {
out.insert(id);
}
if let Some(inner) = step.get("tasks") {
stack.push(inner);
}
}
}
out
}
fn locate_value<'a>(
tasks: &'a mut Value,
members: &[String],
) -> Option<(&'a mut Vec<Value>, usize)> {
let found = {
let list = tasks.as_array()?;
find_run(
list.iter().map(|s| s.get("id").and_then(Value::as_str)),
members,
)
};
if let Some(start) = found {
return Some((tasks.as_array_mut()?, start));
}
for step in tasks.as_array_mut()? {
if let Some(inner) = step.get_mut("tasks")
&& let Some(hit) = locate_value(inner, members)
{
return Some(hit);
}
}
None
}
fn find_run<'a>(ids: impl Iterator<Item = Option<&'a str>>, members: &[String]) -> Option<usize> {
let ids: Vec<Option<&str>> = ids.collect();
if members.is_empty() || ids.len() < members.len() {
return None;
}
(0..=ids.len() - members.len()).find(|&start| {
members
.iter()
.enumerate()
.all(|(k, m)| ids[start + k] == Some(m.as_str()))
})
}
pub fn fold_source(root: &mut Node, fix: &Fix) -> Result<(), Refusal> {
let Fix::FoldRun { members, group_id } = fix;
let not_here = || Refusal::NotAuthoredHere {
members: members.clone(),
};
let in_setup = object_get_mut(root, "loop")
.and_then(|l| object_get_mut(l, "setup"))
.and_then(|setup| locate_node(setup, members))
.is_some();
let tasks = if in_setup {
object_get_mut(root, "loop").and_then(|l| object_get_mut(l, "setup"))
} else {
object_get_mut(root, "tasks")
}
.ok_or_else(not_here)?;
let (list, start) = locate_node(tasks, members).ok_or_else(not_here)?;
for step in &list[start..start + members.len()] {
if step.node.get("condition").is_none() {
let id = step
.node
.get("id")
.and_then(|n| n.node.as_str())
.unwrap_or("?")
.to_string();
return Err(Refusal::ConditionNotOnStep { step: id });
}
}
let mut run: Vec<Spanned<Node>> = list.drain(start..start + members.len()).collect();
let condition = take_member(&mut run[0].node, "condition").unwrap_or(Spanned {
node: Node::Null,
span: placeholder(),
});
for step in &mut run[1..] {
take_member(&mut step.node, "condition");
}
let group = Node::Object(vec![
member("id", Node::String(group_id.clone())),
Member {
key: Spanned {
node: "condition".to_string(),
span: placeholder(),
},
value: condition,
},
member("tasks", Node::Array(run)),
]);
list.insert(
start,
Spanned {
node: group,
span: placeholder(),
},
);
Ok(())
}
fn locate_node<'a>(
tasks: &'a mut Node,
members: &[String],
) -> Option<(&'a mut Vec<Spanned<Node>>, usize)> {
let found = {
let Node::Array(list) = &*tasks else {
return None;
};
let sugar = |step: &Node| {
step.get("use").is_some() || step.get("$use").is_some() || step.get("$each").is_some()
};
find_run(
list.iter().map(|s| {
if sugar(&s.node) {
None
} else {
s.node.get("id").and_then(|n| n.node.as_str())
}
}),
members,
)
};
let Node::Array(list) = tasks else {
return None;
};
if let Some(start) = found {
return Some((list, start));
}
for step in list {
if step.node.get("use").is_some() {
continue;
}
if let Some(inner) = object_get_mut(&mut step.node, "tasks")
&& let Some(hit) = locate_node(inner, members)
{
return Some(hit);
}
}
None
}
fn object_get_mut<'a>(node: &'a mut Node, key: &str) -> Option<&'a mut Node> {
let Node::Object(members) = node else {
return None;
};
members
.iter_mut()
.find(|m| m.key.node == key)
.map(|m| &mut m.value.node)
}
fn take_member(node: &mut Node, key: &str) -> Option<Spanned<Node>> {
let Node::Object(members) = node else {
return None;
};
let index = members.iter().position(|m| m.key.node == key)?;
Some(members.remove(index).value)
}
fn placeholder() -> Span {
Span { start: 0, end: 0 }
}
fn member(key: &str, value: Node) -> Member {
Member {
key: Spanned {
node: key.to_string(),
span: placeholder(),
},
value: Spanned {
node: value,
span: placeholder(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fold(ids: &[&str], group: &str) -> Fix {
Fix::FoldRun {
members: ids.iter().map(|s| s.to_string()).collect(),
group_id: group.to_string(),
}
}
fn step(id: &str, extra: Value) -> Value {
let mut step = json!({"id": id, "name": id, "condition": {"var": "data.go"},
"function": {"name": "map", "input": {"mappings": []}}});
for (k, v) in extra.as_object().expect("object") {
step[k] = v.clone();
}
step
}
fn doc(text: &str) -> Document {
Document::parse(text).expect("json")
}
fn same(v: &Value) -> Option<Value> {
Some(v.clone())
}
#[test]
fn a_run_folds_into_a_group_that_carries_the_condition_once() {
let wf = json!({"tasks": [step("a", json!({})), step("b", json!({"terminal": true})),
step("c", json!({"condition": {"var": "data.other"}}))]});
let source = doc(&wf.to_string());
let out = apply(Some(&source), &wf, &[fold(&["a", "b"], "when_a")], &same);
assert!(out.refused.is_empty(), "{:?}", out.refused);
let text = out.text.expect("written");
let edited: Value = serde_json::from_str(&text).expect("json");
let group = &edited["tasks"][0];
assert_eq!(group["id"], "when_a");
assert_eq!(group["condition"], json!({"var": "data.go"}));
assert!(
group.get("terminal").is_none(),
"the group gets no terminal"
);
assert!(group["tasks"][0].get("condition").is_none());
assert_eq!(
group["tasks"][1]["terminal"], true,
"a member keeps its own"
);
assert_eq!(edited["tasks"][1]["id"], "c");
assert_eq!(out.compiled.expect("folded"), edited);
}
#[test]
fn a_run_inside_a_group_is_found() {
let wf = json!({"tasks": [{"id": "g", "condition": true,
"tasks": [step("a", json!({})), step("b", json!({}))]}]});
let out = apply(
Some(&doc(&wf.to_string())),
&wf,
&[fold(&["a", "b"], "when_a")],
&same,
);
let edited: Value = serde_json::from_str(&out.text.expect("written")).expect("json");
assert_eq!(edited["tasks"][0]["tasks"][0]["id"], "when_a");
}
#[test]
fn what_is_not_written_here_is_refused() {
let compiled = json!({"tasks": [step("pay.a", json!({})), step("pay.b", json!({}))]});
let source = doc(r#"{"tasks": [{"id": "pay", "use": "charge"}]}"#);
let out = apply(
Some(&source),
&compiled,
&[fold(&["pay.a", "pay.b"], "when_pay.a")],
&same,
);
assert!(out.text.is_none());
assert!(matches!(out.refused[0].1, Refusal::NotAuthoredHere { .. }));
}
#[test]
fn a_colliding_group_id_is_refused_not_renamed() {
let wf = json!({"tasks": [step("a", json!({})), step("b", json!({})),
step("when_a", json!({"condition": true}))]});
let out = apply(
Some(&doc(&wf.to_string())),
&wf,
&[fold(&["a", "b"], "when_a")],
&same,
);
assert_eq!(
out.refused[0].1,
Refusal::IdCollision {
id: "when_a".to_string()
}
);
}
#[test]
fn a_group_id_taken_in_loop_setup_is_a_collision() {
let wf = json!({
"loop": {"max": 2, "setup": [step("when_a", json!({}))]},
"tasks": [step("a", json!({})), step("b", json!({}))]
});
let out = apply(
Some(&doc(&wf.to_string())),
&wf,
&[fold(&["a", "b"], "when_a")],
&same,
);
assert_eq!(
out.refused[0].1,
Refusal::IdCollision {
id: "when_a".to_string()
}
);
}
#[test]
fn a_condition_arriving_through_a_splice_is_refused() {
let compiled = json!({"tasks": [step("a", json!({})), step("b", json!({}))]});
let source = doc(r#"{"tasks": [
{"$from": "constants.guarded", "id": "a", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
{"id": "b", "name": "b", "condition": {"var": "data.go"}, "function": {"name": "map", "input": {"mappings": []}}}]}"#);
let out = apply(
Some(&source),
&compiled,
&[fold(&["a", "b"], "when_a")],
&same,
);
assert_eq!(
out.refused[0].1,
Refusal::ConditionNotOnStep {
step: "a".to_string()
}
);
}
#[test]
fn an_edit_that_compiles_to_something_else_is_not_written() {
let wf = json!({"tasks": [step("a", json!({})), step("b", json!({}))]});
let out = apply(
Some(&doc(&wf.to_string())),
&wf,
&[fold(&["a", "b"], "when_a")],
&|_| Some(json!({"tasks": []})),
);
assert!(out.text.is_none());
assert_eq!(out.refused[0].1, Refusal::Verification);
}
#[test]
fn folding_past_the_depth_limit_is_refused() {
let mut inner = json!([step("a", json!({})), step("b", json!({}))]);
for depth in 0..crate::engine::MAX_STEP_DEPTH {
inner = json!([{"id": format!("g{depth}"), "condition": true, "tasks": inner}]);
}
let wf = json!({"tasks": inner});
assert!(
crate::engine::walk_steps(&wf["tasks"], None)
.too_deep
.is_empty()
);
let out = apply(
Some(&doc(&wf.to_string())),
&wf,
&[fold(&["a", "b"], "when_a")],
&same,
);
assert_eq!(out.refused[0].1, Refusal::TooDeep);
}
}