Skip to main content

dspk_app_core/domain/
command.rs

1//! Commands: every change to the session document. The UI and an agent (future MCP server) send
2//! the same commands, so there is one code path; each command is plain data (serializable, so an
3//! agent sends it as JSON) and [`apply`] is pure (no I/O, no device), so it is tested directly.
4//!
5//! Two families, one graph: view commands (`add_op`, `set_op`, …) act on a view's chain (the
6//! path from its recording to the view node), graph commands (`add_node`, `connect`, …) on the
7//! Pipeline canvas. After every command the views are compiled from the graph again.
8//!
9//! Running work (preview, write, sort) is not a document change: the data layer does it and
10//! records its result with a command (e.g. a sort: [`SessionCommand::SetPluginData`]).
11
12use dspk_pipeline::Node;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::graph::{GraphNode, NodeKind, COLUMN};
17use super::epoch::{EpochDoc, EventSetDoc, Placement};
18use super::recipe::RecipeDoc;
19use super::session::{op_name, DisplayDoc, RecordingRef, SessionDoc};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
22#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
23pub enum SessionCommand {
24    /// Adds a recording and a view of it (all its channels, no ops) and shows it.
25    OpenView {
26        recording: RecordingRef,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        name: Option<String>,
29    },
30    /// Copies a view (its chain, display, plugin data) into a new one, optionally reading only
31    /// `channels` (indices into the source, in order): how some channels get other processing.
32    CopyView {
33        view: String,
34        #[serde(default, skip_serializing_if = "Option::is_none")]
35        channels: Option<Vec<usize>>,
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        name: Option<String>,
38    },
39    /// Removes a view and what only fed it.
40    CloseView { view: String },
41    RenameView { view: String, name: String },
42    /// Shows a view.
43    SelectView { view: String },
44    /// Inserts an op at `index` (default: the end) of a view's chain.
45    AddOp {
46        view: String,
47        node: Node,
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        index: Option<usize>,
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        id: Option<String>,
52    },
53    /// Replaces an op's parameters (the node may not change kind to `read` / `write`).
54    SetOp { view: String, op: String, node: Node },
55    EnableOp { view: String, op: String, enabled: bool },
56    /// Removes an op (from every chain through it: its input feeds what it fed).
57    RemoveOp { view: String, op: String },
58    /// Moves an op to `index` in the view's chain (an op other chains share stays put).
59    MoveOp { view: String, op: String, index: usize },
60    SetDisplay { view: String, display: DisplayDoc },
61    /// Replaces what plugin `plugin` keeps for a view (`null` removes it), e.g.
62    /// `{"plugin": "synapse", "data": {"sorting": "rec.kilosort4.sorting.zarr"}}`.
63    SetPluginData { view: String, plugin: String, data: serde_json::Value },
64    /// Adds a node at (`x`, `y`), fed by `after` when given.
65    AddNode {
66        kind: NodeKind,
67        #[serde(default)]
68        x: f32,
69        #[serde(default)]
70        y: f32,
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        id: Option<String>,
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        after: Option<String>,
75    },
76    /// Removes nodes and their edges.
77    RemoveNodes { nodes: Vec<String> },
78    /// Places nodes.
79    MoveNodes { moves: Vec<NodeMove> },
80    /// Feeds `to` from `from` (replacing `to`'s input).
81    Connect { from: String, to: String },
82    /// Removes `to`'s input.
83    Disconnect { to: String },
84    /// Replaces an op node's parameters.
85    SetNode { id: String, node: Node },
86    EnableNode { id: String, enabled: bool },
87    /// Points a source node at another recording (or channel subset).
88    SetSource { id: String, recording: RecordingRef },
89    /// Adds (or replaces) a recipe definition in the session (e.g. from the library).
90    AddRecipe { recipe: RecipeDoc },
91    /// Turns a chain of op nodes into a recipe and one node using it, wired as they were.
92    MakeRecipe {
93        nodes: Vec<String>,
94        name: String,
95        #[serde(default)]
96        labels: Vec<String>,
97    },
98    /// A use's own value of `key` (`"<op>.<key>"`); `null` goes back to the recipe's.
99    SetRecipeOverride { id: String, key: String, value: serde_json::Value },
100    /// Edits the recipe itself (every use follows): op `op`'s values become `node`'s.
101    SetRecipeOp { recipe: String, op: String, node: Node },
102    /// Renames a recipe and relabels it (every use follows).
103    SetRecipeInfo { recipe: String, name: String, labels: Vec<String>, description: String },
104    /// Replaces a recipe node by its ops (with its overrides), wired as it was.
105    Ungroup { id: String },
106    /// Adds an epoch, or replaces the one with its id (an empty id: a new one).
107    SetEpoch { epoch: EpochDoc },
108    RemoveEpoch { id: String },
109    /// Adds several epochs as one change (one undo step); none when one of them is invalid.
110    SetEpochs { epochs: Vec<EpochDoc> },
111    /// Adds an event set (empty id: a new one) or replaces the one with its id (its look).
112    SetEventSet { set: EventSetDoc },
113    RemoveEventSet { id: String },
114    /// Puts a recording at `start_sec` on the session's time.
115    PlaceRecording {
116        path: std::path::PathBuf,
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        source: Option<String>,
119        start_sec: f64,
120    },
121    /// Takes a recording off the session's time.
122    UnplaceRecording { path: std::path::PathBuf },
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct NodeMove {
128    pub id: String,
129    pub x: f32,
130    pub y: f32,
131}
132
133/// What a command changed, so the app redraws only that.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum Changed {
136    /// Views added, removed or renamed, or another view shown.
137    Views,
138    /// The view's op chain (its processed signal changes).
139    Ops(String),
140    /// The view's display only.
141    Display(String),
142    /// What a plugin keeps for the view: (view, plugin).
143    Plugin(String, String),
144    /// The graph (nodes, places, edges); the views it changed are reported on their own.
145    Graph,
146    /// Epochs or placements.
147    Timeline,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
151pub enum CommandError {
152    #[error("no view {0:?}")]
153    NoView(String),
154    #[error("view {view:?} has no op {op:?}")]
155    NoOp { view: String, op: String },
156    #[error("no node {0:?}")]
157    NoNode(String),
158    #[error("{0}")]
159    Invalid(String),
160}
161
162/// Applies `cmd` to `doc`; on an error `doc` is unchanged.
163pub fn apply(doc: &mut SessionDoc, cmd: SessionCommand) -> Result<Changed, CommandError> {
164    let mut work = doc.clone();
165    let change = apply_graph(&mut work, cmd)?;
166    work.compile();
167    *doc = work;
168    Ok(change)
169}
170
171fn apply_graph(doc: &mut SessionDoc, cmd: SessionCommand) -> Result<Changed, CommandError> {
172    use SessionCommand as C;
173    match cmd {
174        C::OpenView { recording, name } => {
175            check_channels(&recording.channels)?;
176            let name = name.unwrap_or_else(|| crate::domain::session::view_name(&recording));
177            let y = doc.graph.next_row();
178            let s = source_id(doc);
179            let v = doc.new_view_id();
180            doc.graph.nodes.push(GraphNode { id: s.clone(), x: 0.0, y, kind: NodeKind::Source { recording } });
181            doc.graph.nodes.push(GraphNode { id: v.clone(), x: COLUMN, y, kind: view_kind(name) });
182            doc.graph.connect(&s, &v).map_err(CommandError::Invalid)?;
183            doc.active_view = Some(v);
184            Ok(Changed::Views)
185        }
186        C::CopyView { view, channels, name } => {
187            let path = view_path(doc, &view)?;
188            let NodeKind::Source { recording } = node(doc, &path[0])?.kind.clone() else { unreachable!("a path starts at a source") };
189            let NodeKind::View { name: old, display, plugins } = node(doc, &view)?.kind.clone() else { unreachable!("checked by view_path") };
190            check_channels(&channels)?;
191            let y = doc.graph.next_row();
192            let source = match channels {
193                Some(subset) => {
194                    // `subset` indexes the source; a copy of a subset view must stay inside it
195                    if let Some(parent) = &recording.channels {
196                        if let Some(c) = subset.iter().find(|c| !parent.contains(c)) {
197                            return Err(CommandError::Invalid(format!("channel {c} is not read by view {view:?}")));
198                        }
199                    }
200                    let s = source_id(doc);
201                    let recording = RecordingRef { channels: Some(subset), ..recording };
202                    doc.graph.nodes.push(GraphNode { id: s.clone(), x: 0.0, y, kind: NodeKind::Source { recording } });
203                    s
204                }
205                None => path[0].clone(),
206            };
207            // Its own copy of the chain (edits to it leave the original alone)
208            let mut last = source;
209            let mut x = COLUMN;
210            for op in &path[1..path.len() - 1] {
211                let kind = node(doc, op)?.kind.clone();
212                let NodeKind::Op { node: n, .. } = &kind else { continue };
213                let id = doc.new_op_id(n);
214                doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind });
215                doc.graph.connect(&last, &id).map_err(CommandError::Invalid)?;
216                last = id;
217                x += COLUMN;
218            }
219            let v = doc.new_view_id();
220            let name = name.unwrap_or_else(|| format!("{old} (copy)"));
221            doc.graph.nodes.push(GraphNode { id: v.clone(), x, y, kind: NodeKind::View { name, display, plugins } });
222            doc.graph.connect(&last, &v).map_err(CommandError::Invalid)?;
223            doc.active_view = Some(v);
224            Ok(Changed::Views)
225        }
226        C::CloseView { view } => {
227            view_node(doc, &view)?;
228            let mut cur = doc.graph.input(&view).map(str::to_string);
229            doc.graph.remove(&view);
230            // What fed only this view goes with it
231            while let Some(id) = cur {
232                if !doc.graph.outputs(&id).is_empty() {
233                    break;
234                }
235                cur = doc.graph.input(&id).map(str::to_string);
236                doc.graph.remove(&id);
237            }
238            if doc.active_view.as_deref() == Some(view.as_str()) {
239                let i = doc.views.iter().position(|v| v.id == view).unwrap_or(0);
240                let rest: Vec<String> = doc.views.iter().filter(|v| v.id != view).map(|v| v.id.clone()).collect();
241                doc.active_view = rest.get(i.min(rest.len().saturating_sub(1))).cloned();
242            }
243            Ok(Changed::Views)
244        }
245        C::RenameView { view, name } => {
246            if let NodeKind::View { name: n, .. } = &mut view_node(doc, &view)?.kind {
247                *n = name;
248            }
249            Ok(Changed::Views)
250        }
251        C::SelectView { view } => {
252            view_path(doc, &view)?;
253            doc.active_view = Some(view);
254            Ok(Changed::Views)
255        }
256        C::AddOp { view, node: op, index, id } => {
257            check_processing(&op)?;
258            let path = view_path(doc, &view)?;
259            let id = match id {
260                Some(id) if doc.graph.contains(&id) || doc.view(&id).is_some() || id == "read" || id == "write" => {
261                    return Err(CommandError::Invalid(format!("op id {id:?} is taken")));
262                }
263                Some(id) => id,
264                None => doc.new_op_id(&op),
265            };
266            let ops = path.len() - 2;
267            let at = index.unwrap_or(ops).min(ops);
268            let (x, y) = node(doc, &path[at + 1]).map(|n| (n.x, n.y))?;
269            // Room for it: what follows on this chain moves one column right
270            for later in &path[at + 1..] {
271                if let Some(n) = doc.graph.node_mut(later) {
272                    n.x += COLUMN;
273                }
274            }
275            doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind: NodeKind::Op { enabled: true, node: op } });
276            doc.graph.splice(&path[at], &path[at + 1], &id);
277            Ok(Changed::Ops(view))
278        }
279        C::SetOp { view, op, node: n } => {
280            check_processing(&n)?;
281            if let NodeKind::Op { node, .. } = &mut chain_op(doc, &view, &op)?.kind {
282                *node = n;
283            }
284            Ok(Changed::Ops(view))
285        }
286        C::EnableOp { view, op, enabled } => {
287            if let NodeKind::Op { enabled: e, .. } = &mut chain_op(doc, &view, &op)?.kind {
288                *e = enabled;
289            }
290            Ok(Changed::Ops(view))
291        }
292        C::RemoveOp { view, op } => {
293            chain_op(doc, &view, &op)?;
294            doc.graph.remove_bridging(&op);
295            Ok(Changed::Ops(view))
296        }
297        C::MoveOp { view, op, index } => {
298            chain_op(doc, &view, &op)?;
299            if doc.graph.outputs(&op).len() > 1 {
300                return Err(CommandError::Invalid(format!("{op} feeds other chains too: move it on the Pipeline canvas")));
301            }
302            doc.graph.unlink_bridging(&op);
303            let path = view_path(doc, &view)?;
304            let at = index.min(path.len() - 2);
305            doc.graph.splice(&path[at], &path[at + 1], &op);
306            Ok(Changed::Ops(view))
307        }
308        C::SetDisplay { view, display } => {
309            if display.lanes == 0 || !(display.gain.is_finite() && display.gain > 0.0) {
310                return Err(CommandError::Invalid("display: lanes ≥ 1 and gain > 0".into()));
311            }
312            if let NodeKind::View { display: d, .. } = &mut view_node(doc, &view)?.kind {
313                *d = display;
314            }
315            Ok(Changed::Display(view))
316        }
317        C::SetPluginData { view, plugin, data } => {
318            if let NodeKind::View { plugins, .. } = &mut view_node(doc, &view)?.kind {
319                if data.is_null() {
320                    plugins.remove(&plugin);
321                } else {
322                    plugins.insert(plugin.clone(), data);
323                }
324            }
325            Ok(Changed::Plugin(view, plugin))
326        }
327        C::AddNode { kind, x, y, id, after } => {
328            let id = match id {
329                Some(id) if doc.graph.contains(&id) || doc.view(&id).is_some() || id == "read" || id == "write" => {
330                    return Err(CommandError::Invalid(format!("node id {id:?} is taken")));
331                }
332                Some(id) => id,
333                None => match &kind {
334                    NodeKind::Source { .. } => source_id(doc),
335                    NodeKind::Op { node, .. } => doc.new_op_id(node),
336                    NodeKind::View { .. } => doc.new_view_id(),
337                    NodeKind::Write {} => doc.graph.numbered_id("w", |id| doc.view(id).is_some()),
338                    NodeKind::Recipe { recipe, .. } => doc.graph.fresh_id(&super::recipe::file_stem(recipe).replace('-', "_")),
339                },
340            };
341            match &kind {
342                NodeKind::Op { node, .. } => check_processing(node)?,
343                NodeKind::Recipe { recipe, .. } if !doc.recipes.contains_key(recipe) => {
344                    return Err(CommandError::Invalid(format!("no recipe {recipe:?} in the session")));
345                }
346                NodeKind::Source { recording } => check_channels(&recording.channels)?,
347                _ => {}
348            }
349            doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind });
350            if let Some(from) = after {
351                doc.graph.connect(&from, &id).map_err(CommandError::Invalid)?;
352            }
353            Ok(Changed::Graph)
354        }
355        C::RemoveNodes { nodes } => {
356            for id in &nodes {
357                node(doc, id)?;
358            }
359            for id in &nodes {
360                doc.graph.remove(id);
361            }
362            Ok(Changed::Graph)
363        }
364        C::MoveNodes { moves } => {
365            for m in moves {
366                let n = doc.graph.node_mut(&m.id).ok_or_else(|| CommandError::NoNode(m.id.clone()))?;
367                if !(m.x.is_finite() && m.y.is_finite()) {
368                    return Err(CommandError::Invalid("node positions are numbers".into()));
369                }
370                (n.x, n.y) = (m.x, m.y);
371            }
372            Ok(Changed::Graph)
373        }
374        C::Connect { from, to } => {
375            doc.graph.connect(&from, &to).map_err(CommandError::Invalid)?;
376            Ok(Changed::Graph)
377        }
378        C::Disconnect { to } => {
379            node(doc, &to)?;
380            doc.graph.disconnect(&to);
381            Ok(Changed::Graph)
382        }
383        C::SetNode { id, node: n } => {
384            check_processing(&n)?;
385            match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
386                NodeKind::Op { node, .. } => *node = n,
387                _ => return Err(CommandError::Invalid(format!("{id} is not an op"))),
388            }
389            Ok(Changed::Graph)
390        }
391        C::EnableNode { id, enabled } => {
392            match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
393                NodeKind::Op { enabled: e, .. } | NodeKind::Recipe { enabled: e, .. } => *e = enabled,
394                _ => return Err(CommandError::Invalid(format!("{id} is not an op"))),
395            }
396            Ok(Changed::Graph)
397        }
398        C::AddRecipe { recipe } => {
399            if recipe.name.trim().is_empty() {
400                return Err(CommandError::Invalid("a recipe needs a name".into()));
401            }
402            for op in &recipe.ops {
403                check_processing(&op.node)?;
404            }
405            doc.recipes.insert(recipe.name.clone(), recipe);
406            Ok(Changed::Graph)
407        }
408        C::MakeRecipe { nodes, name, labels } => {
409            let name = name.trim().to_string();
410            if name.is_empty() || doc.recipes.contains_key(&name) {
411                return Err(CommandError::Invalid(format!("recipe name {name:?} is empty or taken")));
412            }
413            let order = chain_order(doc, &nodes)?;
414            let ops: Vec<super::session::OpDoc> = order
415                .iter()
416                .map(|id| match &doc.graph.node(id).expect("checked").kind {
417                    NodeKind::Op { enabled, node } => super::session::OpDoc { id: id.clone(), enabled: *enabled, node: node.clone() },
418                    _ => unreachable!("checked: ops only"),
419                })
420                .collect();
421            let (head, tail) = (order[0].clone(), order[order.len() - 1].clone());
422            let input = doc.graph.input(&head).map(str::to_string);
423            let outputs: Vec<String> = doc.graph.outputs(&tail).into_iter().map(str::to_string).collect();
424            let (x, y) = node(doc, &head).map(|n| (n.x, n.y))?;
425            let id = doc.graph.fresh_id(&super::recipe::file_stem(&name).replace('-', "_"));
426            let mut labels = labels;
427            if labels.is_empty() {
428                let mut fams: Vec<String> = ops.iter().map(|o| super::session::op_family(&o.node).to_string()).collect();
429                fams.dedup();
430                labels = fams;
431            }
432            doc.recipes.insert(name.clone(), RecipeDoc::new(name.clone(), labels, ops));
433            for n in &order {
434                doc.graph.remove(n);
435            }
436            doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind: NodeKind::Recipe { recipe: name, enabled: true, overrides: Default::default() } });
437            if let Some(i) = input {
438                doc.graph.connect(&i, &id).map_err(CommandError::Invalid)?;
439            }
440            for o in outputs {
441                doc.graph.connect(&id, &o).map_err(CommandError::Invalid)?;
442            }
443            Ok(Changed::Graph)
444        }
445        C::SetRecipeOverride { id, key, value } => {
446            let recipe = match &node(doc, &id)?.kind {
447                NodeKind::Recipe { recipe, .. } => recipe.clone(),
448                _ => return Err(CommandError::Invalid(format!("{id} is not a recipe"))),
449            };
450            let def = doc.recipes.get(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?.clone();
451            let Some(NodeKind::Recipe { overrides, .. }) = doc.graph.node_mut(&id).map(|n| &mut n.kind) else { unreachable!() };
452            if value.is_null() {
453                overrides.remove(&key);
454            } else {
455                overrides.insert(key.clone(), value);
456            }
457            // The values must still make valid ops
458            for op in def.ops_with(overrides) {
459                check_processing(&op.node)?;
460            }
461            Ok(Changed::Graph)
462        }
463        C::SetRecipeOp { recipe, op, node: n } => {
464            check_processing(&n)?;
465            let def = doc.recipes.get_mut(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?;
466            let slot = def.ops.iter_mut().find(|o| o.id == op).ok_or_else(|| CommandError::Invalid(format!("recipe {recipe:?} has no op {op:?}")))?;
467            slot.node = n;
468            def.version += 1;
469            Ok(Changed::Graph)
470        }
471        C::SetRecipeInfo { recipe, name, labels, description } => {
472            let name = name.trim().to_string();
473            if name.is_empty() || (name != recipe && doc.recipes.contains_key(&name)) {
474                return Err(CommandError::Invalid(format!("recipe name {name:?} is empty or taken")));
475            }
476            let mut def = doc.recipes.remove(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?;
477            def.name = name.clone();
478            def.labels = labels;
479            def.description = description;
480            def.version += 1;
481            doc.recipes.insert(name.clone(), def);
482            for n in &mut doc.graph.nodes {
483                if let NodeKind::Recipe { recipe: r, .. } = &mut n.kind {
484                    if *r == recipe {
485                        *r = name.clone();
486                    }
487                }
488            }
489            Ok(Changed::Graph)
490        }
491        C::Ungroup { id } => {
492            let (recipe, enabled, overrides, x, y) = match &node(doc, &id)?.kind {
493                NodeKind::Recipe { recipe, enabled, overrides } => (recipe.clone(), *enabled, overrides.clone(), node(doc, &id)?.x, node(doc, &id)?.y),
494                _ => return Err(CommandError::Invalid(format!("{id} is not a recipe"))),
495            };
496            let def = doc.recipes.get(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?.clone();
497            let input = doc.graph.input(&id).map(str::to_string);
498            let outputs: Vec<String> = doc.graph.outputs(&id).into_iter().map(str::to_string).collect();
499            doc.graph.remove(&id);
500            let mut last = input;
501            for (k, op) in def.ops_with(&overrides).into_iter().enumerate() {
502                let nid = doc.new_op_id(&op.node);
503                doc.graph.nodes.push(GraphNode { id: nid.clone(), x: x + k as f32 * COLUMN, y, kind: NodeKind::Op { enabled: op.enabled && enabled, node: op.node } });
504                if let Some(l) = &last {
505                    doc.graph.connect(l, &nid).map_err(CommandError::Invalid)?;
506                }
507                last = Some(nid);
508            }
509            if let Some(l) = last {
510                for o in outputs {
511                    doc.graph.connect(&l, &o).map_err(CommandError::Invalid)?;
512                }
513            }
514            Ok(Changed::Graph)
515        }
516        C::SetEpoch { mut epoch } => {
517            if !(epoch.start_sec.is_finite() && epoch.end_sec.is_finite() && epoch.end_sec > epoch.start_sec && epoch.start_sec >= 0.0) {
518                return Err(CommandError::Invalid("an epoch ends after it starts, at or after 0 s".into()));
519            }
520            if epoch.name.trim().is_empty() {
521                epoch.name = format!("{} {}", epoch.kind, doc.epochs.len() + 1);
522            }
523            if epoch.id.is_empty() {
524                epoch.id = (1..).map(|i| format!("e{i}")).find(|id| !doc.epochs.iter().any(|e| e.id == *id)).expect("ids are unbounded");
525            }
526            match doc.epochs.iter_mut().find(|e| e.id == epoch.id) {
527                Some(e) => *e = epoch,
528                None => doc.epochs.push(epoch),
529            }
530            doc.epochs.sort_by(|a, b| a.recording.cmp(&b.recording).then(a.start_sec.total_cmp(&b.start_sec)));
531            Ok(Changed::Timeline)
532        }
533        C::RemoveEpoch { id } => {
534            let n = doc.epochs.len();
535            doc.epochs.retain(|e| e.id != id);
536            if doc.epochs.len() == n {
537                return Err(CommandError::Invalid(format!("no epoch {id:?}")));
538            }
539            Ok(Changed::Timeline)
540        }
541        C::SetEpochs { epochs } => {
542            let mut d = doc.clone();
543            for epoch in epochs {
544                apply(&mut d, C::SetEpoch { epoch })?;
545            }
546            *doc = d;
547            Ok(Changed::Timeline)
548        }
549        C::SetEventSet { mut set } => {
550            if !(set.tick_height.is_finite() && set.tick_width.is_finite() && set.hue.is_finite()) {
551                return Err(CommandError::Invalid("an event set's look must be finite".into()));
552            }
553            set.tick_height = set.tick_height.clamp(0.05, 1.0);
554            set.tick_width = set.tick_width.clamp(0.5, 8.0);
555            set.hue = set.hue.rem_euclid(360.0);
556            if set.name.trim().is_empty() {
557                set.name = format!("events {}", doc.event_sets.len() + 1);
558            }
559            if set.id.is_empty() {
560                set.id = (1..).map(|i| format!("ev{i}")).find(|id| !doc.event_sets.iter().any(|e| e.id == *id)).expect("ids are unbounded");
561            }
562            match doc.event_sets.iter_mut().find(|e| e.id == set.id) {
563                Some(e) => *e = set,
564                None => doc.event_sets.push(set),
565            }
566            Ok(Changed::Timeline)
567        }
568        C::RemoveEventSet { id } => {
569            let n = doc.event_sets.len();
570            doc.event_sets.retain(|e| e.id != id);
571            if doc.event_sets.len() == n {
572                return Err(CommandError::Invalid(format!("no event set {id:?}")));
573            }
574            Ok(Changed::Timeline)
575        }
576        C::PlaceRecording { path, source, start_sec } => {
577            if !(start_sec.is_finite() && start_sec >= 0.0) {
578                return Err(CommandError::Invalid("a recording starts at or after 0 s".into()));
579            }
580            match doc.placements.iter_mut().find(|p| p.path == path && p.source == source) {
581                Some(p) => p.start_sec = start_sec,
582                None => doc.placements.push(Placement { path, source, start_sec }),
583            }
584            Ok(Changed::Timeline)
585        }
586        C::UnplaceRecording { path } => {
587            doc.placements.retain(|p| p.path != path);
588            Ok(Changed::Timeline)
589        }
590        C::SetSource { id, recording } => {
591            check_channels(&recording.channels)?;
592            match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
593                NodeKind::Source { recording: r } => *r = recording,
594                _ => return Err(CommandError::Invalid(format!("{id} is not a recording"))),
595            }
596            Ok(Changed::Graph)
597        }
598    }
599}
600
601/// `nodes` (op nodes) in chain order: one enters the group from outside, each leads to the next.
602fn chain_order(doc: &SessionDoc, nodes: &[String]) -> Result<Vec<String>, CommandError> {
603    let bad = |m: &str| Err(CommandError::Invalid(format!("a recipe is made of a chain of ops: {m}")));
604    if nodes.is_empty() {
605        return bad("nothing selected");
606    }
607    for id in nodes {
608        if !matches!(node(doc, id)?.kind, NodeKind::Op { .. }) {
609            return bad(&format!("{id} is not an op"));
610        }
611    }
612    let inside = |id: &str| nodes.iter().any(|n| n == id);
613    let heads: Vec<&String> = nodes.iter().filter(|n| !doc.graph.input(n).is_some_and(inside)).collect();
614    if heads.len() != 1 {
615        return bad("the selection must be one chain");
616    }
617    let mut order = vec![heads[0].clone()];
618    while order.len() < nodes.len() {
619        let next: Vec<&str> = doc.graph.outputs(order.last().expect("non-empty")).into_iter().filter(|o| inside(o)).collect();
620        match next.as_slice() {
621            [one] if doc.graph.outputs(order.last().expect("non-empty")).len() == 1 => order.push(one.to_string()),
622            _ => return bad("the selection branches or has gaps"),
623        }
624    }
625    Ok(order)
626}
627
628/// A new view node.
629pub fn view_kind(name: String) -> NodeKind {
630    NodeKind::View { name, display: DisplayDoc::default(), plugins: Default::default() }
631}
632
633fn source_id(doc: &SessionDoc) -> String {
634    doc.graph.numbered_id("s", |id| doc.view(id).is_some())
635}
636
637fn check_channels(channels: &Option<Vec<usize>>) -> Result<(), CommandError> {
638    if channels.as_ref().is_some_and(Vec::is_empty) {
639        return Err(CommandError::Invalid("a view needs at least one channel".into()));
640    }
641    Ok(())
642}
643
644/// Processing nodes run on the visible window and in the written output; `read` / `write` are the
645/// view's recording and its write step, and `resample` changes the timeline (not in v1 views).
646fn check_processing(node: &Node) -> Result<(), CommandError> {
647    match node {
648        Node::Read { .. } | Node::Write { .. } | Node::Resample { .. } => {
649            Err(CommandError::Invalid(format!("{} is not an op of a view", op_name(node))))
650        }
651        _ => node.stage().map(|_| ()).map_err(|e| CommandError::Invalid(e.to_string())),
652    }
653}
654
655fn node<'a>(doc: &'a SessionDoc, id: &str) -> Result<&'a GraphNode, CommandError> {
656    doc.graph.node(id).ok_or_else(|| CommandError::NoNode(id.to_string()))
657}
658
659/// The view node `view`.
660fn view_node<'a>(doc: &'a mut SessionDoc, view: &str) -> Result<&'a mut GraphNode, CommandError> {
661    match doc.graph.node_mut(view) {
662        Some(n) if matches!(n.kind, NodeKind::View { .. }) => Ok(n),
663        _ => Err(CommandError::NoView(view.to_string())),
664    }
665}
666
667/// `view`'s chain `[source, op…, view]`.
668fn view_path(doc: &mut SessionDoc, view: &str) -> Result<Vec<String>, CommandError> {
669    view_node(doc, view)?;
670    doc.graph.path(view).ok_or_else(|| CommandError::Invalid(format!("view {view:?} is not connected to a recording")))
671}
672
673/// Op `op` on `view`'s chain.
674fn chain_op<'a>(doc: &'a mut SessionDoc, view: &str, op: &str) -> Result<&'a mut GraphNode, CommandError> {
675    let path = view_path(doc, view)?;
676    if !path[1..path.len() - 1].iter().any(|n| n == op) {
677        return Err(CommandError::NoOp { view: view.to_string(), op: op.to_string() });
678    }
679    doc.graph.node_mut(op).ok_or_else(|| CommandError::NoOp { view: view.to_string(), op: op.to_string() })
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::domain::session::Show;
686    use crate::domain::graph::NodeKind;
687    use dspk_pipeline::{Band, Design, Mode, Start};
688
689    fn bandpass(lo: f64, hi: f64) -> Node {
690        Node::Iir { band: Band::Bandpass, cutoff: vec![lo, hi], order: 3, design: Design::Butterworth, ripple_db: None, mode: Mode::ForwardBackward, start: Start::Rest }
691    }
692
693    fn opened() -> SessionDoc {
694        let mut d = SessionDoc::default();
695        let rec = RecordingRef { path: "/data/rec.bin".into(), source: None, channels: None };
696        assert_eq!(apply(&mut d, SessionCommand::OpenView { recording: rec, name: None }), Ok(Changed::Views));
697        d
698    }
699
700    #[test]
701    fn test_open_add_set_remove() {
702        let mut d = opened();
703        assert_eq!(d.active().unwrap().name, "rec.bin");
704        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
705        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
706        assert_eq!(d.views[0].ops.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["iir", "cmr"]);
707        apply(&mut d, SessionCommand::SetOp { view: "v1".into(), op: "iir".into(), node: bandpass(500.0, 5000.0) }).unwrap();
708        assert_eq!(d.views[0].ops[0].node, bandpass(500.0, 5000.0));
709        apply(&mut d, SessionCommand::MoveOp { view: "v1".into(), op: "cmr".into(), index: 0 }).unwrap();
710        assert_eq!(d.views[0].ops[0].id, "cmr");
711        apply(&mut d, SessionCommand::RemoveOp { view: "v1".into(), op: "cmr".into() }).unwrap();
712        assert_eq!(d.views[0].ops.len(), 1);
713    }
714
715    /// Views are named by their source (the file for a file of one source); a document saved with
716    /// the old default names (`file · /source`) loads with the new ones.
717    #[test]
718    fn test_views_are_named_by_source() {
719        let mut d = opened();
720        let emg = RecordingRef { path: "/data/meps.nwb.zarr".into(), source: Some("/acquisition/EMG".into()), channels: None };
721        apply(&mut d, SessionCommand::OpenView { recording: emg.clone(), name: None }).unwrap();
722        assert_eq!(d.active().unwrap().name, "EMG");
723        let main = RecordingRef { path: "/data/rec.zarr".into(), source: Some(dspk_io::sources::MAIN.into()), channels: None };
724        assert_eq!(crate::domain::session::view_name(&main), "rec.zarr");
725        let view = d.active_view.clone().unwrap();
726        apply(&mut d, SessionCommand::RenameView { view, name: "meps.nwb.zarr · /acquisition/EMG".into() }).unwrap();
727        let loaded = SessionDoc::from_json(&serde_json::to_string(&d).unwrap()).unwrap();
728        assert!(loaded.views.iter().any(|v| v.name == "EMG"), "{:?}", loaded.views.iter().map(|v| &v.name).collect::<Vec<_>>());
729    }
730
731    #[test]
732    fn test_invalid_commands_leave_the_document_unchanged() {
733        let mut d = opened();
734        let before = d.clone();
735        let bad_iir = Node::Iir { band: Band::Bandpass, cutoff: vec![300.0], order: 3, design: Design::Butterworth, ripple_db: None, mode: Mode::ForwardBackward, start: Start::Rest };
736        assert!(apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bad_iir, index: None, id: None }).is_err());
737        assert!(apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Car {}, index: None, id: Some("read".into()) }).is_err());
738        assert!(apply(&mut d, SessionCommand::RemoveOp { view: "v1".into(), op: "nope".into() }).is_err());
739        assert!(apply(&mut d, SessionCommand::SelectView { view: "v9".into() }).is_err());
740        let display = DisplayDoc { lanes: 0, ..DisplayDoc::default() };
741        assert!(apply(&mut d, SessionCommand::SetDisplay { view: "v1".into(), display }).is_err());
742        assert_eq!(d, before);
743    }
744
745    #[test]
746    fn test_copy_with_a_channel_subset() {
747        let mut d = opened();
748        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
749        apply(&mut d, SessionCommand::CopyView { view: "v1".into(), channels: Some(vec![4, 5]), name: None }).unwrap();
750        let copy = d.active().unwrap();
751        assert_eq!((copy.id.as_str(), copy.recording.channels.as_deref()), ("v2", Some(&[4, 5][..])));
752        let nodes = |ops: &[crate::domain::session::OpDoc]| ops.iter().map(|o| (o.enabled, o.node.clone())).collect::<Vec<_>>();
753        assert_eq!(nodes(&copy.ops), nodes(&d.views[0].ops), "the copy starts with the same chain");
754        assert_ne!(copy.ops[0].id, d.views[0].ops[0].id, "its own ops: edits to it leave the original alone");
755        // A copy of a subset stays inside it
756        assert!(apply(&mut d, SessionCommand::CopyView { view: "v2".into(), channels: Some(vec![6]), name: None }).is_err());
757    }
758
759    #[test]
760    fn test_close_moves_the_active_view() {
761        let mut d = opened();
762        apply(&mut d, SessionCommand::CopyView { view: "v1".into(), channels: None, name: None }).unwrap();
763        apply(&mut d, SessionCommand::CloseView { view: "v2".into() }).unwrap();
764        assert_eq!(d.active_view.as_deref(), Some("v1"));
765        apply(&mut d, SessionCommand::CloseView { view: "v1".into() }).unwrap();
766        assert_eq!(d.active_view, None);
767    }
768
769    #[test]
770    fn test_commands_are_json() {
771        let cmd: SessionCommand = serde_json::from_str(
772            r#"{"command": "set_op", "view": "v1", "op": "iir", "node": {"op": "iir", "band": "highpass", "cutoff": [250.0]}}"#,
773        )
774        .unwrap();
775        let mut d = opened();
776        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
777        assert_eq!(apply(&mut d, cmd), Ok(Changed::Ops("v1".into())));
778        let display = DisplayDoc { show: Show::Raw, ..DisplayDoc::default() };
779        assert_eq!(apply(&mut d, SessionCommand::SetDisplay { view: "v1".into(), display }), Ok(Changed::Display("v1".into())));
780    }
781
782    #[test]
783    fn test_plugin_data_is_set_and_removed() {
784        let mut d = opened();
785        let data = serde_json::json!({"sorting": "/d/rec.kilosort4.sorting.zarr"});
786        let cmd = SessionCommand::SetPluginData { view: "v1".into(), plugin: "synapse".into(), data: data.clone() };
787        assert_eq!(apply(&mut d, cmd), Ok(Changed::Plugin("v1".into(), "synapse".into())));
788        assert_eq!(d.views[0].plugins["synapse"], data);
789        let back = SessionDoc::from_json(&d.to_json()).unwrap();
790        assert_eq!(back, d);
791        apply(&mut d, SessionCommand::SetPluginData { view: "v1".into(), plugin: "synapse".into(), data: serde_json::Value::Null }).unwrap();
792        assert!(d.views[0].plugins.is_empty());
793    }
794
795    #[test]
796    fn test_graph_commands_branch_a_recording() {
797        let mut d = opened();
798        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
799        // A second branch off the recording: a low-pass into a new view
800        let lp: Node = serde_json::from_value(serde_json::json!({"op": "iir", "band": "lowpass", "cutoff": [300.0]})).unwrap();
801        apply(&mut d, SessionCommand::AddNode { kind: NodeKind::Op { enabled: true, node: lp }, x: 260.0, y: 150.0, id: Some("lfp_band".into()), after: Some("s1".into()) }).unwrap();
802        apply(&mut d, SessionCommand::AddNode { kind: view_kind("LFP".into()), x: 520.0, y: 150.0, id: None, after: Some("lfp_band".into()) }).unwrap();
803        assert_eq!(d.views.len(), 2);
804        assert_eq!(d.views[1].name, "LFP");
805        assert_eq!(d.views[1].ops[0].id, "lfp_band");
806        // Editing the shared recording's branch leaves the other alone
807        apply(&mut d, SessionCommand::EnableNode { id: "iir".into(), enabled: false }).unwrap();
808        assert!(!d.views[0].ops[0].enabled && d.views[1].ops[0].enabled);
809        // A view cut off its source is no longer a view
810        apply(&mut d, SessionCommand::Disconnect { to: "v2".into() }).unwrap();
811        assert_eq!(d.views.len(), 1);
812        assert!(apply(&mut d, SessionCommand::Connect { from: "v1".into(), to: "v2".into() }).is_err(), "a view has no output");
813        apply(&mut d, SessionCommand::MoveNodes { moves: vec![NodeMove { id: "v2".into(), x: 10.0, y: 20.0 }] }).unwrap();
814        assert_eq!((d.graph.node("v2").unwrap().x, d.graph.node("v2").unwrap().y), (10.0, 20.0));
815        apply(&mut d, SessionCommand::RemoveNodes { nodes: vec!["v2".into(), "lfp_band".into()] }).unwrap();
816        assert_eq!(d.graph.nodes.len(), 3);
817    }
818
819    #[test]
820    fn test_epochs_and_placements() {
821        let mut d = opened();
822        let e = EpochDoc { id: String::new(), recording: "/data/rec.bin".into(), name: String::new(), kind: "stimulus".into(), start_sec: 2.0, end_sec: 3.5, notes: String::new() };
823        apply(&mut d, SessionCommand::SetEpoch { epoch: e.clone() }).unwrap();
824        assert_eq!((d.epochs[0].id.as_str(), d.epochs[0].name.as_str()), ("e1", "stimulus 1"));
825        assert!(apply(&mut d, SessionCommand::SetEpoch { epoch: EpochDoc { end_sec: 1.0, ..e.clone() } }).is_err(), "ends before it starts");
826        apply(&mut d, SessionCommand::SetEpoch { epoch: EpochDoc { id: "e1".into(), name: "tone".into(), ..e } }).unwrap();
827        assert_eq!((d.epochs.len(), d.epochs[0].name.as_str()), (1, "tone"));
828        apply(&mut d, SessionCommand::RemoveEpoch { id: "e1".into() }).unwrap();
829        // Event sets: ids given, the look clamped, removed by id
830        let set = EventSetDoc { tick_height: 3.0, tick_width: 0.0, ..EventSetDoc::new("/r.bin".into(), "", "/r.dspk/events/a.parquet".into()) };
831        apply(&mut d, SessionCommand::SetEventSet { set }).unwrap();
832        let s = &d.event_sets[0];
833        assert_eq!((s.id.as_str(), s.name.as_str(), s.tick_height, s.tick_width), ("ev1", "events 1", 1.0, 0.5));
834        apply(&mut d, SessionCommand::RemoveEventSet { id: "ev1".into() }).unwrap();
835        assert!(d.event_sets.is_empty());
836        // Several epochs at once, or none when one is invalid
837        let ep = |a: f64, b: f64| EpochDoc { id: String::new(), recording: "/r.bin".into(), name: String::new(), kind: "response".into(), start_sec: a, end_sec: b, notes: String::new() };
838        assert!(apply(&mut d, SessionCommand::SetEpochs { epochs: vec![ep(1.0, 2.0), ep(3.0, 2.0)] }).is_err());
839        assert!(d.epochs.is_empty(), "nothing added");
840        apply(&mut d, SessionCommand::SetEpochs { epochs: vec![ep(1.0, 2.0), ep(3.0, 4.0)] }).unwrap();
841        assert_eq!(d.epochs.len(), 2);
842        apply(&mut d, SessionCommand::PlaceRecording { path: "/data/rec.bin".into(), source: None, start_sec: 60.0 }).unwrap();
843        apply(&mut d, SessionCommand::PlaceRecording { path: "/data/rec.bin".into(), source: None, start_sec: 90.0 }).unwrap();
844        assert_eq!((d.placements.len(), d.placements[0].start_sec), (1, 90.0));
845        let back = SessionDoc::from_json(&d.to_json()).unwrap();
846        assert_eq!(back.placements, d.placements);
847    }
848
849    #[test]
850    fn test_recipes_make_override_edit_ungroup() {
851        let mut d = opened();
852        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
853        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
854        let before = d.views[0].ops.iter().map(|o| o.node.clone()).collect::<Vec<_>>();
855        apply(&mut d, SessionCommand::MakeRecipe { nodes: vec!["cmr".into(), "iir".into()], name: "Spikes".into(), labels: vec![] }).unwrap();
856        assert_eq!(d.graph.nodes.len(), 3, "source, recipe, view");
857        assert_eq!(d.views[0].ops.iter().map(|o| o.node.clone()).collect::<Vec<_>>(), before, "the view runs the same ops");
858        assert_eq!(d.recipes["Spikes"].labels, ["filter", "reference"]);
859        // A use's own value, then the recipe's (every use follows)
860        apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::json!([500.0, 5000.0]) }).unwrap();
861        assert_eq!(d.views[0].ops[0].node, bandpass(500.0, 5000.0));
862        assert!(apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::json!([500.0]) }).is_err(), "a band-pass needs two cutoffs");
863        apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::Value::Null }).unwrap();
864        apply(&mut d, SessionCommand::SetRecipeOp { recipe: "Spikes".into(), op: "iir".into(), node: bandpass(250.0, 7000.0) }).unwrap();
865        assert_eq!((d.views[0].ops[0].node.clone(), d.recipes["Spikes"].version), (bandpass(250.0, 7000.0), 2));
866        apply(&mut d, SessionCommand::Ungroup { id: "spikes".into() }).unwrap();
867        assert_eq!(d.graph.nodes.len(), 4);
868        assert_eq!(d.views[0].ops[0].node, bandpass(250.0, 7000.0));
869        // Not a chain
870        assert!(apply(&mut d, SessionCommand::MakeRecipe { nodes: vec!["s1".into()], name: "x".into(), labels: vec![] }).is_err());
871    }
872
873    #[test]
874    fn test_a_shared_op_is_not_moved_by_one_view() {
875        let mut d = opened();
876        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
877        apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
878        apply(&mut d, SessionCommand::AddNode { kind: view_kind("other".into()), x: 0.0, y: 0.0, id: None, after: Some("cmr".into()) }).unwrap();
879        assert!(apply(&mut d, SessionCommand::MoveOp { view: "v1".into(), op: "cmr".into(), index: 1 }).is_err());
880        assert_eq!(d.view("v2").unwrap().ops.len(), 1, "the other view keeps cmr");
881    }
882}