Skip to main content

zerodds_ast_core/
change.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Changes and changesets. Spec §5.2.
4
5use crate::id::NodeId;
6use alloc::string::String;
7use alloc::vec::Vec;
8
9/// Discriminator for a node kind.
10///
11/// Open-coded so that `zerodds-ast` owns its own kind space; this crate
12/// only compares and logs them. The numbers are part of the snapshot
13/// format — append only, never renumber. Spec §9.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub struct NodeKindTag(pub u16);
16
17/// Discriminator for a field within a node kind.
18///
19/// Typed, never a string: a misspelled field must be a compile error,
20/// not a silently missed branch in an observer's match.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
22pub struct FieldTag(pub u16);
23
24/// State of a reference, for changesets that record a resolution
25/// transition. Spec §2.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum RefState {
28    /// Points at the given node.
29    Resolved(NodeId),
30    /// Still awaiting resolution.
31    Pending,
32    /// Resolution failed.
33    Broken,
34}
35
36/// A field value as carried in an `Updated` change.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum FieldValue {
40    /// The field had no value.
41    Absent,
42    /// Boolean field.
43    Bool(bool),
44    /// Unsigned integral field.
45    U64(u64),
46    /// Signed integral field.
47    I64(i64),
48    /// Textual field.
49    Text(String),
50    /// Field holding another node's identity.
51    Node(NodeId),
52    /// Field holding a reference.
53    Reference(RefState),
54    /// Opaque field, for facet payloads with no simpler shape.
55    Bytes(Vec<u8>),
56}
57
58/// One committed model change.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Change {
61    /// A node came into existence.
62    Created {
63        /// The new node.
64        node: NodeId,
65        /// Its kind.
66        kind: NodeKindTag,
67    },
68    /// A field of an existing node changed.
69    Updated {
70        /// The node whose field changed.
71        node: NodeId,
72        /// Which field.
73        field: FieldTag,
74        /// Value before the change.
75        before: FieldValue,
76        /// Value after the change.
77        after: FieldValue,
78    },
79    /// A node ceased to exist.
80    Deleted {
81        /// The removed node.
82        node: NodeId,
83        /// Its kind.
84        kind: NodeKindTag,
85    },
86}
87
88impl Change {
89    /// The node this change concerns.
90    #[must_use]
91    pub const fn node(&self) -> NodeId {
92        match self {
93            Self::Created { node, .. }
94            | Self::Updated { node, .. }
95            | Self::Deleted { node, .. } => *node,
96        }
97    }
98
99    /// Whether this change created its node.
100    #[must_use]
101    pub const fn is_created(&self) -> bool {
102        matches!(self, Self::Created { .. })
103    }
104
105    /// Whether this change removed its node.
106    #[must_use]
107    pub const fn is_deleted(&self) -> bool {
108        matches!(self, Self::Deleted { .. })
109    }
110}
111
112/// The changes of one commit, in the order they were applied.
113///
114/// Replaying a changeset onto the model that preceded the commit
115/// reproduces the committed model exactly; that property is what lets
116/// observers such as the layout cache invalidate precisely instead of
117/// wholesale. Spec §5.2.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Changeset {
120    changes: Vec<Change>,
121    sequence: u64,
122}
123
124impl Changeset {
125    /// Empty changeset carrying the given commit sequence number.
126    #[must_use]
127    pub const fn new(sequence: u64) -> Self {
128        Self {
129            changes: Vec::new(),
130            sequence,
131        }
132    }
133
134    /// Monotonic commit sequence number.
135    #[must_use]
136    pub const fn sequence(&self) -> u64 {
137        self.sequence
138    }
139
140    /// Append a change.
141    pub fn push(&mut self, change: Change) {
142        self.changes.push(change);
143    }
144
145    /// Whether the commit changed nothing.
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.changes.is_empty()
149    }
150
151    /// Number of changes.
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.changes.len()
155    }
156
157    /// The changes in application order.
158    pub fn iter(&self) -> impl Iterator<Item = &Change> {
159        self.changes.iter()
160    }
161
162    /// Every node touched, in change order, once per change.
163    pub fn nodes_touched(&self) -> impl Iterator<Item = NodeId> + '_ {
164        self.changes.iter().map(Change::node)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    // Tests assert on Results directly; a failed expect IS the test failure.
171    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
172    use super::*;
173    use crate::id::IdSpace;
174    use alloc::string::ToString;
175    use alloc::vec;
176    use alloc::vec::Vec;
177
178    fn node(counter: u64) -> NodeId {
179        NodeId::from_parts(IdSpace::MODEL, counter)
180    }
181
182    #[test]
183    fn changeset_preserves_insertion_order() {
184        let mut cs = Changeset::new(1);
185        cs.push(Change::Created {
186            node: node(1),
187            kind: NodeKindTag(7),
188        });
189        cs.push(Change::Updated {
190            node: node(1),
191            field: FieldTag(2),
192            before: FieldValue::Absent,
193            after: FieldValue::Text("Pose".to_string()),
194        });
195        cs.push(Change::Deleted {
196            node: node(2),
197            kind: NodeKindTag(7),
198        });
199
200        let kinds: Vec<&'static str> = cs
201            .iter()
202            .map(|c| match c {
203                Change::Created { .. } => "created",
204                Change::Updated { .. } => "updated",
205                Change::Deleted { .. } => "deleted",
206            })
207            .collect();
208        assert_eq!(kinds, ["created", "updated", "deleted"]);
209        assert_eq!(cs.len(), 3);
210        assert_eq!(cs.sequence(), 1);
211    }
212
213    #[test]
214    fn nodes_touched_reports_every_affected_node_once_per_change() {
215        let mut cs = Changeset::new(4);
216        cs.push(Change::Created {
217            node: node(1),
218            kind: NodeKindTag(1),
219        });
220        cs.push(Change::Updated {
221            node: node(1),
222            field: FieldTag(0),
223            before: FieldValue::Bool(false),
224            after: FieldValue::Bool(true),
225        });
226        cs.push(Change::Deleted {
227            node: node(9),
228            kind: NodeKindTag(1),
229        });
230
231        let touched: Vec<NodeId> = cs.nodes_touched().collect();
232        assert_eq!(touched, vec![node(1), node(1), node(9)]);
233    }
234
235    #[test]
236    fn empty_changeset_is_reported_as_empty() {
237        let cs = Changeset::new(0);
238        assert!(cs.is_empty());
239        assert_eq!(cs.len(), 0);
240    }
241
242    #[test]
243    fn reference_transitions_are_representable_as_field_values() {
244        let before = FieldValue::Reference(RefState::Pending);
245        let after = FieldValue::Reference(RefState::Resolved(node(3)));
246        assert_ne!(before, after);
247    }
248}