Skip to main content

blockworx/document/
change.rs

1// The caller is autosave, which is native-only until the web gets a writer of
2// its own (todo.md P5). Building this for wasm regardless is what proves it
3// stayed portable rather than quietly acquiring a native-only dependency.
4#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
5
6//! What one edit touched, named so the name still means the same thing later.
7//!
8//! This is what fills a history entry's `changed` list, which is the whole
9//! reason the history is searchable: "when did this route change?" becomes a
10//! grep over small sidecars rather than a reconstruct-and-diff over every
11//! snapshot.
12//!
13//! **Only blocks and pins carry stable ids.** Routes, texts, comments and images
14//! are id-less on disk — the schema mints their ids on load — so a positional
15//! `r7` would name a different route after a reload, which is exactly the
16//! instability content-derived asset ids removed. So the comparison runs on the
17//! session-local ids (cheap and exact, both documents being in memory) while the
18//! *reporting* uses names that survive a reload: a route by its endpoints, and
19//! an annotation by the block holding it.
20
21use std::collections::BTreeSet;
22
23use super::model::Document;
24use super::{AutoRoute, Block, LineAnchor};
25use crate::store::{PinId, RectId};
26
27/// A pin, named by the block that owns it.
28///
29/// Deliberately *absolute*, unlike the on-disk anchor it is built from: there,
30/// a route's own port is written `p1` because the `route` node sits inside its
31/// owner's `block` node, so the owner is implied. A change set has no enclosing
32/// context, and the one spelling has to work everywhere in it — which also means
33/// a search for `b0:p1` finds both that pin's own edits and every route touching
34/// it, rather than only one of the two.
35#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
36pub struct PinRef {
37    pub block: RectId,
38    pub pin: PinId,
39}
40
41impl PinRef {
42    /// Resolve an anchor against the block owning the route carrying it. A
43    /// route's own port *is* a pin of that block.
44    fn resolve(owner: RectId, anchor: LineAnchor) -> Self {
45        match anchor {
46            LineAnchor::Port(pin) => PinRef { block: owner, pin },
47            LineAnchor::Pin { block, pin } => PinRef { block, pin },
48        }
49    }
50}
51
52impl std::fmt::Display for PinRef {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}:{}", self.block, self.pin)
55    }
56}
57
58/// One thing an edit touched.
59#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
60pub enum Change {
61    /// The document's own properties — its name, or which block is the top.
62    Document,
63    /// A block: added, removed, or changed in itself. "In itself" includes its
64    /// annotations (texts, comments, images, icon), which have no stable name of
65    /// their own, so the block is the finest thing that can be reported.
66    Block(RectId),
67    /// One of a block's pins.
68    Pin(PinRef),
69    /// A route, named by its two endpoints. The owner is not part of the name:
70    /// once both endpoints are absolute it adds nothing, since a route lives in
71    /// the block containing both of them. A route whose endpoints moved reports
72    /// under both its old and new names, so a search for either finds the edit.
73    Route { from: PinRef, to: PinRef },
74}
75
76impl std::fmt::Display for Change {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Change::Document => write!(f, "document"),
80            Change::Block(id) => write!(f, "{id}"),
81            Change::Pin(pin) => write!(f, "{pin}"),
82            Change::Route { from, to } => write!(f, "{from}->{to}"),
83        }
84    }
85}
86
87/// Everything one edit touched, in a stable order so two runs over the same
88/// pair of documents produce the same list.
89#[derive(Clone, PartialEq, Eq, Debug, Default)]
90pub struct ChangeSet(BTreeSet<Change>);
91
92impl ChangeSet {
93    pub fn is_empty(&self) -> bool {
94        self.0.is_empty()
95    }
96
97    /// The names a history sidecar records.
98    pub fn names(&self) -> Vec<String> {
99        self.0.iter().map(Change::to_string).collect()
100    }
101
102    fn insert(&mut self, change: Change) {
103        self.0.insert(change);
104    }
105}
106
107/// What changed between two versions of a document.
108///
109/// Empty means they are equivalent, which is the same judgement `PartialEq`
110/// makes — a settled edit that changed nothing produces no history entry.
111pub fn changed(prev: &Document, next: &Document) -> ChangeSet {
112    let mut set = ChangeSet::default();
113    if document_itself_differs(prev, next) {
114        set.insert(Change::Document);
115    }
116    for id in prev.blocks.keys().chain(next.blocks.keys()).copied() {
117        match (prev.blocks.get(&id), next.blocks.get(&id)) {
118            // Added or removed: the block itself is the change, and its pins and
119            // routes came or went with it rather than being separate edits.
120            (None, Some(_)) | (Some(_), None) => set.insert(Change::Block(id)),
121            (Some(before), Some(after)) => compare_block(id, before, after, &mut set),
122            (None, None) => unreachable!("the id came from one of the two maps"),
123        }
124    }
125    set
126}
127
128/// Whether the document's own properties differ.
129///
130/// Destructured rather than field-accessed: a field added to [`Document`] is
131/// then a compile error here instead of a change that silently stops being
132/// reported.
133fn document_itself_differs(prev: &Document, next: &Document) -> bool {
134    let Document {
135        name,
136        top_id,
137        blocks: _,
138    } = prev;
139    name != &next.name || top_id != &next.top_id
140}
141
142fn compare_block(id: RectId, before: &Block, after: &Block, set: &mut ChangeSet) {
143    if block_itself_differs(before, after) {
144        set.insert(Change::Block(id));
145    }
146    for pin in before.pins.keys().chain(after.pins.keys()).copied() {
147        if before.pins.get(&pin) != after.pins.get(&pin) {
148            set.insert(Change::Pin(PinRef { block: id, pin }));
149        }
150    }
151    for route in before.routes.keys().chain(after.routes.keys()) {
152        let (was, now) = (before.routes.get(route), after.routes.get(route));
153        if was == now {
154            continue;
155        }
156        // Both names when the endpoints moved: searching for either the old or
157        // the new one should find the edit that moved it.
158        for r in [was, now].into_iter().flatten() {
159            set.insert(route_change(id, r));
160        }
161    }
162}
163
164fn route_change(owner: RectId, route: &AutoRoute) -> Change {
165    Change::Route {
166        from: PinRef::resolve(owner, route.start()),
167        to: PinRef::resolve(owner, route.finish()),
168    }
169}
170
171/// Whether the block's own properties differ — everything except its pins and
172/// routes, which are reported at their own granularity.
173///
174/// Destructured for the same reason as [`document_itself_differs`]: a new field
175/// on [`Block`] must not quietly become invisible to the history.
176fn block_itself_differs(before: &Block, after: &Block) -> bool {
177    let Block {
178        inner,
179        decorations,
180        pins: _,
181        children,
182        routes: _,
183        texts,
184        comments,
185        images,
186        icon,
187        role,
188        locked,
189    } = before;
190    inner != &after.inner
191        || decorations != &after.decorations
192        || children != &after.children
193        || texts != &after.texts
194        || comments != &after.comments
195        || images != &after.images
196        || icon != &after.icon
197        || role != &after.role
198        || locked != &after.locked
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::document::schema_convert::{load, to_kdl};
205    use crate::presentation::store::IdMapExt as _;
206    use crate::store::{RectId, RouteId};
207
208    /// Two blocks wired to a third's ports, so there are pins and routes to
209    /// report separately from the blocks holding them.
210    const SRC: &str = r#"
211top "b0"
212block "b0" x=0 y=0 w=40 h=30 {
213    pin "p1" "in" loc="w1"
214    pin "p2" "out" loc="e1"
215    route "p1" "b1:p1"
216    route "p2" "b2:p1"
217    children "b1" "b2"
218}
219block "b1" x=5 y=5 w=8 h=8 { pin "p1" "a" loc="e1" }
220block "b2" x=5 y=20 w=8 h=8 { pin "p1" "b" loc="e1" }
221"#;
222
223    fn doc() -> Document {
224        load(SRC, "t.kdl").expect("the fixture parses")
225    }
226
227    fn b(n: usize) -> RectId {
228        RectId::nth_default(n)
229    }
230
231    fn names(prev: &Document, next: &Document) -> Vec<String> {
232        changed(prev, next).names()
233    }
234
235    #[test]
236    fn an_unchanged_document_reports_nothing() {
237        let d = doc();
238        assert!(changed(&d, &d.clone()).is_empty());
239    }
240
241    #[test]
242    fn moving_a_block_reports_only_that_block() {
243        let before = doc();
244        let mut after = before.clone();
245        let block = after.blocks.get_mut(&b(1)).expect("b1");
246        block.inner.min.x += 3;
247        assert_eq!(names(&before, &after), vec!["b1"]);
248    }
249
250    /// A pin is reported at its own granularity, not as a change to the block
251    /// carrying it — "when did this pin get renamed?" is a question the history
252    /// should be able to answer.
253    #[test]
254    fn renaming_a_pin_reports_the_pin_not_the_block() {
255        let before = doc();
256        let mut after = before.clone();
257        let pin = *after.blocks[&b(1)].pins.keys().next().expect("a pin");
258        after
259            .blocks
260            .get_mut(&b(1))
261            .expect("b1")
262            .pins
263            .get_mut(&pin)
264            .expect("the pin")
265            .name = "renamed".to_string();
266        assert_eq!(names(&before, &after), vec![format!("b1:{pin}")]);
267    }
268
269    /// A block arriving or leaving is one change, not one per pin and route it
270    /// happened to carry.
271    #[test]
272    fn adding_and_removing_a_block_reports_the_block_alone() {
273        let before = doc();
274        let mut after = before.clone();
275        // What a real delete does: drop the block *and* unlink it from its
276        // parent, so the parent's child list is a change of its own.
277        after.blocks.shift_remove(&b(2));
278        after
279            .blocks
280            .get_mut(&b(0))
281            .expect("b0")
282            .children
283            .shift_remove(&b(2));
284        assert_eq!(names(&before, &after), vec!["b0", "b2"]);
285
286        let mut grown = before.clone();
287        let added = grown.blocks.insert_value(Block::default());
288        assert_eq!(names(&before, &grown), vec![added.to_string()]);
289    }
290
291    /// Annotations carry no stable name of their own, so the block holding them
292    /// is the finest thing that can be reported.
293    #[test]
294    fn an_annotation_reports_the_block_holding_it() {
295        use crate::document::TextBox;
296        let before = doc();
297        let mut after = before.clone();
298        after
299            .blocks
300            .get_mut(&b(1))
301            .expect("b1")
302            .texts
303            .insert_value(TextBox {
304                text: "a note".to_string(),
305                anchor: crate::document::GridPos { x: 1, y: 1 },
306                role: None,
307            });
308        assert_eq!(names(&before, &after), vec!["b1"]);
309    }
310
311    #[test]
312    fn renaming_the_document_reports_the_document() {
313        let before = doc();
314        let mut after = before.clone();
315        after.name = Some("CT Scanner".to_string());
316        assert_eq!(names(&before, &after), vec!["document"]);
317    }
318
319    /// A route is named by the block owning it and by its endpoints — never by
320    /// its id, which is minted on load.
321    #[test]
322    fn a_route_is_named_by_its_endpoints() {
323        let before = doc();
324        let mut after = before.clone();
325        let route = *after.blocks[&b(0)].routes.keys().next().expect("a route");
326        after
327            .blocks
328            .get_mut(&b(0))
329            .expect("b0")
330            .routes
331            .get_mut(&route)
332            .expect("the route")
333            .set_role(Some(3));
334
335        let reported = names(&before, &after);
336        assert_eq!(reported, vec!["b0:p1->b1:p1"]);
337        assert!(
338            !reported.iter().any(|n| n.contains(&route.to_string())),
339            "the route's session-local id leaked into {reported:?}"
340        );
341    }
342
343    /// Re-pointing a route reports both names, so a search for either the old or
344    /// the new endpoint finds the edit that moved it.
345    #[test]
346    fn a_repointed_route_reports_both_names() {
347        let before = doc();
348        let mut after = before.clone();
349        let route = *after.blocks[&b(0)].routes.keys().next().expect("a route");
350        let elsewhere = LineAnchor::Pin {
351            block: b(2),
352            pin: crate::store::PinId::nth_default(1),
353        };
354        after
355            .blocks
356            .get_mut(&b(0))
357            .expect("b0")
358            .routes
359            .get_mut(&route)
360            .expect("the route")
361            .finish = elsewhere;
362
363        assert_eq!(
364            names(&before, &after),
365            vec!["b0:p1->b1:p1", "b0:p1->b2:p1"],
366            "both the old and the new endpoint should be findable"
367        );
368    }
369
370    /// The property the endpoint naming exists for. Route ids are minted on
371    /// load, so they shift when an earlier route is removed — a positional name
372    /// would mean a *different* route after a reload. The name must not.
373    #[test]
374    fn a_route_name_survives_a_reload_that_shifts_its_id() {
375        let full = doc();
376        let mut trimmed = full.clone();
377        let first = *trimmed.blocks[&b(0)].routes.keys().next().expect("a route");
378        trimmed
379            .blocks
380            .get_mut(&b(0))
381            .expect("b0")
382            .routes
383            .shift_remove(&first);
384
385        let reloaded = load(&to_kdl(&trimmed), "t.kdl").expect("the round trip parses");
386
387        let ids =
388            |d: &Document| -> Vec<RouteId> { d.blocks[&b(0)].routes.keys().copied().collect() };
389        // Proves the precondition: without it this test would pass vacuously.
390        assert_ne!(
391            ids(&trimmed),
392            ids(&reloaded),
393            "the surviving route kept its id, so nothing is being tested"
394        );
395
396        let recolor = |d: &Document| {
397            let mut next = d.clone();
398            let r = *next.blocks[&b(0)].routes.keys().next().expect("a route");
399            next.blocks
400                .get_mut(&b(0))
401                .expect("b0")
402                .routes
403                .get_mut(&r)
404                .expect("the route")
405                .set_role(Some(5));
406            next
407        };
408
409        assert_eq!(
410            names(&trimmed, &recolor(&trimmed)),
411            names(&reloaded, &recolor(&reloaded)),
412            "the same route reported under different names either side of a reload"
413        );
414    }
415
416    /// The reason a route's endpoints are absolute rather than owner-relative
417    /// (`b0:p1`, not the on-disk `p1`): one pin has one spelling, so a single
418    /// search finds both the pin's own edits and every route touching it.
419    #[test]
420    fn a_pin_is_spelled_the_same_way_in_a_route_name_as_on_its_own() {
421        let before = doc();
422
423        // The pin's own edit.
424        let mut renamed = before.clone();
425        let pin = *renamed.blocks[&b(0)].pins.keys().next().expect("a pin");
426        renamed
427            .blocks
428            .get_mut(&b(0))
429            .expect("b0")
430            .pins
431            .get_mut(&pin)
432            .expect("the pin")
433            .name = "renamed".to_string();
434        let pin_name = names(&before, &renamed);
435
436        // An edit to a route that lands on that same pin. It is a `Port` anchor
437        // there — the form the on-disk document writes bare as `p1`.
438        let mut recolored = before.clone();
439        let route = *recolored.blocks[&b(0)]
440            .routes
441            .keys()
442            .next()
443            .expect("a route");
444        assert_eq!(
445            recolored.blocks[&b(0)].routes[&route].start(),
446            LineAnchor::Port(pin),
447            "the fixture's first route should start at that port"
448        );
449        recolored
450            .blocks
451            .get_mut(&b(0))
452            .expect("b0")
453            .routes
454            .get_mut(&route)
455            .expect("the route")
456            .set_role(Some(3));
457        let route_name = names(&before, &recolored);
458
459        let token = &pin_name[0];
460        assert_eq!(token, &format!("b0:{pin}"));
461        assert!(
462            route_name[0].starts_with(&format!("{token}->")),
463            "searching for {token:?} would miss the route: {route_name:?}"
464        );
465    }
466}