hypen_engine/reactive/
binding.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
5pub enum BindingSource {
6 State,
8 Item,
10 DataSource(String),
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct Binding {
18 pub source: BindingSource,
20 pub path: Vec<String>,
22}
23
24impl Binding {
25 pub fn new(source: BindingSource, path: Vec<String>) -> Self {
26 Self { source, path }
27 }
28
29 pub fn state(path: Vec<String>) -> Self {
31 Self::new(BindingSource::State, path)
32 }
33
34 pub fn item(path: Vec<String>) -> Self {
36 Self::new(BindingSource::Item, path)
37 }
38
39 pub fn data_source(provider: impl Into<String>, path: Vec<String>) -> Self {
41 Self::new(BindingSource::DataSource(provider.into()), path)
42 }
43
44 pub fn is_state(&self) -> bool {
46 matches!(self.source, BindingSource::State)
47 }
48
49 pub fn is_item(&self) -> bool {
51 matches!(self.source, BindingSource::Item)
52 }
53
54 pub fn is_data_source(&self) -> bool {
56 matches!(self.source, BindingSource::DataSource(_))
57 }
58
59 pub fn provider(&self) -> Option<&str> {
61 match &self.source {
62 BindingSource::DataSource(name) => Some(name.as_str()),
63 _ => None,
64 }
65 }
66
67 pub fn root_key(&self) -> Option<&str> {
69 self.path.first().map(|s| s.as_str())
70 }
71
72 pub fn full_path(&self) -> String {
74 self.path.join(".")
75 }
76
77 pub fn full_path_with_source(&self) -> String {
79 let prefix = match &self.source {
80 BindingSource::State => "state".to_string(),
81 BindingSource::Item => "item".to_string(),
82 BindingSource::DataSource(provider) => provider.clone(),
83 };
84 if self.path.is_empty() {
85 prefix
86 } else {
87 format!("{}.{}", prefix, self.path.join("."))
88 }
89 }
90}
91
92fn is_valid_path_segment(s: &str) -> bool {
97 if s.is_empty() {
98 return false;
99 }
100
101 if s.chars().all(|c| c.is_ascii_digit()) {
103 return true;
104 }
105
106 let mut chars = s.chars();
107 match chars.next() {
109 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
110 _ => return false,
111 }
112 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
114}
115
116pub fn parse_binding(s: &str) -> Option<Binding> {
124 let trimmed = s.trim();
125
126 if !trimmed.starts_with("${") || !trimmed.ends_with('}') {
128 return None;
129 }
130
131 let content = &trimmed[2..trimmed.len() - 1];
133
134 let (source, path_start) = if content.starts_with("state.") {
136 (BindingSource::State, "state.".len())
137 } else if content.starts_with("item.") {
138 (BindingSource::Item, "item.".len())
139 } else if content == "item" {
140 return Some(Binding::item(vec![]));
142 } else {
143 return None;
147 };
148
149 let path: Vec<String> = content[path_start..]
151 .split('.')
152 .map(|s| s.to_string())
153 .collect();
154
155 for segment in &path {
158 if !is_valid_path_segment(segment) {
159 return None;
160 }
161 }
162
163 if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
164 return None;
165 }
166
167 Some(Binding::new(source, path))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn test_parse_simple_state_binding() {
176 let binding = parse_binding("${state.user}").unwrap();
177 assert!(binding.is_state());
178 assert_eq!(binding.path, vec!["user"]);
179 assert_eq!(binding.root_key(), Some("user"));
180 }
181
182 #[test]
183 fn test_parse_nested_state_binding() {
184 let binding = parse_binding("${state.user.name}").unwrap();
185 assert!(binding.is_state());
186 assert_eq!(binding.path, vec!["user", "name"]);
187 assert_eq!(binding.root_key(), Some("user"));
188 assert_eq!(binding.full_path(), "user.name");
189 assert_eq!(binding.full_path_with_source(), "state.user.name");
190 }
191
192 #[test]
193 fn test_parse_simple_item_binding() {
194 let binding = parse_binding("${item.name}").unwrap();
195 assert!(binding.is_item());
196 assert_eq!(binding.path, vec!["name"]);
197 assert_eq!(binding.root_key(), Some("name"));
198 assert_eq!(binding.full_path(), "name");
199 assert_eq!(binding.full_path_with_source(), "item.name");
200 }
201
202 #[test]
203 fn test_parse_nested_item_binding() {
204 let binding = parse_binding("${item.user.profile.avatar}").unwrap();
205 assert!(binding.is_item());
206 assert_eq!(binding.path, vec!["user", "profile", "avatar"]);
207 assert_eq!(binding.full_path(), "user.profile.avatar");
208 }
209
210 #[test]
211 fn test_parse_bare_item_binding() {
212 let binding = parse_binding("${item}").unwrap();
213 assert!(binding.is_item());
214 assert_eq!(binding.path, Vec::<String>::new());
215 assert_eq!(binding.full_path(), "");
216 assert_eq!(binding.full_path_with_source(), "item");
217 }
218
219 #[test]
220 fn test_parse_invalid_binding() {
221 assert!(parse_binding("state.user").is_none());
222 assert!(parse_binding("${user}").is_none());
223 assert!(parse_binding("${state}").is_none());
224 }
225
226 #[test]
227 fn test_parse_binding_rejects_unknown_prefix() {
228 assert!(parse_binding("${spacetime.messages}").is_none());
232 assert!(parse_binding("${firebase.user.profile.name}").is_none());
233 assert!(parse_binding("${spacetime}").is_none());
234 assert!(parse_binding("${foo.bar}").is_none());
235 }
236
237 #[test]
238 fn test_data_source_binding_helpers() {
239 let binding = Binding::data_source("convex", vec!["tasks".to_string()]);
240 assert!(binding.is_data_source());
241 assert!(!binding.is_state());
242 assert!(!binding.is_item());
243 assert_eq!(binding.provider(), Some("convex"));
244 }
245}