Skip to main content

rosace_widgets/template/
reload.rs

1//! The reload runtime (D103 / D102 Tier 1 — rollout step 4, the watcher's core).
2//!
3//! Given an edited `.rs` file's path + source, [`apply_reload`] re-parses every
4//! `view!` in it, matches each to the running site (line + path-suffix — see
5//! [`registry::find_running`], tolerating the macro-vs-scanner key differences),
6//! diffs it, and applies safe swaps to the registry. It returns a
7//! [`ReloadReport`] the watcher uses to decide: keep running (all swapped),
8//! ignore (unparseable mid-edit), or escalate to a Tier 0 restart (a change
9//! touched compiled logic, or a brand-new `view!` site appeared).
10//!
11//! This is the whole in-process reload decision — pure over `(file, src)`, so
12//! it is fully testable without a window. Wiring it to a file watcher +
13//! repaint is the only remaining integration.
14
15use super::{apply_swap, parse_file_templates, registry, EscalationReason, SwapOutcome, Template};
16
17/// Outcome of reloading one edited file.
18#[derive(Debug, Clone, Default, PartialEq)]
19pub struct ReloadReport {
20    /// Sites hot-swapped (shape changed, safe).
21    pub applied: usize,
22    /// Sites whose shape did not change.
23    pub unchanged: usize,
24    /// `view!` sites with no matching running site — a new `view!` (new
25    /// compiled code) → needs a restart to take effect.
26    pub unknown: usize,
27    /// Sites that changed in a way needing compiled code (Tier 2/0).
28    pub escalations: Vec<EscalationReason>,
29    /// The file didn't parse (mid-edit) — do nothing, wait for the next save.
30    pub parse_error: Option<String>,
31}
32
33impl ReloadReport {
34    /// A safe data reload happened — repaint, don't restart.
35    pub fn hot_swapped(&self) -> bool {
36        self.applied > 0 && !self.needs_restart() && self.parse_error.is_none()
37    }
38    /// Something needs compiled code — the watcher should fall back to a Tier 0
39    /// rebuild + restart.
40    pub fn needs_restart(&self) -> bool {
41        !self.escalations.is_empty() || self.unknown > 0
42    }
43    /// The edit couldn't be parsed (likely mid-typing) — ignore it.
44    pub fn ignored(&self) -> bool {
45        self.parse_error.is_some()
46    }
47}
48
49/// Re-parse an edited file and apply safe swaps to the running app's registry.
50pub fn apply_reload(file: &str, src: &str) -> ReloadReport {
51    let mut report = ReloadReport::default();
52
53    let scanned = match parse_file_templates(src, file) {
54        Ok(v) => v,
55        Err(e) => {
56            report.parse_error = Some(e.to_string());
57            return report;
58        }
59    };
60
61    for site in scanned {
62        match registry::find_running(file, site.key.line) {
63            None => report.unknown += 1,
64            Some(running) => {
65                // Re-key the edited template to the running site's key so the
66                // diff/registry operate on the same entry.
67                let candidate = Template::new(running.key.clone(), site.root);
68                match apply_swap(candidate) {
69                    SwapOutcome::Applied => report.applied += 1,
70                    SwapOutcome::Unchanged => report.unchanged += 1,
71                    SwapOutcome::Escalate(reason) => report.escalations.push(reason),
72                    SwapOutcome::UnknownSite => report.unknown += 1,
73                }
74            }
75        }
76    }
77
78    report
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::template::{registry, PropValue, StaticValue, Template, TemplateKey, TemplateNode};
85
86    fn baseline(file: &str, line: u32, col: u32, spacing: PropValue) -> Template {
87        let mut root = TemplateNode::new("Column");
88        root.props.push(("spacing".into(), spacing));
89        Template::new(TemplateKey::new(file, line, col), root)
90    }
91
92    #[test]
93    fn safe_edit_swaps_despite_different_path_form_and_column() {
94        // Baseline as the macro would register it: package-relative file, a
95        // column! column. The view! sits on line 3.
96        registry::register(baseline(
97            "reload_pkg/src/app.rs",
98            3,
99            17,
100            PropValue::Static(StaticValue::Float(4.0)),
101        ));
102
103        // Edited file: ABSOLUTE path, and the scanner's own column — must still
104        // match by (line, path-suffix). view! is on line 3.
105        let src = "// l1\n// l2\nfn v() { let _ = view! { Column { spacing: 40.0 } }; }\n";
106        let report = apply_reload("/Users/x/reload_pkg/src/app.rs", src);
107
108        assert_eq!(report.applied, 1, "should hot-swap the matched site: {report:?}");
109        assert!(report.hot_swapped());
110        assert!(!report.needs_restart());
111        // Registry entry (keyed by the BASELINE key) now holds the edit.
112        let now = registry::get(&TemplateKey::new("reload_pkg/src/app.rs", 3, 17)).unwrap();
113        assert_eq!(now.root.props[0].1, PropValue::Static(StaticValue::Float(40.0)));
114    }
115
116    #[test]
117    fn a_logic_change_reports_needs_restart() {
118        registry::register(baseline(
119            "reload_pkg/src/b.rs",
120            1,
121            9,
122            PropValue::Static(StaticValue::Float(4.0)),
123        ));
124        // spacing becomes a hole → hole count 0→1 → escalation.
125        let src = "fn v() { let _ = view! { Column { spacing: g } }; }\n";
126        let report = apply_reload("reload_pkg/src/b.rs", src);
127        assert_eq!(report.applied, 0);
128        assert!(report.needs_restart(), "a new hole must force a restart: {report:?}");
129    }
130
131    #[test]
132    fn a_new_view_site_is_unknown_and_needs_restart() {
133        // Nothing registered for this file/line.
134        let src = "fn v() { let _ = view! { Row { } }; }\n";
135        let report = apply_reload("reload_pkg/src/brand_new.rs", src);
136        assert_eq!(report.unknown, 1);
137        assert!(report.needs_restart());
138    }
139
140    #[test]
141    fn an_unparseable_edit_is_ignored_not_restarted() {
142        let src = "fn v() { let _ = view! { Column { : : : } }; }\n";
143        let report = apply_reload("reload_pkg/src/c.rs", src);
144        assert!(report.ignored());
145        assert!(!report.needs_restart(), "mid-edit garbage should be ignored, not restarted");
146    }
147}