//! `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> { 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::()) { (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 { 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::>()?), Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), substitute(x, results)?))).collect::>()?), _ => v.clone(), }) } fn batch(s: &mut Session, p: &Value) -> Result { 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 = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { let run = |s: &mut Session, results: &[Value]| -> Result { 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, 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 { 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}"); } }