use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BindingSource {
State,
Item,
DataSource(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Binding {
pub source: BindingSource,
pub path: Vec<String>,
}
impl Binding {
pub fn new(source: BindingSource, path: Vec<String>) -> Self {
Self { source, path }
}
pub fn state(path: Vec<String>) -> Self {
Self::new(BindingSource::State, path)
}
pub fn item(path: Vec<String>) -> Self {
Self::new(BindingSource::Item, path)
}
pub fn data_source(provider: impl Into<String>, path: Vec<String>) -> Self {
Self::new(BindingSource::DataSource(provider.into()), path)
}
pub fn is_state(&self) -> bool {
matches!(self.source, BindingSource::State)
}
pub fn is_item(&self) -> bool {
matches!(self.source, BindingSource::Item)
}
pub fn is_data_source(&self) -> bool {
matches!(self.source, BindingSource::DataSource(_))
}
pub fn provider(&self) -> Option<&str> {
match &self.source {
BindingSource::DataSource(name) => Some(name.as_str()),
_ => None,
}
}
pub fn root_key(&self) -> Option<&str> {
self.path.first().map(|s| s.as_str())
}
pub fn full_path(&self) -> String {
self.path.join(".")
}
pub fn full_path_with_source(&self) -> String {
let prefix = match &self.source {
BindingSource::State => "state".to_string(),
BindingSource::Item => "item".to_string(),
BindingSource::DataSource(provider) => provider.clone(),
};
if self.path.is_empty() {
prefix
} else {
format!("{}.{}", prefix, self.path.join("."))
}
}
}
fn is_valid_path_segment(s: &str) -> bool {
if s.is_empty() {
return false;
}
if s.chars().all(|c| c.is_ascii_digit()) {
return true;
}
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn parse_binding(s: &str) -> Option<Binding> {
let trimmed = s.trim();
if !trimmed.starts_with("@{") || !trimmed.ends_with('}') {
return None;
}
let content = &trimmed[2..trimmed.len() - 1];
let (source, path_start) = if content.starts_with("state.") {
(BindingSource::State, "state.".len())
} else if content.starts_with("item.") {
(BindingSource::Item, "item.".len())
} else if content == "item" {
return Some(Binding::item(vec![]));
} else {
return None;
};
let path: Vec<String> = content[path_start..]
.split('.')
.map(|s| s.to_string())
.collect();
for segment in &path {
if !is_valid_path_segment(segment) {
return None;
}
}
if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
return None;
}
Some(Binding::new(source, path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_state_binding() {
let binding = parse_binding("@{state.user}").unwrap();
assert!(binding.is_state());
assert_eq!(binding.path, vec!["user"]);
assert_eq!(binding.root_key(), Some("user"));
}
#[test]
fn test_parse_nested_state_binding() {
let binding = parse_binding("@{state.user.name}").unwrap();
assert!(binding.is_state());
assert_eq!(binding.path, vec!["user", "name"]);
assert_eq!(binding.root_key(), Some("user"));
assert_eq!(binding.full_path(), "user.name");
assert_eq!(binding.full_path_with_source(), "state.user.name");
}
#[test]
fn test_parse_simple_item_binding() {
let binding = parse_binding("@{item.name}").unwrap();
assert!(binding.is_item());
assert_eq!(binding.path, vec!["name"]);
assert_eq!(binding.root_key(), Some("name"));
assert_eq!(binding.full_path(), "name");
assert_eq!(binding.full_path_with_source(), "item.name");
}
#[test]
fn test_parse_nested_item_binding() {
let binding = parse_binding("@{item.user.profile.avatar}").unwrap();
assert!(binding.is_item());
assert_eq!(binding.path, vec!["user", "profile", "avatar"]);
assert_eq!(binding.full_path(), "user.profile.avatar");
}
#[test]
fn test_parse_bare_item_binding() {
let binding = parse_binding("@{item}").unwrap();
assert!(binding.is_item());
assert_eq!(binding.path, Vec::<String>::new());
assert_eq!(binding.full_path(), "");
assert_eq!(binding.full_path_with_source(), "item");
}
#[test]
fn test_parse_invalid_binding() {
assert!(parse_binding("state.user").is_none());
assert!(parse_binding("@{user}").is_none());
assert!(parse_binding("@{state}").is_none());
}
#[test]
fn test_parse_binding_rejects_unknown_prefix() {
assert!(parse_binding("@{spacetime.messages}").is_none());
assert!(parse_binding("@{firebase.user.profile.name}").is_none());
assert!(parse_binding("@{spacetime}").is_none());
assert!(parse_binding("@{foo.bar}").is_none());
}
#[test]
fn test_data_source_binding_helpers() {
let binding = Binding::data_source("convex", vec!["tasks".to_string()]);
assert!(binding.is_data_source());
assert!(!binding.is_state());
assert!(!binding.is_item());
assert_eq!(binding.provider(), Some("convex"));
}
}