Skip to main content

oino_ui/
patch.rs

1//! Patch ops (§4.7): the [`NodeOp`] wire types plus the reference
2//! [`diff`] / [`apply`] pair (`apply(old, diff(old, new)) == new`, §4.7).
3//!
4//! Diffing rule set (§4.7): different `t`/`key` ⇒ `replace`; same kind ⇒ one
5//! `set` with all changed scalar/object props; collections match by key —
6//! unchanged key sequence ⇒ recurse/`item` per changed member, tail-append-only
7//! (`items`/`rows`/`lines`) ⇒ `append`, otherwise one `splice` per maximal
8//! contiguous changed run (LIS keeps stable members in place). If the encoded
9//! ops exceed 50 % of the encoded new node, a single root `replace` is emitted
10//! instead.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value as Json;
14
15use crate::node::{Key, Node};
16
17/// A path from the entry root to a node (§4.6): sibling keys plus the fixed
18/// segments `"child"` ([`Block.child`]), `"fallback"` and `"action"`
19/// ([`Empty.action`]), and tab keys for `Tabs.panes`.
20pub type NodePath = Vec<String>;
21
22/// Collection a patch op addresses (§4.7).
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum PatchField {
26    /// `Layout.children`.
27    Children,
28    /// `List.items`.
29    Items,
30    /// `Table.rows`.
31    Rows,
32    /// `Tree.nodes`.
33    Nodes,
34    /// `Log.lines`.
35    Lines,
36    /// `Tabs.titles`.
37    Titles,
38    /// `Chart.datasets`.
39    Datasets,
40    /// `Sparkline.data` / `BarChart` bars.
41    Data,
42    /// `Tabs.panes` (segment = tab key).
43    Panes,
44}
45
46/// A collection member: a node (`Layout.children`, `Tabs.panes`) or a plain
47/// item (`List.items`, `Table.rows`, `Log.lines`, …).
48#[allow(
49    clippy::large_enum_variant,
50    reason = "patch ops are built once per update; boxing every node adds noise"
51)]
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53#[serde(untagged)]
54pub enum PatchItem {
55    /// A node subtree.
56    Node(Node),
57    /// A plain item (shape depends on `field`).
58    Item(Json),
59}
60
61/// One patch op (§4.7).
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "op", rename_all = "lowercase")]
64pub enum NodeOp {
65    /// Replace the node at `path` (empty path = the entry root).
66    Replace {
67        /// Path to the node.
68        path: NodePath,
69        /// The new node.
70        node: Node,
71    },
72    /// Shallow prop merge at `path`; `null` unsets a prop. Not for collections.
73    Set {
74        /// Path to the node.
75        path: NodePath,
76        /// Changed props (`null` unsets).
77        props: serde_json::Map<String, Json>,
78    },
79    /// Remove `remove` items at `index` and insert `insert` (§4.7).
80    Splice {
81        /// Path to the owning node.
82        path: NodePath,
83        /// Collection to modify.
84        field: PatchField,
85        /// Index of the first removed item.
86        index: u32,
87        /// Number of removed items.
88        remove: u32,
89        /// Items inserted at `index`.
90        insert: Vec<PatchItem>,
91    },
92    /// Append items to a ring-trimmed collection (`Log.lines`, `List.items`,
93    /// `Table.rows`); older items drop silently past the limit (§4.8).
94    Append {
95        /// Path to the owning node.
96        path: NodePath,
97        /// Collection to extend.
98        field: PatchField,
99        /// Items appended at the tail.
100        items: Vec<PatchItem>,
101    },
102    /// Replace one keyed item (key sequence unchanged, §4.7).
103    Item {
104        /// Path to the owning node.
105        path: NodePath,
106        /// Collection to modify.
107        field: PatchField,
108        /// Key of the replaced item.
109        key: String,
110        /// The new item.
111        value: PatchItem,
112    },
113}
114
115impl NodeOp {
116    /// `replace` at `path` (an empty path is the entry root).
117    #[must_use]
118    pub fn replace(
119        path: impl IntoIterator<Item = impl Into<String>>,
120        node: impl Into<Node>,
121    ) -> Self {
122        Self::Replace {
123            path: path.into_iter().map(Into::into).collect(),
124            node: node.into(),
125        }
126    }
127
128    /// `set`: shallow prop merge at `path`.
129    #[must_use]
130    pub fn set(
131        path: impl IntoIterator<Item = impl Into<String>>,
132        props: serde_json::Map<String, Json>,
133    ) -> Self {
134        Self::Set {
135            path: path.into_iter().map(Into::into).collect(),
136            props,
137        }
138    }
139
140    /// `splice`: remove `remove` members at `index` and insert `insert`.
141    #[must_use]
142    pub fn splice(
143        path: impl IntoIterator<Item = impl Into<String>>,
144        field: PatchField,
145        index: u32,
146        remove: u32,
147        insert: impl IntoIterator<Item = impl Into<PatchItem>>,
148    ) -> Self {
149        Self::Splice {
150            path: path.into_iter().map(Into::into).collect(),
151            field,
152            index,
153            remove,
154            insert: insert.into_iter().map(Into::into).collect(),
155        }
156    }
157
158    /// `append`: extend a ring-trimmed collection at `path` (the hot path for logs).
159    #[must_use]
160    pub fn append(
161        path: impl IntoIterator<Item = impl Into<String>>,
162        field: PatchField,
163        items: impl IntoIterator<Item = impl Into<PatchItem>>,
164    ) -> Self {
165        Self::Append {
166            path: path.into_iter().map(Into::into).collect(),
167            field,
168            items: items.into_iter().map(Into::into).collect(),
169        }
170    }
171
172    /// `item`: replace one keyed member at `path`.
173    #[must_use]
174    pub fn item(
175        path: impl IntoIterator<Item = impl Into<String>>,
176        field: PatchField,
177        key: impl Into<String>,
178        value: impl Into<PatchItem>,
179    ) -> Self {
180        Self::Item {
181            path: path.into_iter().map(Into::into).collect(),
182            field,
183            key: key.into(),
184            value: value.into(),
185        }
186    }
187}
188
189impl From<Json> for PatchItem {
190    fn from(json: Json) -> Self {
191        Self::Item(json)
192    }
193}
194
195impl From<String> for PatchItem {
196    fn from(text: String) -> Self {
197        Self::Item(Json::String(text))
198    }
199}
200
201impl From<&str> for PatchItem {
202    fn from(text: &str) -> Self {
203        Self::Item(Json::String(text.to_owned()))
204    }
205}
206
207impl From<Node> for PatchItem {
208    fn from(node: Node) -> Self {
209        Self::Node(node)
210    }
211}
212
213/// Why a patch failed to resolve (§4.7 / §9.3): the consumer drops the tree and
214/// requests a resync. Ops before the failing one stay applied — the protocol
215/// applies ops in order and gives no transactionality guarantee.
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub enum PatchError {
218    /// No node resolves at the op path (the resolved prefix is shown).
219    MissingPath {
220        /// The path prefix that could not be descended.
221        path: NodePath,
222    },
223    /// The node at `path` does not own `field` (wrong kind).
224    FieldMismatch {
225        /// Path of the offending node.
226        path: NodePath,
227        /// The collection the op addressed.
228        field: PatchField,
229        /// Kind of the node found there.
230        kind: String,
231    },
232    /// No member with `key` exists in the collection at `path`.
233    MissingKey {
234        /// Path of the owning node.
235        path: NodePath,
236        /// The collection.
237        field: PatchField,
238        /// The member key that did not resolve.
239        key: String,
240    },
241    /// `index + remove` runs past the end of the collection.
242    OutOfBounds {
243        /// Path of the owning node.
244        path: NodePath,
245        /// The collection.
246        field: PatchField,
247        /// The splice start index.
248        index: u32,
249        /// Current length of the collection.
250        len: usize,
251    },
252    /// A splice/insert payload is not a member of `field` (e.g. a node in
253    /// `rows`).
254    BadItem {
255        /// Path of the owning node.
256        path: NodePath,
257        /// The collection.
258        field: PatchField,
259        /// What was wrong with the payload.
260        detail: String,
261    },
262    /// A `set` op merged the node into something that no longer parses.
263    BadPatch {
264        /// Path of the offending node.
265        path: NodePath,
266        /// The (de)serialization error.
267        detail: String,
268    },
269    /// A `panes` splice insert carried a node without the pane key.
270    MissingItemKey {
271        /// Path of the owning `Tabs` node.
272        path: NodePath,
273    },
274}
275
276impl std::fmt::Display for PatchError {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            Self::MissingPath { path } => write!(f, "no node at path {path:?}"),
280            Self::FieldMismatch { path, field, kind } => {
281                write!(
282                    f,
283                    "node at {path:?} is {kind}, which has no {field:?} collection"
284                )
285            }
286            Self::MissingKey { path, field, key } => {
287                write!(f, "no {field:?} member keyed {key:?} at {path:?}")
288            }
289            Self::OutOfBounds {
290                path,
291                field,
292                index,
293                len,
294            } => {
295                write!(
296                    f,
297                    "splice at {index} past end ({len}) of {field:?} at {path:?}"
298                )
299            }
300            Self::BadItem {
301                path,
302                field,
303                detail,
304            } => {
305                write!(f, "bad {field:?} item at {path:?}: {detail}")
306            }
307            Self::BadPatch { path, detail } => {
308                write!(f, "set at {path:?} broke the node: {detail}")
309            }
310            Self::MissingItemKey { path } => {
311                write!(f, "panes insert at {path:?} without a key")
312            }
313        }
314    }
315}
316
317impl std::error::Error for PatchError {}
318
319/// Diffs `old` into `new` (§4.7): ops whose paths are relative to the entry
320/// root, with `apply(old, &ops) == new`. Always succeeds — anything the rule
321/// set cannot express incrementally degenerates to one root `replace`.
322#[must_use]
323pub fn diff(old: &Node, new: &Node) -> Vec<NodeOp> {
324    let mut ops = Vec::new();
325    diff_node(old, new, &NodePath::new(), &mut ops);
326    // §4.7 rule 4: ops exceeding 50 % of the encoded new node ⇒ one replace.
327    let new_bytes = serde_json::to_vec(new).map_or(usize::MAX, |v| v.len());
328    let ops_bytes = serde_json::to_vec(&ops).map_or(usize::MAX, |v| v.len());
329    if ops_bytes
330        .checked_mul(2)
331        .is_none_or(|twice| twice > new_bytes)
332    {
333        return vec![NodeOp::Replace {
334            path: NodePath::new(),
335            node: new.clone(),
336        }];
337    }
338    ops
339}
340
341/// Applies ops in order (§4.7). The first op that does not resolve aborts the
342/// batch with [`PatchError`] — consumers then request a resync (§9.3).
343///
344/// # Errors
345///
346/// When a path, key, index or payload does not resolve against the tree.
347pub fn apply(old: &Node, ops: &[NodeOp]) -> Result<Node, PatchError> {
348    let mut root = old.clone();
349    for op in ops {
350        apply_op(&mut root, op)?;
351    }
352    Ok(root)
353}
354
355/// One collection member as the differ sees it: derived key (§4.1: explicit
356/// `key`, else the positional `#<index>`) plus the payload.
357#[allow(
358    clippy::large_enum_variant,
359    reason = "patch ops are built once per update; boxing every node adds noise"
360)]
361#[derive(Clone, Debug, PartialEq)]
362enum Member {
363    /// A node member (`children`, `panes`); recursed into, not serialized.
364    Node {
365        /// Derived member key.
366        key: String,
367        /// The subtree.
368        node: Node,
369    },
370    /// A plain item member, kept in its serialized form.
371    Item {
372        /// Derived member key.
373        key: String,
374        /// Serialized item.
375        json: Json,
376    },
377}
378
379impl Member {
380    /// The derived key. Keys starting with `'#'` are positional (§4.1) and
381    /// never anchor members across mutations.
382    fn key(&self) -> &str {
383        match self {
384            Self::Node { key, .. } | Self::Item { key, .. } => key,
385        }
386    }
387
388    /// The payload as a patch insert.
389    fn to_patch_item(&self) -> PatchItem {
390        match self {
391            Self::Node { node, .. } => PatchItem::Node(node.clone()),
392            Self::Item { json, .. } => PatchItem::Item(json.clone()),
393        }
394    }
395}
396
397/// Keys starting with `'#'` are the implicit positional form (§4.1); the differ
398/// treats every such key as positional and never matches it across positions.
399fn is_positional(key: &str) -> bool {
400    key.starts_with('#')
401}
402
403/// Derived key of the member at `index` (§4.1).
404fn derived_key(explicit: Option<&str>, index: usize) -> String {
405    match explicit {
406        Some(key) => key.to_owned(),
407        None => format!("#{index}"),
408    }
409}
410
411/// `path` extended by one member-key segment (§4.6).
412fn child_path(path: &NodePath, key: &str) -> NodePath {
413    let mut path = path.clone();
414    path.push(key.to_owned());
415    path
416}
417
418/// The node props that hold collections addressed by patch ops (§4.7); they
419/// are excluded from the shallow `set` prop diff and handled structurally.
420fn collection_fields(kind: &str) -> &'static [&'static str] {
421    match kind {
422        "Layout" => &["children"],
423        "Tabs" => &["titles", "panes"],
424        "List" => &["items"],
425        "Table" => &["rows"],
426        "Tree" => &["nodes"],
427        "Log" => &["lines"],
428        "Chart" => &["datasets"],
429        "Sparkline" | "BarChart" => &["data"],
430        _ => &[],
431    }
432}
433
434/// The collections a node kind owns, in op order.
435fn node_fields(node: &Node) -> &'static [PatchField] {
436    match node {
437        Node::Layout(_) => &[PatchField::Children],
438        Node::Tabs(_) => &[PatchField::Titles, PatchField::Panes],
439        Node::List(_) => &[PatchField::Items],
440        Node::Table(_) => &[PatchField::Rows],
441        Node::Tree(_) => &[PatchField::Nodes],
442        Node::Log(_) => &[PatchField::Lines],
443        Node::Chart(_) => &[PatchField::Datasets],
444        Node::Sparkline(_) | Node::BarChart(_) => &[PatchField::Data],
445        _ => &[],
446    }
447}
448
449/// Serializes a slice of members with a per-member explicit key extractor.
450fn seq_members<T: Serialize, F: Fn(&T) -> Option<String>>(
451    items: &[T],
452    explicit_key: F,
453) -> Vec<Member> {
454    items
455        .iter()
456        .enumerate()
457        .map(|(index, value)| Member::Item {
458            key: explicit_key(value).unwrap_or_else(|| format!("#{index}")),
459            json: serde_json::to_value(value).unwrap_or(Json::Null),
460        })
461        .collect()
462}
463
464/// The members of `field` on `node`, in collection order (panes in sorted key
465/// order; `None` panes and empty panes both yield no members).
466fn members(node: &Node, field: PatchField) -> Vec<Member> {
467    match (node, field) {
468        (Node::Layout(l), PatchField::Children) => l
469            .children
470            .iter()
471            .enumerate()
472            .map(|(index, child)| Member::Node {
473                key: derived_key(child.common().key, index),
474                node: child.clone(),
475            })
476            .collect(),
477        (Node::Tabs(t), PatchField::Panes) => t
478            .panes
479            .iter()
480            .flatten()
481            .map(|(key, pane)| Member::Node {
482                key: key.clone(),
483                node: pane.clone(),
484            })
485            .collect(),
486        (Node::Tabs(t), PatchField::Titles) => {
487            seq_members(&t.titles, |title| Some(title.key.clone()))
488        }
489        (Node::List(l), PatchField::Items) => seq_members(&l.items, |item| match item {
490            crate::widgets::list::ListItem::Full { key: Some(key), .. } => Some(key.clone()),
491            _ => None,
492        }),
493        (Node::Table(t), PatchField::Rows) => seq_members(&t.rows, |row| row.key.clone()),
494        (Node::Tree(t), PatchField::Nodes) => seq_members(&t.nodes, |n| Some(n.key.clone())),
495        (Node::Log(l), PatchField::Lines) => seq_members(&l.lines, |_| None),
496        (Node::Chart(c), PatchField::Datasets) => seq_members(&c.datasets, |_| None),
497        (Node::Sparkline(s), PatchField::Data) => seq_members(&s.data, |_| None),
498        (Node::BarChart(b), PatchField::Data) => seq_members(&b.data, |_| None),
499        _ => Vec::new(),
500    }
501}
502
503/// Rules 1–3 of §4.7 at one position, appending to `ops`.
504fn diff_node(old: &Node, new: &Node, path: &NodePath, ops: &mut Vec<NodeOp>) {
505    // Rule 1: different kind or key ⇒ replace.
506    if old.kind() != new.kind() || old.common().key != new.common().key {
507        ops.push(NodeOp::Replace {
508            path: path.clone(),
509            node: new.clone(),
510        });
511        return;
512    }
513    // Rule 2: one `set` with all changed scalar/object props.
514    diff_props(old, new, path, ops);
515    // Rule 3: collections matched by key.
516    for field in node_fields(new) {
517        diff_collection(old, new, *field, path, ops);
518    }
519}
520
521/// Rule 2: shallow JSON prop diff (collections and identity excluded).
522fn diff_props(old: &Node, new: &Node, path: &NodePath, ops: &mut Vec<NodeOp>) {
523    let old_json = serde_json::to_value(old).unwrap_or(Json::Null);
524    let new_json = serde_json::to_value(new).unwrap_or(Json::Null);
525    let (Some(old_obj), Some(new_obj)) = (old_json.as_object(), new_json.as_object()) else {
526        return;
527    };
528    let skip = collection_fields(new.kind());
529    let is_skipped = |key: &&str| *key == "t" || skip.contains(key);
530    let mut props = serde_json::Map::new();
531    for (key, value) in new_obj {
532        if is_skipped(&key.as_str()) {
533            continue;
534        }
535        if old_obj.get(key) != Some(value) {
536            props.insert(key.clone(), value.clone());
537        }
538    }
539    for key in old_obj
540        .keys()
541        .filter(|key| !new_obj.contains_key(key.as_str()))
542    {
543        if is_skipped(&key.as_str()) {
544            continue;
545        }
546        props.insert(key.clone(), Json::Null);
547    }
548    if !props.is_empty() {
549        ops.push(NodeOp::Set {
550            path: path.clone(),
551            props,
552        });
553    }
554}
555
556/// Rule 3 for one collection.
557fn diff_collection(
558    old: &Node,
559    new: &Node,
560    field: PatchField,
561    path: &NodePath,
562    ops: &mut Vec<NodeOp>,
563) {
564    let old_m = members(old, field);
565    let new_m = members(new, field);
566    // Key sequence unchanged ⇒ recurse/`item` per changed member.
567    if old_m.len() == new_m.len() && old_m.iter().zip(&new_m).all(|(o, n)| o.key() == n.key()) {
568        diff_stable(&old_m, &new_m, field, path, ops);
569        return;
570    }
571    // Tail append only (Log/List/Table, §4.7) ⇒ `append`.
572    if matches!(
573        field,
574        PatchField::Items | PatchField::Rows | PatchField::Lines
575    ) && new_m.len() > old_m.len()
576        && old_m.iter().zip(&new_m).all(|(o, n)| o == n)
577    {
578        ops.push(NodeOp::Append {
579            path: path.clone(),
580            field,
581            items: new_m[old_m.len()..]
582                .iter()
583                .map(Member::to_patch_item)
584                .collect(),
585        });
586        return;
587    }
588    // `panes` inserts need the pane key on the node (the protocol has no keyed
589    // insert op); anything else degenerates to replacing the whole `Tabs`.
590    if field == PatchField::Panes
591        && !new_m.iter().all(|member| match member {
592            Member::Node { key, node } => node.common().key == Some(key.as_str()),
593            Member::Item { .. } => false,
594        })
595    {
596        ops.push(NodeOp::Replace {
597            path: path.clone(),
598            node: new.clone(),
599        });
600        return;
601    }
602    // Otherwise: one `splice` per maximal contiguous changed run (LIS keeps
603    // stable members in place), then keyed ops for stable-but-changed members.
604    let kept = splice_diff(&old_m, &new_m, field, path, ops);
605    let stable = kept
606        .into_iter()
607        .map(|(new_index, old_index)| (&old_m[old_index], &new_m[new_index]));
608    for (o, n) in stable {
609        if o == n {
610            continue;
611        }
612        emit_stable_change(o, n, field, path, ops);
613    }
614}
615
616/// Emits the keyed op for one stable member that changed (recursion for node
617/// members, `item` for plain items). Mixed node/item pairs cannot occur within
618/// one collection.
619fn emit_stable_change(
620    o: &Member,
621    n: &Member,
622    field: PatchField,
623    path: &NodePath,
624    ops: &mut Vec<NodeOp>,
625) {
626    match (o, n) {
627        (Member::Node { node: old_node, .. }, Member::Node { key, node }) => {
628            diff_node(old_node, node, &child_path(path, key), ops);
629        }
630        (_, Member::Item { key, json }) => ops.push(NodeOp::Item {
631            path: path.clone(),
632            field,
633            key: key.clone(),
634            value: PatchItem::Item(json.clone()),
635        }),
636        (Member::Item { .. }, Member::Node { .. }) => {}
637    }
638}
639
640/// Members whose derived keys line up one-to-one (unchanged key sequence).
641fn diff_stable(
642    old_m: &[Member],
643    new_m: &[Member],
644    field: PatchField,
645    path: &NodePath,
646    ops: &mut Vec<NodeOp>,
647) {
648    for (o, n) in old_m.iter().zip(new_m) {
649        if o == n {
650            continue;
651        }
652        emit_stable_change(o, n, field, path, ops);
653    }
654}
655
656/// Emits one `splice` per maximal contiguous changed run, keeping LIS-stable
657/// members in place, and returns the kept `(new_index, old_index)` pairs.
658fn splice_diff(
659    old_m: &[Member],
660    new_m: &[Member],
661    field: PatchField,
662    path: &NodePath,
663    ops: &mut Vec<NodeOp>,
664) -> Vec<(usize, usize)> {
665    // Anchor members by explicit (non-positional) key.
666    let old_by_key: std::collections::HashMap<&str, usize> = old_m
667        .iter()
668        .enumerate()
669        .filter(|(_, m)| !is_positional(m.key()))
670        .map(|(index, m)| (m.key(), index))
671        .collect();
672    let pairs: Vec<(usize, usize)> = new_m
673        .iter()
674        .enumerate()
675        .filter(|(_, m)| !is_positional(m.key()))
676        .filter_map(|(new_index, m)| {
677            old_by_key
678                .get(m.key())
679                .map(|&old_index| (new_index, old_index))
680        })
681        .collect();
682    let kept = longest_increasing_chain(&pairs); // (new_index, old_index), both ascending
683    // One `splice` per maximal contiguous changed run. Each run's index is
684    // its start in old coordinates, adjusted by the net length change of the
685    // runs before it (they are emitted head-first, so by the time a run
686    // applies, every earlier run has already landed).
687    let mut gaps: Vec<(usize, usize, Vec<&Member>)> = Vec::new(); // (old start, removed, inserts)
688    let (mut n_cursor, mut o_cursor) = (0usize, 0usize);
689    for (new_index, old_index) in &kept {
690        if *new_index > n_cursor || *old_index > o_cursor {
691            gaps.push((
692                o_cursor,
693                old_index - o_cursor,
694                new_m[n_cursor..*new_index].iter().collect(),
695            ));
696        }
697        n_cursor = new_index + 1;
698        o_cursor = old_index + 1;
699    }
700    if new_m.len() > n_cursor || old_m.len() > o_cursor {
701        gaps.push((
702            o_cursor,
703            old_m.len() - o_cursor,
704            new_m[n_cursor..].iter().collect(),
705        ));
706    }
707    let mut delta: isize = 0; // net member count change of the emitted runs
708    for (start, removed, inserts) in &gaps {
709        if *removed == 0 && inserts.is_empty() {
710            continue;
711        }
712        let index = isize::try_from(*start).unwrap_or(0) + delta;
713        ops.push(NodeOp::Splice {
714            path: path.clone(),
715            field,
716            index: u32::try_from(index.max(0)).unwrap_or(u32::MAX),
717            remove: u32::try_from(*removed).unwrap_or(u32::MAX),
718            insert: inserts.iter().map(|m| m.to_patch_item()).collect(),
719        });
720        delta +=
721            isize::try_from(inserts.len()).unwrap_or(0) - isize::try_from(*removed).unwrap_or(0);
722    }
723    kept
724}
725
726/// Indices into `pairs` forming the longest strictly increasing chain by
727/// `.1` (pairs are ascending by `.0`; old indices are unique per collection).
728fn longest_increasing_chain(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
729    let mut tails: Vec<usize> = Vec::new(); // smallest chain-ending old index per length
730    let mut tail_pair: Vec<usize> = Vec::new(); // pair index achieving it
731    let mut prev = vec![usize::MAX; pairs.len()];
732    for (i, &(_, old_index)) in pairs.iter().enumerate() {
733        match tails.binary_search(&old_index) {
734            Ok(pos) => {
735                tails[pos] = old_index;
736                tail_pair[pos] = i;
737            }
738            Err(pos) => {
739                prev[i] = pos.checked_sub(1).map_or(usize::MAX, |q| tail_pair[q]);
740                if pos == tails.len() {
741                    tails.push(old_index);
742                    tail_pair.push(i);
743                } else {
744                    tails[pos] = old_index;
745                    tail_pair[pos] = i;
746                }
747            }
748        }
749    }
750    let mut chain = Vec::new();
751    let mut cursor = tail_pair.last().copied();
752    while let Some(i) = cursor {
753        chain.push(pairs[i]);
754        cursor = (prev[i] != usize::MAX).then_some(prev[i]);
755    }
756    chain.reverse();
757    chain
758}
759
760/// Resolves the mutable node at `path` (§4.6: member keys plus the fixed
761/// segments `"child"`, `"fallback"` and `"action"`).
762fn node_at<'a>(root: &'a mut Node, path: &NodePath) -> Result<&'a mut Node, PatchError> {
763    let mut node = root;
764    for (depth, segment) in path.iter().enumerate() {
765        let missing = || PatchError::MissingPath {
766            path: path[..=depth].to_vec(),
767        };
768        node = match segment.as_str() {
769            "child" => match node {
770                Node::Block(block) => block.child.as_deref_mut(),
771                _ => None,
772            }
773            .ok_or_else(missing)?,
774            "fallback" => fallback_slot(node).ok_or_else(missing)?,
775            "action" => match node {
776                Node::Empty(empty) => empty.action.as_deref_mut(),
777                _ => None,
778            }
779            .ok_or_else(missing)?,
780            key => descend_member(node, key).ok_or_else(missing)?,
781        };
782    }
783    Ok(node)
784}
785
786/// Mutable slot of the common `fallback` of any node kind.
787fn fallback_slot(node: &mut Node) -> Option<&mut Node> {
788    macro_rules! common_fallback {
789        ($($variant:ident),* $(,)?) => {
790            match node {
791                $(Node::$variant(inner) => inner.common.fallback.as_deref_mut(),)*
792                Node::Unknown(unknown) => unknown.fallback.as_deref_mut(),
793            }
794        };
795    }
796    common_fallback!(
797        Layout, Block, Paragraph, List, Table, Tabs, Gauge, LineGauge, Sparkline, BarChart, Chart,
798        Scrollbar, Markdown, Code, Diff, Image, Log, Tree, KeyHints, Input, Toggle, Select, Button,
799        Spinner, Empty
800    )
801}
802
803/// Descends into a keyed node member: a `Layout` child by derived key, or a
804/// `Tabs` pane by map key.
805fn descend_member<'a>(node: &'a mut Node, key: &str) -> Option<&'a mut Node> {
806    match node {
807        Node::Layout(layout) => {
808            let position = layout
809                .children
810                .iter()
811                .enumerate()
812                .position(|(index, child)| derived_key(child.common().key, index) == key)?;
813            layout.children.get_mut(position)
814        }
815        Node::Tabs(tabs) => tabs.panes.as_mut()?.get_mut(key),
816        _ => None,
817    }
818}
819
820fn apply_op(root: &mut Node, op: &NodeOp) -> Result<(), PatchError> {
821    match op {
822        NodeOp::Replace { path, node } => {
823            *node_at(root, path)? = node.clone();
824        }
825        NodeOp::Set { path, props } => set_props(node_at(root, path)?, path, props)?,
826        NodeOp::Splice {
827            path,
828            field,
829            index,
830            remove,
831            insert,
832        } => splice_at(node_at(root, path)?, path, *field, *index, *remove, insert)?,
833        NodeOp::Append { path, field, items } => {
834            append_at(node_at(root, path)?, path, *field, items)?;
835        }
836        NodeOp::Item {
837            path,
838            field,
839            key,
840            value,
841        } => replace_item(node_at(root, path)?, path, *field, key, value)?,
842    }
843    Ok(())
844}
845
846/// Shallow prop merge; `null` unsets (§4.7).
847fn set_props(
848    slot: &mut Node,
849    path: &NodePath,
850    props: &serde_json::Map<String, Json>,
851) -> Result<(), PatchError> {
852    let bad = |detail: String| PatchError::BadPatch {
853        path: path.clone(),
854        detail,
855    };
856    let mut json = serde_json::to_value(&*slot).map_err(|e| bad(e.to_string()))?;
857    let Some(obj) = json.as_object_mut() else {
858        return Err(bad("node is not a JSON object".into()));
859    };
860    for (key, value) in props {
861        if value.is_null() {
862            obj.remove(key);
863        } else {
864            obj.insert(key.clone(), value.clone());
865        }
866    }
867    *slot = serde_json::from_value(json).map_err(|e| bad(e.to_string()))?;
868    Ok(())
869}
870
871/// A plain-item payload: nodes are rejected (they only live in `children` and
872/// `panes`).
873fn plain_item(value: &PatchItem, field: PatchField, path: &NodePath) -> Result<Json, PatchError> {
874    match value {
875        PatchItem::Item(json) => Ok(json.clone()),
876        PatchItem::Node(_) => Err(PatchError::BadItem {
877            path: path.clone(),
878            field,
879            detail: "a node cannot be a plain item".into(),
880        }),
881    }
882}
883
884/// A node payload (for `children`/`panes`).
885fn member_node(value: &PatchItem, field: PatchField, path: &NodePath) -> Result<Node, PatchError> {
886    match value {
887        PatchItem::Node(node) => Ok(node.clone()),
888        PatchItem::Item(json) => {
889            serde_json::from_value(json.clone()).map_err(|e| PatchError::BadItem {
890                path: path.clone(),
891                field,
892                detail: e.to_string(),
893            })
894        }
895    }
896}
897
898/// A `panes` insert: the pane key comes from the inserted node's `key`.
899fn pane_insert(value: &PatchItem, path: &NodePath) -> Result<(Key, Node), PatchError> {
900    let node = member_node(value, PatchField::Panes, path)?;
901    let Some(key) = node.common().key.map(str::to_owned) else {
902        return Err(PatchError::MissingItemKey { path: path.clone() });
903    };
904    Ok((key, node))
905}
906
907/// Deserializes plain-item payloads into `T` and splices `items[start..end]`.
908fn splice_seq<T: serde::de::DeserializeOwned>(
909    items: &mut Vec<T>,
910    field: PatchField,
911    path: &NodePath,
912    start: usize,
913    end: usize,
914    insert: &[PatchItem],
915) -> Result<(), PatchError> {
916    if end > items.len() {
917        return Err(PatchError::OutOfBounds {
918            path: path.clone(),
919            field,
920            index: u32::try_from(start).unwrap_or(u32::MAX),
921            len: items.len(),
922        });
923    }
924    let inserted = insert
925        .iter()
926        .map(|value| plain_item(value, field, path))
927        .collect::<Result<Vec<_>, _>>()?
928        .into_iter()
929        .map(|json| serde_json::from_value::<T>(json))
930        .collect::<Result<Vec<_>, _>>()
931        .map_err(|e| PatchError::BadItem {
932            path: path.clone(),
933            field,
934            detail: e.to_string(),
935        })?;
936    items.splice(start..end, inserted);
937    Ok(())
938}
939
940fn splice_at(
941    slot: &mut Node,
942    path: &NodePath,
943    field: PatchField,
944    index: u32,
945    remove: u32,
946    insert: &[PatchItem],
947) -> Result<(), PatchError> {
948    let Some(end) = index.checked_add(remove) else {
949        return Err(PatchError::OutOfBounds {
950            path: path.clone(),
951            field,
952            index,
953            len: usize::MAX,
954        });
955    };
956    let (start, end) = (usize::try_from(index).unwrap_or(usize::MAX), end as usize);
957    match (slot, field) {
958        (Node::Layout(layout), PatchField::Children) => {
959            if end > layout.children.len() {
960                return Err(PatchError::OutOfBounds {
961                    path: path.clone(),
962                    field,
963                    index,
964                    len: layout.children.len(),
965                });
966            }
967            let inserted = insert
968                .iter()
969                .map(|value| member_node(value, field, path))
970                .collect::<Result<Vec<_>, _>>()?;
971            layout.children.splice(start..end, inserted);
972        }
973        (Node::Tabs(tabs), PatchField::Panes) => {
974            let map = tabs
975                .panes
976                .get_or_insert_with(std::collections::BTreeMap::new);
977            let mut entries: Vec<(Key, Node)> = map
978                .iter()
979                .map(|(key, pane)| (key.clone(), pane.clone()))
980                .collect();
981            if end > entries.len() {
982                return Err(PatchError::OutOfBounds {
983                    path: path.clone(),
984                    field,
985                    index,
986                    len: entries.len(),
987                });
988            }
989            let inserted = insert
990                .iter()
991                .map(|value| pane_insert(value, path))
992                .collect::<Result<Vec<_>, _>>()?;
993            entries.splice(start..end, inserted);
994            // `None` and an empty map are the same patch surface (§4.7): keep
995            // the canonical shape so `apply(old, ops) == new` holds exactly.
996            tabs.panes = if entries.is_empty() {
997                None
998            } else {
999                Some(entries.into_iter().collect())
1000            };
1001        }
1002        (Node::List(list), PatchField::Items) => {
1003            splice_seq(&mut list.items, field, path, start, end, insert)?;
1004        }
1005        (Node::Table(table), PatchField::Rows) => {
1006            splice_seq(&mut table.rows, field, path, start, end, insert)?;
1007        }
1008        (Node::Tree(tree), PatchField::Nodes) => {
1009            splice_seq(&mut tree.nodes, field, path, start, end, insert)?;
1010        }
1011        (Node::Log(log), PatchField::Lines) => {
1012            splice_seq(&mut log.lines, field, path, start, end, insert)?;
1013        }
1014        (Node::Tabs(tabs), PatchField::Titles) => {
1015            splice_seq(&mut tabs.titles, field, path, start, end, insert)?;
1016        }
1017        (Node::Chart(chart), PatchField::Datasets) => {
1018            splice_seq(&mut chart.datasets, field, path, start, end, insert)?;
1019        }
1020        (Node::Sparkline(sparkline), PatchField::Data) => {
1021            splice_seq(&mut sparkline.data, field, path, start, end, insert)?;
1022        }
1023        (Node::BarChart(bars), PatchField::Data) => {
1024            splice_seq(&mut bars.data, field, path, start, end, insert)?;
1025        }
1026        (node, _) => {
1027            return Err(PatchError::FieldMismatch {
1028                path: path.clone(),
1029                field,
1030                kind: node.kind().to_owned(),
1031            });
1032        }
1033    }
1034    Ok(())
1035}
1036
1037/// Tail append (§4.7: `Log.lines`, `List.items`, `Table.rows`). Ring-trimming
1038/// past the limit is the consumer's rendering concern (§4.8) — `apply` stores
1039/// everything so the reference invariant stays exact.
1040fn append_at(
1041    slot: &mut Node,
1042    path: &NodePath,
1043    field: PatchField,
1044    items: &[PatchItem],
1045) -> Result<(), PatchError> {
1046    let convert = |value: &PatchItem| plain_item(value, field, path);
1047    let jsons = items.iter().map(convert).collect::<Result<Vec<_>, _>>()?;
1048    match (slot, field) {
1049        (Node::List(list), PatchField::Items) => push_items(&mut list.items, jsons, path, field)?,
1050        (Node::Table(table), PatchField::Rows) => push_items(&mut table.rows, jsons, path, field)?,
1051        (Node::Log(log), PatchField::Lines) => push_items(&mut log.lines, jsons, path, field)?,
1052        (node, _) => {
1053            return Err(PatchError::FieldMismatch {
1054                path: path.clone(),
1055                field,
1056                kind: node.kind().to_owned(),
1057            });
1058        }
1059    }
1060    Ok(())
1061}
1062
1063fn push_items<T: serde::de::DeserializeOwned>(
1064    items: &mut Vec<T>,
1065    jsons: Vec<Json>,
1066    path: &NodePath,
1067    field: PatchField,
1068) -> Result<(), PatchError> {
1069    for json in jsons {
1070        let item = serde_json::from_value::<T>(json).map_err(|e| PatchError::BadItem {
1071            path: path.clone(),
1072            field,
1073            detail: e.to_string(),
1074        })?;
1075        items.push(item);
1076    }
1077    Ok(())
1078}
1079
1080/// Replaces one keyed member (key sequence unchanged, §4.7). Positional keys
1081/// `#<index>` resolve by index.
1082fn replace_item(
1083    slot: &mut Node,
1084    path: &NodePath,
1085    field: PatchField,
1086    key: &str,
1087    value: &PatchItem,
1088) -> Result<(), PatchError> {
1089    let missing = || PatchError::MissingKey {
1090        path: path.clone(),
1091        field,
1092        key: key.to_owned(),
1093    };
1094    match (slot, field) {
1095        (Node::Layout(layout), PatchField::Children) => {
1096            let position = layout
1097                .children
1098                .iter()
1099                .enumerate()
1100                .find_map(|(index, child)| {
1101                    (derived_key(child.common().key, index) == key).then_some(index)
1102                })
1103                .ok_or_else(missing)?;
1104            layout.children[position] = member_node(value, field, path)?;
1105        }
1106        (Node::Tabs(tabs), PatchField::Panes) => {
1107            let pane = tabs
1108                .panes
1109                .as_mut()
1110                .and_then(|m| m.get_mut(key))
1111                .ok_or_else(missing)?;
1112            *pane = member_node(value, field, path)?;
1113        }
1114        (Node::List(list), PatchField::Items) => replace_seq(
1115            &mut list.items,
1116            field,
1117            path,
1118            key,
1119            value,
1120            |item| match item {
1121                crate::widgets::list::ListItem::Full {
1122                    key: Some(item_key),
1123                    ..
1124                } => Some(item_key.as_str()),
1125                _ => None,
1126            },
1127        )?,
1128        (Node::Table(table), PatchField::Rows) => {
1129            replace_seq(&mut table.rows, field, path, key, value, |row| {
1130                row.key.as_deref()
1131            })?;
1132        }
1133        (Node::Tree(tree), PatchField::Nodes) => {
1134            replace_seq(&mut tree.nodes, field, path, key, value, |n| {
1135                Some(n.key.as_str())
1136            })?;
1137        }
1138        (Node::Tabs(tabs), PatchField::Titles) => {
1139            replace_seq(&mut tabs.titles, field, path, key, value, |title| {
1140                Some(title.key.as_str())
1141            })?;
1142        }
1143        (Node::Log(log), PatchField::Lines) => {
1144            replace_seq(&mut log.lines, field, path, key, value, |_| None)?;
1145        }
1146        (Node::Chart(chart), PatchField::Datasets) => {
1147            replace_seq(&mut chart.datasets, field, path, key, value, |_| None)?;
1148        }
1149        (Node::Sparkline(sparkline), PatchField::Data) => {
1150            replace_seq(&mut sparkline.data, field, path, key, value, |_| None)?;
1151        }
1152        (Node::BarChart(bars), PatchField::Data) => {
1153            replace_seq(&mut bars.data, field, path, key, value, |_| None)?;
1154        }
1155        (node, _) => {
1156            return Err(PatchError::FieldMismatch {
1157                path: path.clone(),
1158                field,
1159                kind: node.kind().to_owned(),
1160            });
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// Keyed replace on a plain sequence: match by the extractor's explicit key,
1167/// else fall back to the positional `#<index>` reading.
1168fn replace_seq<T: serde::de::DeserializeOwned, F: Fn(&T) -> Option<&str>>(
1169    items: &mut [T],
1170    field: PatchField,
1171    path: &NodePath,
1172    key: &str,
1173    value: &PatchItem,
1174    explicit_key: F,
1175) -> Result<(), PatchError> {
1176    let missing = || PatchError::MissingKey {
1177        path: path.clone(),
1178        field,
1179        key: key.to_owned(),
1180    };
1181    let position = items
1182        .iter()
1183        .enumerate()
1184        .find_map(|(index, item)| (explicit_key(item) == Some(key)).then_some(index))
1185        .or_else(|| {
1186            key.strip_prefix('#')
1187                .and_then(|index| index.parse::<usize>().ok())
1188                .filter(|&index| index < items.len())
1189        })
1190        .ok_or_else(missing)?;
1191    let json = plain_item(value, field, path)?;
1192    items[position] = serde_json::from_value(json).map_err(|e| PatchError::BadItem {
1193        path: path.clone(),
1194        field,
1195        detail: e.to_string(),
1196    })?;
1197    Ok(())
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    #![allow(
1203        clippy::unwrap_used,
1204        clippy::expect_used,
1205        clippy::panic,
1206        reason = "tests assert by unwrapping"
1207    )]
1208    use super::*;
1209    use crate::extras::log::Log;
1210
1211    #[test]
1212    fn ops_round_trip() {
1213        let ops = vec![
1214            NodeOp::Replace {
1215                path: NodePath::new(),
1216                node: Log::new(["a"]).into(),
1217            },
1218            NodeOp::Set {
1219                path: NodePath::new(),
1220                props: serde_json::Map::from_iter([("follow".to_owned(), Json::Bool(false))]),
1221            },
1222            NodeOp::Splice {
1223                path: vec!["main".to_owned()],
1224                field: PatchField::Items,
1225                index: 2,
1226                remove: 1,
1227                insert: vec![PatchItem::Item(Json::Null)],
1228            },
1229            NodeOp::Append {
1230                path: NodePath::new(),
1231                field: PatchField::Lines,
1232                items: vec![PatchItem::Item(Json::String("new".into()))],
1233            },
1234            NodeOp::Item {
1235                path: NodePath::new(),
1236                field: PatchField::Rows,
1237                key: "r1".into(),
1238                value: PatchItem::Item(Json::Null),
1239            },
1240        ];
1241        let json = serde_json::to_string(&ops).unwrap_or_default();
1242        let back: Vec<NodeOp> = serde_json::from_str(&json).unwrap_or_default();
1243        assert_eq!(ops, back);
1244    }
1245}