hypen_engine/
state.rs

1//! Efficient state management using path-based change notifications
2//!
3//! The engine does NOT store state values - the host (JS/TS/Rust) does.
4//! The engine only tracks WHICH paths changed to determine what to re-render.
5
6use indexmap::IndexSet;
7use serde_json::Value;
8
9/// Represents a state change notification - just the paths that changed
10#[derive(Debug, Clone)]
11pub struct StateChange {
12    /// Paths that changed (e.g., ["user.name", "count"])
13    pub changed_paths: IndexSet<String>,
14}
15
16impl StateChange {
17    pub fn new() -> Self {
18        Self {
19            changed_paths: IndexSet::new(),
20        }
21    }
22
23    /// Add a changed path
24    pub fn add_path(&mut self, path: impl Into<String>) {
25        self.changed_paths.insert(path.into());
26    }
27
28    /// Create from a JSON patch object
29    /// Extracts all paths from the patch
30    pub fn from_json(patch: &Value) -> Self {
31        let mut change = Self::new();
32        extract_paths("", patch, &mut change.changed_paths);
33        change
34    }
35
36    /// Create from a list of path strings
37    pub fn from_paths(paths: impl IntoIterator<Item = String>) -> Self {
38        Self {
39            changed_paths: paths.into_iter().collect(),
40        }
41    }
42
43    /// Get all changed paths
44    pub fn paths(&self) -> impl Iterator<Item = &str> {
45        self.changed_paths.iter().map(|s| s.as_str())
46    }
47
48    /// Check if a specific path changed
49    pub fn contains(&self, path: &str) -> bool {
50        self.changed_paths.contains(path)
51    }
52
53    /// Check if any path with this prefix changed
54    /// E.g., "user" would match "user", "user.name", "user.profile.bio"
55    pub fn has_prefix(&self, prefix: &str) -> bool {
56        self.changed_paths.iter().any(|p| {
57            p == prefix || p.starts_with(&format!("{}.", prefix))
58        })
59    }
60}
61
62impl Default for StateChange {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// Extract all paths from a JSON object
69fn extract_paths(prefix: &str, value: &Value, paths: &mut IndexSet<String>) {
70    match value {
71        Value::Object(map) => {
72            // Add the object path itself
73            if !prefix.is_empty() {
74                paths.insert(prefix.to_string());
75            }
76
77            // Recurse into children
78            for (key, val) in map {
79                let path = if prefix.is_empty() {
80                    key.clone()
81                } else {
82                    format!("{}.{}", prefix, key)
83                };
84                extract_paths(&path, val, paths);
85            }
86        }
87        _ => {
88            // Leaf value
89            if !prefix.is_empty() {
90                paths.insert(prefix.to_string());
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use serde_json::json;
100
101    #[test]
102    fn test_extract_paths() {
103        let value = json!({
104            "user": {
105                "name": "Alice",
106                "age": 30
107            },
108            "count": 42
109        });
110
111        let change = StateChange::from_json(&value);
112
113        assert!(change.contains("user"));
114        assert!(change.contains("user.name"));
115        assert!(change.contains("user.age"));
116        assert!(change.contains("count"));
117    }
118
119    #[test]
120    fn test_has_prefix() {
121        let mut change = StateChange::new();
122        change.add_path("user.name");
123        change.add_path("user.profile.bio");
124        change.add_path("count");
125
126        // "user" prefix matches both user.name and user.profile.bio
127        assert!(change.has_prefix("user"));
128
129        // "user.profile" matches user.profile.bio
130        assert!(change.has_prefix("user.profile"));
131
132        // "count" matches count
133        assert!(change.has_prefix("count"));
134
135        // "settings" doesn't match anything
136        assert!(!change.has_prefix("settings"));
137    }
138
139    #[test]
140    fn test_state_change_from_paths() {
141        let paths = vec!["user.name".to_string(), "count".to_string()];
142        let change = StateChange::from_paths(paths);
143
144        assert_eq!(change.changed_paths.len(), 2);
145        assert!(change.contains("user.name"));
146        assert!(change.contains("count"));
147    }
148}