Skip to main content

plushie_core/
spec.rs

1//! Payload specifications for events and commands.
2//!
3//! Describes the expected shape of an event's or command's payload
4//! so it can be validated at runtime. The same spec structure is
5//! used for both events and commands, mirroring Elixir's
6//! `Plushie.Event.BuiltinSpecs` system.
7//!
8//! # Spec shapes
9//!
10//! - [`PayloadSpec::None`] - no payload (e.g., click, reset)
11//! - [`PayloadSpec::Value`] - single typed value (e.g., slide -> f32)
12//! - [`PayloadSpec::Fields`] - named typed fields (e.g., drag -> {x, y})
13//!
14//! # Usage
15//!
16//! Widget renderers declare specs alongside their event/command
17//! definitions. The registry validates payloads against specs
18//! when converting messages to outgoing events.
19
20use serde_json::Value;
21
22/// The type of a single value in a payload.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ValueType {
25    /// JSON string
26    String,
27    /// JSON number (f64)
28    Float,
29    /// JSON integer
30    Integer,
31    /// JSON boolean
32    Bool,
33    /// Any JSON value (no type constraint)
34    Any,
35}
36
37impl ValueType {
38    /// Check if a JSON value matches this type.
39    pub fn matches(&self, value: &Value) -> bool {
40        match self {
41            Self::String => value.is_string(),
42            Self::Float => value.is_f64(),
43            Self::Integer => value.is_i64() || value.is_u64(),
44            Self::Bool => value.is_boolean(),
45            Self::Any => true,
46        }
47    }
48}
49
50/// Describes the expected shape of a message payload.
51#[derive(Debug, Clone)]
52pub enum PayloadSpec {
53    /// No payload expected (value should be null).
54    None,
55    /// A single typed value.
56    Value(ValueType),
57    /// Named fields with types. Some fields may be required.
58    Fields {
59        /// Fields.
60        fields: Vec<(String, ValueType)>,
61        /// Whether input is required.
62        required: Vec<String>,
63    },
64}
65
66impl PayloadSpec {
67    /// Validate a JSON value against this spec.
68    ///
69    /// Returns true if the value conforms to the expected shape.
70    pub fn validate(&self, value: &Value) -> bool {
71        match self {
72            Self::None => value.is_null(),
73            Self::Value(vt) => vt.matches(value),
74            Self::Fields { fields, required } => {
75                let obj = match value.as_object() {
76                    Some(o) => o,
77                    None => return false,
78                };
79
80                // All required fields must be present
81                let has_required = required.iter().all(|r| obj.contains_key(r));
82
83                // Present fields must have correct types
84                let types_ok = fields
85                    .iter()
86                    .all(|(name, vt)| obj.get(name).map(|v| vt.matches(v)).unwrap_or(true));
87
88                has_required && types_ok
89            }
90        }
91    }
92}
93
94/// Spec for a widget event.
95#[derive(Debug, Clone)]
96pub struct EventSpec {
97    /// Event family name (e.g., "slide", "click", "change").
98    pub family: String,
99    /// Expected payload shape.
100    pub payload: PayloadSpec,
101}
102
103/// Spec for a widget command.
104#[derive(Debug, Clone)]
105pub struct CommandSpec {
106    /// Command family name (e.g., "set_value", "reset").
107    pub family: String,
108    /// Expected payload shape.
109    pub payload: PayloadSpec,
110}
111
112/// Trait for types that can encode widget commands for the wire.
113///
114/// Mirrors [`crate::types::WidgetEventEncode`] for commands.
115/// Typically derived via `#[derive(WidgetCommand)]`.
116pub trait WidgetCommandEncode {
117    /// Encode this command to its wire representation.
118    ///
119    /// Returns `(family, value)`.
120    fn to_wire(&self) -> (&'static str, crate::protocol::PropValue);
121
122    /// Return the specs for all command variants.
123    fn command_specs() -> Vec<CommandSpec>
124    where
125        Self: Sized;
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use serde_json::json;
132
133    #[test]
134    fn none_spec_validates_null() {
135        assert!(PayloadSpec::None.validate(&Value::Null));
136        assert!(!PayloadSpec::None.validate(&json!("hello")));
137        assert!(!PayloadSpec::None.validate(&json!(42)));
138    }
139
140    #[test]
141    fn value_spec_validates_type() {
142        let spec = PayloadSpec::Value(ValueType::Float);
143        assert!(spec.validate(&json!(2.5)));
144        assert!(!spec.validate(&json!("hello")));
145        assert!(!spec.validate(&Value::Null));
146
147        let spec = PayloadSpec::Value(ValueType::String);
148        assert!(spec.validate(&json!("hello")));
149        assert!(!spec.validate(&json!(42)));
150
151        let spec = PayloadSpec::Value(ValueType::Bool);
152        assert!(spec.validate(&json!(true)));
153        assert!(!spec.validate(&json!("true")));
154
155        let spec = PayloadSpec::Value(ValueType::Any);
156        assert!(spec.validate(&json!(42)));
157        assert!(spec.validate(&json!("hello")));
158        assert!(spec.validate(&Value::Null));
159    }
160
161    #[test]
162    fn fields_spec_validates_structure() {
163        let spec = PayloadSpec::Fields {
164            fields: vec![
165                ("x".into(), ValueType::Float),
166                ("y".into(), ValueType::Float),
167            ],
168            required: vec!["x".into(), "y".into()],
169        };
170
171        assert!(spec.validate(&json!({"x": 1.0, "y": 2.0})));
172        assert!(spec.validate(&json!({"x": 1.0, "y": 2.0, "extra": true})));
173        assert!(!spec.validate(&json!({"x": 1.0})));
174        assert!(!spec.validate(&json!({"x": "wrong", "y": 2.0})));
175        assert!(!spec.validate(&json!("not an object")));
176    }
177
178    #[test]
179    fn fields_spec_optional_fields() {
180        let spec = PayloadSpec::Fields {
181            fields: vec![
182                ("x".into(), ValueType::Float),
183                ("label".into(), ValueType::String),
184            ],
185            required: vec!["x".into()],
186        };
187
188        assert!(spec.validate(&json!({"x": 1.0})));
189        assert!(spec.validate(&json!({"x": 1.0, "label": "hello"})));
190        assert!(!spec.validate(&json!({"label": "hello"})));
191    }
192
193    #[test]
194    fn integer_type_matches_both_signed_and_unsigned() {
195        let spec = PayloadSpec::Value(ValueType::Integer);
196        assert!(spec.validate(&json!(42)));
197        assert!(spec.validate(&json!(-5)));
198        assert!(!spec.validate(&json!(2.5)));
199        assert!(!spec.validate(&json!("42")));
200    }
201}