effectcraft/crates/engine/src/commands/batch.rs

173 lines
7.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `engine.batch`: run several commands in one call, as one undo step, with later steps able to
//! use what earlier ones returned (`"$1.layer"` is step 1's `layer` result). Agents build a
//! layer, animate it and style it in one round trip; a failing step rolls everything back.
use std::sync::Arc;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
/// Commands that can't run inside a batch (they replace the project or nest batches).
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
let rest = s.strip_prefix('$')?;
let mut parts = rest.split('.');
let n: usize = parts.next()?.parse().ok()?;
let path: Vec<&str> = parts.collect();
if path.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) {
return None;
}
let Some(mut v) = n.checked_sub(1).and_then(|i| results.get(i)) else {
return Some(Err(bad("engine.batch", format!("`{s}` refers to step {n}, but only {} step(s) ran before", results.len()))));
};
for p in &path {
let next = match (v, p.parse::<usize>()) {
(Value::Array(a), Ok(i)) => a.get(i),
(Value::Object(o), _) => o.get(*p),
_ => None,
};
match next {
Some(x) => v = x,
None => return Some(Err(bad("engine.batch", format!("`{s}`: step {n} returned {v}, which has no `{p}`")))),
}
}
Some(Ok(v.clone()))
}
/// Replace every `$N…` string in `v` (recursively) with the referenced result.
fn substitute(v: &Value, results: &[Value]) -> Result<Value> {
Ok(match v {
Value::String(s) => match reference(s, results) {
Some(r) => r?,
None => v.clone(),
},
Value::Array(a) => Value::Array(a.iter().map(|x| substitute(x, results)).collect::<Result<_>>()?),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), substitute(x, results)?))).collect::<Result<_>>()?),
_ => v.clone(),
})
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone();
let label = str_p(p, "label").unwrap_or("Batch").to_string();
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
let id = step
.get("command")
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
if NOT_IN_BATCH.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
s.execute_checked(id, params)
};
match run(s, &results) {
Ok(r) => results.push(r),
Err(e) => {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
let note = if atomic {
s.project = project;
s.history = history;
s.state = state;
s.sanitize_state();
s.bump();
"; the batch was rolled back (nothing changed)".to_string()
} else {
fold(s, &project, &label);
format!("; steps 1–{i} were applied")
};
return Err(EngineError::Other(format!("step {} ({id}): {e}{note}", i + 1)));
}
}
}
fold(s, &project, &label);
Ok(json!({"steps": results.len(), "results": results}))
}
/// Fold the undo steps recorded since the project was `snap` into one step called `label`.
fn fold(s: &mut Session, snap: &Arc<effectcraft_project::Project>, label: &str) {
let h = &mut s.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { return };
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((label.to_string(), first));
h.merge_key = None;
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "engine.batch",
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,
// The steps journal themselves.
journal: false,
}]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn batch_refs_one_undo_step_and_rollback() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "B", "width": 100, "height": 100})).unwrap();
let undo_before = s.history.undo.len();
let r = s
.execute_checked(
"engine.batch",
json!({"label": "Build", "steps": [
{"command": "layer.newSolid", "params": {"name": "Box", "color": "#ff0000"}},
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/opacity", "value": 40}},
{"command": "layer.rename", "params": {"layer": "$1.layer", "name": "Red Box"}},
]}),
)
.unwrap();
assert_eq!(r["steps"], 3);
let lid = r["results"][0]["layer"].as_u64().unwrap();
let comp = s.active_comp().unwrap();
let l = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
assert_eq!(l.name, "Red Box");
assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 40.0);
assert_eq!(s.history.undo.len(), undo_before + 1, "one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Build");
assert!(s.undo());
assert!(s.active_comp().unwrap().layers.is_empty());
assert!(s.redo());
// A failing step rolls back the earlier ones and names the step.
let n = s.active_comp().unwrap().layers.len();
let e = s
.execute_checked(
"engine.batch",
json!({"steps": [
{"command": "layer.newNull", "params": {}},
{"command": "prop.set", "params": {"layer": "$1.layer", "path": "transform/nope", "value": 1}},
]}),
)
.unwrap_err()
.to_string();
assert!(e.starts_with("step 2 (prop.set)") && e.contains("rolled back"), "{e}");
assert_eq!(s.active_comp().unwrap().layers.len(), n);
// Bad references are reported.
let e = s.execute_checked("engine.batch", json!({"steps": [{"command": "layer.rename", "params": {"layer": "$3.layer", "name": "x"}}]})).unwrap_err();
assert!(e.to_string().contains("refers to step 3"), "{e}");
}
}