Skip to main content

rosace_widgets/template/
diff.rs

1//! Template diffing (D103 / D102 Tier 1 — rollout step 4): decide whether an
2//! edited template can be HOT-SWAPPED as data, or must ESCALATE to a rebuild.
3//!
4//! This is the gate the whole universal-reload story rests on. The running
5//! binary produces, each frame, a fixed hole array `[h0, h1, …]` whose TYPES are
6//! frozen at compile time. A swap re-inflates a NEW descriptor with those SAME
7//! compiled holes — so it is only safe if every hole still feeds a slot of the
8//! same type. [`diff`] enforces exactly that.
9//!
10//! # The safety rule (the locked per-slot check — see `.steering/HOT_RELOAD.md`)
11//! A hole's "type site" is the `(widget kind, prop name)` it fills — that pair
12//! determines the setter, hence the type the compiled value must be. A swap is
13//! safe only when, for EVERY hole index, that site is unchanged between the
14//! running template and the edited one. Then:
15//! - static-only edits (retext / restyle / wrap / add-remove static elements)
16//!   → [`TemplateDiff::Swappable`];
17//! - a hole added/removed → count changed → [`EscalationReason::HoleCountChanged`];
18//! - a hole retargeted to a different `(widget, prop)` → its compiled type may
19//!   no longer fit → [`EscalationReason::HoleSlotRetargeted`].
20//!
21//! `hole_count` alone is NOT enough (a String slot could become an f32 slot at
22//! the same count) — the per-slot site comparison is what makes this sound.
23//!
24//! # Known limitation (positional binding)
25//! Because holes bind by INDEX, this guarantees TYPE safety, not value identity
26//! across a reorder of two SAME-typed holes (e.g. swapping two `Button`
27//! `on_press` slots): no crash, but the values could bind to the swapped
28//! position until the next real recompile. Name-based hole binding (deferred,
29//! see D125) removes that caveat.
30
31use std::collections::BTreeMap;
32
33use super::{PropValue, Template, TemplateNode};
34
35/// The verdict for one edited `view!` site.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum TemplateDiff {
38    /// Byte-identical shape — nothing to do.
39    Unchanged,
40    /// Shape changed but every hole's type site is preserved: re-inflate the
41    /// new descriptor with the running binary's current holes.
42    Swappable,
43    /// The change touched compiled logic — escalate to Tier 2 (dylib swap) or
44    /// Tier 0 (restart). Never inflate.
45    Escalate(EscalationReason),
46}
47
48/// Why a diff cannot be hot-swapped as data.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EscalationReason {
51    /// Diffing two different `view!` sites — a caller bug, not a real edit.
52    KeyMismatch,
53    /// A hole was added or removed (new/removed compiled `{expr}`).
54    HoleCountChanged { old: usize, new: usize },
55    /// Hole `index` now feeds a different `(widget, prop)` than the running
56    /// binary compiled it for — its type may not match. The per-slot guard.
57    HoleSlotRetargeted { index: usize },
58}
59
60/// Diff the currently-running template (`old`) against an edited one (`new`).
61pub fn diff(old: &Template, new: &Template) -> TemplateDiff {
62    if old.key != new.key {
63        return TemplateDiff::Escalate(EscalationReason::KeyMismatch);
64    }
65    if old.root == new.root {
66        return TemplateDiff::Unchanged;
67    }
68    if old.hole_count != new.hole_count {
69        return TemplateDiff::Escalate(EscalationReason::HoleCountChanged {
70            old: old.hole_count,
71            new: new.hole_count,
72        });
73    }
74
75    // Per-slot type-site check: every hole must still feed the same
76    // (widget kind, prop name) so the compiled value's type still fits.
77    let old_slots = hole_slots(old);
78    let new_slots = hole_slots(new);
79    for (index, old_site) in &old_slots {
80        if new_slots.get(index) != Some(old_site) {
81            return TemplateDiff::Escalate(EscalationReason::HoleSlotRetargeted { index: *index });
82        }
83    }
84
85    TemplateDiff::Swappable
86}
87
88/// Map each hole index → the `(widget kind, prop name)` it feeds. Walk order
89/// matches the macro's hole-indexing (props before children), but the map is
90/// keyed by the recorded index, so ordering never matters for the comparison.
91fn hole_slots(t: &Template) -> BTreeMap<usize, (String, String)> {
92    let mut slots = BTreeMap::new();
93    collect_slots(&t.root, &mut slots);
94    slots
95}
96
97fn collect_slots(node: &TemplateNode, slots: &mut BTreeMap<usize, (String, String)>) {
98    // Positional constructor-arg holes: type-site keyed by position.
99    for (pos, value) in node.args.iter().enumerate() {
100        if let PropValue::Hole(i) = value {
101            slots.insert(*i, (node.widget.clone(), format!("$arg{pos}")));
102        }
103    }
104    for (prop, value) in &node.props {
105        if let PropValue::Hole(i) = value {
106            slots.insert(*i, (node.widget.clone(), prop.clone()));
107        }
108    }
109    for child in &node.children {
110        collect_slots(child, slots);
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::template::{StaticValue, TemplateKey, TemplateNode};
118
119    fn key() -> TemplateKey {
120        TemplateKey::new("src/app.rs", 10, 5)
121    }
122    fn t(root: TemplateNode) -> Template {
123        Template::new(key(), root)
124    }
125
126    #[test]
127    fn identical_templates_are_unchanged() {
128        let a = t(TemplateNode::new("Column").with_static("spacing", StaticValue::Float(8.0)));
129        let b = t(TemplateNode::new("Column").with_static("spacing", StaticValue::Float(8.0)));
130        assert_eq!(diff(&a, &b), TemplateDiff::Unchanged);
131    }
132
133    #[test]
134    fn changing_a_static_literal_is_swappable() {
135        // retext: a pure data edit, no holes touched.
136        let a = t(TemplateNode::new("Text").with_static("content", StaticValue::Str("Save".into())));
137        let b = t(TemplateNode::new("Text").with_static("content", StaticValue::Str("Store".into())));
138        assert_eq!(diff(&a, &b), TemplateDiff::Swappable);
139    }
140
141    #[test]
142    fn wrapping_in_a_static_container_preserves_hole_sites_and_is_swappable() {
143        // Hole 0 = (Text, content) in both, just nested deeper.
144        let a = t(TemplateNode::new("Column").with_child(TemplateNode::new("Text").with_hole("content", 0)));
145        let b = t(TemplateNode::new("Column")
146            .with_child(TemplateNode::new("Container").with_child(TemplateNode::new("Text").with_hole("content", 0))));
147        assert_eq!(diff(&a, &b), TemplateDiff::Swappable);
148    }
149
150    #[test]
151    fn adding_a_hole_escalates_on_count() {
152        let a = t(TemplateNode::new("Column").with_hole("spacing", 0));
153        let b = t(TemplateNode::new("Column")
154            .with_hole("spacing", 0)
155            .with_child(TemplateNode::new("Text").with_hole("content", 1)));
156        assert_eq!(
157            diff(&a, &b),
158            TemplateDiff::Escalate(EscalationReason::HoleCountChanged { old: 1, new: 2 })
159        );
160    }
161
162    #[test]
163    fn removing_a_hole_escalates_on_count() {
164        let a = t(TemplateNode::new("Column").with_hole("spacing", 0).with_hole("cross", 1));
165        let b = t(TemplateNode::new("Column").with_hole("spacing", 0));
166        assert_eq!(
167            diff(&a, &b),
168            TemplateDiff::Escalate(EscalationReason::HoleCountChanged { old: 2, new: 1 })
169        );
170    }
171
172    #[test]
173    fn retargeting_a_hole_to_a_different_prop_escalates_even_at_same_count() {
174        // Hole 0 moves from Column.spacing (f32) to Text.content (String):
175        // the compiled value's type would no longer fit → must escalate.
176        let a = t(TemplateNode::new("Row")
177            .with_child(TemplateNode::new("Column").with_hole("spacing", 0))
178            .with_child(TemplateNode::new("Text").with_static("content", StaticValue::Str("x".into()))));
179        let b = t(TemplateNode::new("Row")
180            .with_child(TemplateNode::new("Column").with_static("spacing", StaticValue::Float(5.0)))
181            .with_child(TemplateNode::new("Text").with_hole("content", 0)));
182        assert_eq!(
183            diff(&a, &b),
184            TemplateDiff::Escalate(EscalationReason::HoleSlotRetargeted { index: 0 })
185        );
186    }
187
188    #[test]
189    fn same_count_same_sites_but_reordered_static_neighbours_is_swappable() {
190        // Adding a static sibling before the held Text shifts nothing about
191        // hole 0's site (still Text.content) → swappable.
192        let a = t(TemplateNode::new("Column").with_child(TemplateNode::new("Text").with_hole("content", 0)));
193        let b = t(TemplateNode::new("Column")
194            .with_child(TemplateNode::new("Text").with_static("content", StaticValue::Str("header".into())))
195            .with_child(TemplateNode::new("Text").with_hole("content", 0)));
196        assert_eq!(diff(&a, &b), TemplateDiff::Swappable);
197    }
198
199    #[test]
200    fn different_site_keys_are_a_caller_bug() {
201        let a = Template::new(TemplateKey::new("src/a.rs", 1, 1), TemplateNode::new("Column"));
202        let b = Template::new(TemplateKey::new("src/b.rs", 2, 2), TemplateNode::new("Column"));
203        assert_eq!(diff(&a, &b), TemplateDiff::Escalate(EscalationReason::KeyMismatch));
204    }
205}