Skip to main content

hypen_engine/wasm/
ffi.rs

1//! Shared FFI utilities for WASM bindings
2//!
3//! This module contains helpers and types used by both JS and WASI bindings.
4
5use serde::{Deserialize, Serialize};
6
7/// Extract changed paths from a state patch JSON value
8pub fn extract_changed_paths(patch: &serde_json::Value) -> Vec<String> {
9    let mut paths = Vec::new();
10    extract_paths_recursive(patch, String::new(), &mut paths);
11    paths
12}
13
14fn extract_paths_recursive(value: &serde_json::Value, prefix: String, paths: &mut Vec<String>) {
15    match value {
16        serde_json::Value::Object(map) => {
17            for (key, val) in map {
18                let path = if prefix.is_empty() {
19                    key.clone()
20                } else {
21                    format!("{}.{}", prefix, key)
22                };
23                paths.push(path.clone());
24                extract_paths_recursive(val, path, paths);
25            }
26        }
27        // Leaf values: path was already added by parent when iterating object keys
28        // Only arrays at root level would reach here without a prefix
29        _ => {}
30    }
31}
32
33/// Result type for FFI operations that can be serialized
34#[derive(Debug, Serialize, Deserialize)]
35#[serde(tag = "status")]
36pub enum FfiResult<T> {
37    #[serde(rename = "ok")]
38    Ok { value: T },
39    #[serde(rename = "error")]
40    Error { message: String },
41}
42
43impl<T> From<Result<T, String>> for FfiResult<T> {
44    fn from(result: Result<T, String>) -> Self {
45        match result {
46            Ok(value) => FfiResult::Ok { value },
47            Err(message) => FfiResult::Error { message },
48        }
49    }
50}
51
52/// Module configuration for initialization
53#[derive(Debug, Serialize, Deserialize)]
54pub struct ModuleConfig {
55    pub name: String,
56    pub actions: Vec<String>,
57    pub state_keys: Vec<String>,
58    pub initial_state: serde_json::Value,
59}
60
61/// Component resolution result
62#[derive(Debug, Serialize, Deserialize)]
63pub struct ResolvedComponent {
64    pub source: String,
65    pub path: String,
66    #[serde(default)]
67    pub passthrough: bool,
68    #[serde(default)]
69    pub lazy: bool,
70}
71
72/// Action payload for dispatching
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ActionPayload {
75    pub name: String,
76    #[serde(default)]
77    pub payload: serde_json::Value,
78}
79
80/// Sparse state update request
81#[derive(Debug, Serialize, Deserialize)]
82pub struct SparseStateUpdate {
83    pub paths: Vec<String>,
84    pub values: serde_json::Value,
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use serde_json::json;
91
92    // =========================================================================
93    // Path Extraction Tests
94    // =========================================================================
95
96    #[test]
97    fn test_extract_changed_paths_nested() {
98        let patch = json!({
99            "user": {
100                "name": "Alice",
101                "age": 30
102            }
103        });
104
105        let paths = extract_changed_paths(&patch);
106        assert!(paths.contains(&"user".to_string()));
107        assert!(paths.contains(&"user.name".to_string()));
108        assert!(paths.contains(&"user.age".to_string()));
109    }
110
111    #[test]
112    fn test_extract_changed_paths_flat() {
113        let patch = json!({
114            "count": 42,
115            "name": "test"
116        });
117
118        let paths = extract_changed_paths(&patch);
119        assert!(paths.contains(&"count".to_string()));
120        assert!(paths.contains(&"name".to_string()));
121        assert_eq!(paths.len(), 2);
122    }
123
124    #[test]
125    fn test_extract_changed_paths_deeply_nested() {
126        let patch = json!({
127            "a": {
128                "b": {
129                    "c": {
130                        "d": "value"
131                    }
132                }
133            }
134        });
135
136        let paths = extract_changed_paths(&patch);
137        assert!(paths.contains(&"a".to_string()));
138        assert!(paths.contains(&"a.b".to_string()));
139        assert!(paths.contains(&"a.b.c".to_string()));
140        assert!(paths.contains(&"a.b.c.d".to_string()));
141    }
142
143    #[test]
144    fn test_extract_changed_paths_empty() {
145        let patch = json!({});
146        let paths = extract_changed_paths(&patch);
147        assert!(paths.is_empty());
148    }
149
150    #[test]
151    fn test_extract_changed_paths_primitive() {
152        let patch = json!(42);
153        let paths = extract_changed_paths(&patch);
154        assert!(paths.is_empty()); // Root primitive has no path
155    }
156
157    #[test]
158    fn test_extract_changed_paths_array() {
159        let patch = json!({
160            "items": [1, 2, 3]
161        });
162
163        let paths = extract_changed_paths(&patch);
164        assert!(paths.contains(&"items".to_string()));
165        // Arrays are treated as leaf values, not traversed
166        assert_eq!(paths.len(), 1);
167    }
168
169    #[test]
170    fn test_extract_changed_paths_mixed_types() {
171        let patch = json!({
172            "string_val": "hello",
173            "number_val": 42,
174            "bool_val": true,
175            "null_val": null,
176            "array_val": [1, 2],
177            "nested": {"key": "value"}
178        });
179
180        let paths = extract_changed_paths(&patch);
181        assert!(paths.contains(&"string_val".to_string()));
182        assert!(paths.contains(&"number_val".to_string()));
183        assert!(paths.contains(&"bool_val".to_string()));
184        assert!(paths.contains(&"null_val".to_string()));
185        assert!(paths.contains(&"array_val".to_string()));
186        assert!(paths.contains(&"nested".to_string()));
187        assert!(paths.contains(&"nested.key".to_string()));
188        assert_eq!(paths.len(), 7);
189    }
190
191    #[test]
192    fn test_extract_changed_paths_special_characters_in_keys() {
193        let patch = json!({
194            "key-with-dash": 1,
195            "key_with_underscore": 2,
196            "key.with.dots": 3
197        });
198
199        let paths = extract_changed_paths(&patch);
200        assert!(paths.contains(&"key-with-dash".to_string()));
201        assert!(paths.contains(&"key_with_underscore".to_string()));
202        assert!(paths.contains(&"key.with.dots".to_string()));
203    }
204
205    #[test]
206    fn test_extract_changed_paths_unicode_keys() {
207        let patch = json!({
208            "日本語": "value",
209            "emoji🎉": "party"
210        });
211
212        let paths = extract_changed_paths(&patch);
213        assert!(paths.contains(&"日本語".to_string()));
214        assert!(paths.contains(&"emoji🎉".to_string()));
215    }
216
217    #[test]
218    fn test_extract_changed_paths_numeric_string_keys() {
219        let patch = json!({
220            "0": "first",
221            "1": "second",
222            "100": "hundredth"
223        });
224
225        let paths = extract_changed_paths(&patch);
226        assert!(paths.contains(&"0".to_string()));
227        assert!(paths.contains(&"1".to_string()));
228        assert!(paths.contains(&"100".to_string()));
229    }
230
231    // =========================================================================
232    // FfiResult Tests
233    // =========================================================================
234
235    #[test]
236    fn test_ffi_result_ok_serialization() {
237        let ok_result: FfiResult<i32> = FfiResult::Ok { value: 42 };
238        let json = serde_json::to_string(&ok_result).unwrap();
239        assert!(json.contains("\"status\":\"ok\""));
240        assert!(json.contains("\"value\":42"));
241    }
242
243    #[test]
244    fn test_ffi_result_error_serialization() {
245        let err_result: FfiResult<i32> = FfiResult::Error {
246            message: "Something went wrong".to_string(),
247        };
248        let json = serde_json::to_string(&err_result).unwrap();
249        assert!(json.contains("\"status\":\"error\""));
250        assert!(json.contains("Something went wrong"));
251    }
252
253    #[test]
254    fn test_ffi_result_from_result_ok() {
255        let result: Result<String, String> = Ok("success".to_string());
256        let ffi_result: FfiResult<String> = result.into();
257        match ffi_result {
258            FfiResult::Ok { value } => assert_eq!(value, "success"),
259            FfiResult::Error { .. } => panic!("Expected Ok"),
260        }
261    }
262
263    #[test]
264    fn test_ffi_result_from_result_err() {
265        let result: Result<String, String> = Err("failure".to_string());
266        let ffi_result: FfiResult<String> = result.into();
267        match ffi_result {
268            FfiResult::Ok { .. } => panic!("Expected Error"),
269            FfiResult::Error { message } => assert_eq!(message, "failure"),
270        }
271    }
272
273    #[test]
274    fn test_ffi_result_roundtrip() {
275        let original: FfiResult<Vec<i32>> = FfiResult::Ok { value: vec![1, 2, 3] };
276        let json = serde_json::to_string(&original).unwrap();
277        let parsed: FfiResult<Vec<i32>> = serde_json::from_str(&json).unwrap();
278        match parsed {
279            FfiResult::Ok { value } => assert_eq!(value, vec![1, 2, 3]),
280            FfiResult::Error { .. } => panic!("Expected Ok"),
281        }
282    }
283
284    #[test]
285    fn test_ffi_result_with_complex_value() {
286        #[derive(Debug, Serialize, Deserialize, PartialEq)]
287        struct ComplexData {
288            id: u32,
289            name: String,
290            tags: Vec<String>,
291        }
292
293        let data = ComplexData {
294            id: 123,
295            name: "test".to_string(),
296            tags: vec!["a".to_string(), "b".to_string()],
297        };
298
299        let result: FfiResult<ComplexData> = FfiResult::Ok { value: data };
300        let json = serde_json::to_string(&result).unwrap();
301        let parsed: FfiResult<ComplexData> = serde_json::from_str(&json).unwrap();
302
303        match parsed {
304            FfiResult::Ok { value } => {
305                assert_eq!(value.id, 123);
306                assert_eq!(value.name, "test");
307                assert_eq!(value.tags, vec!["a", "b"]);
308            }
309            FfiResult::Error { .. } => panic!("Expected Ok"),
310        }
311    }
312
313    #[test]
314    fn test_ffi_result_error_with_special_chars() {
315        let err: FfiResult<()> = FfiResult::Error {
316            message: "Error: \"quotes\" and 'apostrophes' and\nnewlines".to_string(),
317        };
318        let json = serde_json::to_string(&err).unwrap();
319        let parsed: FfiResult<()> = serde_json::from_str(&json).unwrap();
320
321        match parsed {
322            FfiResult::Error { message } => {
323                assert!(message.contains("quotes"));
324                assert!(message.contains("newlines"));
325            }
326            FfiResult::Ok { .. } => panic!("Expected Error"),
327        }
328    }
329
330    // =========================================================================
331    // ModuleConfig Tests
332    // =========================================================================
333
334    #[test]
335    fn test_module_config_serialization() {
336        let config = ModuleConfig {
337            name: "TestModule".to_string(),
338            actions: vec!["action1".to_string(), "action2".to_string()],
339            state_keys: vec!["count".to_string(), "name".to_string()],
340            initial_state: json!({"count": 0, "name": ""}),
341        };
342
343        let json = serde_json::to_string(&config).unwrap();
344        assert!(json.contains("\"name\":\"TestModule\""));
345        assert!(json.contains("\"actions\""));
346        assert!(json.contains("\"action1\""));
347    }
348
349    #[test]
350    fn test_module_config_deserialization() {
351        let json = r#"{
352            "name": "MyModule",
353            "actions": ["submit", "cancel"],
354            "state_keys": ["value"],
355            "initial_state": {"value": 100}
356        }"#;
357
358        let config: ModuleConfig = serde_json::from_str(json).unwrap();
359        assert_eq!(config.name, "MyModule");
360        assert_eq!(config.actions, vec!["submit", "cancel"]);
361        assert_eq!(config.state_keys, vec!["value"]);
362        assert_eq!(config.initial_state["value"], 100);
363    }
364
365    #[test]
366    fn test_module_config_empty_collections() {
367        let config = ModuleConfig {
368            name: "EmptyModule".to_string(),
369            actions: vec![],
370            state_keys: vec![],
371            initial_state: json!({}),
372        };
373
374        let json = serde_json::to_string(&config).unwrap();
375        let parsed: ModuleConfig = serde_json::from_str(&json).unwrap();
376
377        assert_eq!(parsed.name, "EmptyModule");
378        assert!(parsed.actions.is_empty());
379        assert!(parsed.state_keys.is_empty());
380        assert!(parsed.initial_state.is_object());
381    }
382
383    #[test]
384    fn test_module_config_complex_initial_state() {
385        let config = ModuleConfig {
386            name: "ComplexModule".to_string(),
387            actions: vec!["update".to_string()],
388            state_keys: vec!["user".to_string(), "items".to_string()],
389            initial_state: json!({
390                "user": {
391                    "id": null,
392                    "name": "",
393                    "settings": {
394                        "theme": "dark",
395                        "notifications": true
396                    }
397                },
398                "items": [],
399                "count": 0
400            }),
401        };
402
403        let json = serde_json::to_string(&config).unwrap();
404        let parsed: ModuleConfig = serde_json::from_str(&json).unwrap();
405
406        assert_eq!(parsed.initial_state["user"]["settings"]["theme"], "dark");
407        assert!(parsed.initial_state["user"]["id"].is_null());
408        assert!(parsed.initial_state["items"].is_array());
409    }
410
411    // =========================================================================
412    // ResolvedComponent Tests
413    // =========================================================================
414
415    #[test]
416    fn test_resolved_component_defaults() {
417        let json = r#"{
418            "source": "Text(\"Hello\")",
419            "path": "/components/Hello.hypen"
420        }"#;
421
422        let component: ResolvedComponent = serde_json::from_str(json).unwrap();
423        assert_eq!(component.source, "Text(\"Hello\")");
424        assert_eq!(component.path, "/components/Hello.hypen");
425        assert!(!component.passthrough); // Default false
426        assert!(!component.lazy); // Default false
427    }
428
429    #[test]
430    fn test_resolved_component_with_flags() {
431        let component = ResolvedComponent {
432            source: "".to_string(),
433            path: "/lazy/Component.hypen".to_string(),
434            passthrough: false,
435            lazy: true,
436        };
437
438        let json = serde_json::to_string(&component).unwrap();
439        assert!(json.contains("\"lazy\":true"));
440    }
441
442    #[test]
443    fn test_resolved_component_passthrough() {
444        let component = ResolvedComponent {
445            source: String::new(),
446            path: "/components/Router.hypen".to_string(),
447            passthrough: true,
448            lazy: false,
449        };
450
451        let json = serde_json::to_string(&component).unwrap();
452        let parsed: ResolvedComponent = serde_json::from_str(&json).unwrap();
453
454        assert!(parsed.passthrough);
455        assert!(!parsed.lazy);
456        assert!(parsed.source.is_empty());
457    }
458
459    #[test]
460    fn test_resolved_component_complex_source() {
461        let source = r#"Column {
462    Text("Header")
463    Row {
464        Button("Submit") { Text("OK") }
465        Button("Cancel") { Text("No") }
466    }
467}"#;
468        let component = ResolvedComponent {
469            source: source.to_string(),
470            path: "/components/Form.hypen".to_string(),
471            passthrough: false,
472            lazy: false,
473        };
474
475        let json = serde_json::to_string(&component).unwrap();
476        let parsed: ResolvedComponent = serde_json::from_str(&json).unwrap();
477
478        assert!(parsed.source.contains("Column"));
479        assert!(parsed.source.contains("Button"));
480    }
481
482    // =========================================================================
483    // ActionPayload Tests
484    // =========================================================================
485
486    #[test]
487    fn test_action_payload_minimal() {
488        let json = r#"{"name": "click"}"#;
489        let action: ActionPayload = serde_json::from_str(json).unwrap();
490        assert_eq!(action.name, "click");
491        assert!(action.payload.is_null()); // Default
492    }
493
494    #[test]
495    fn test_action_payload_with_data() {
496        let action = ActionPayload {
497            name: "submit".to_string(),
498            payload: json!({"form": {"email": "test@example.com"}}),
499        };
500
501        let json = serde_json::to_string(&action).unwrap();
502        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
503        assert_eq!(parsed.name, "submit");
504        assert_eq!(parsed.payload["form"]["email"], "test@example.com");
505    }
506
507    #[test]
508    fn test_action_payload_with_array() {
509        let action = ActionPayload {
510            name: "selectItems".to_string(),
511            payload: json!({
512                "ids": [1, 2, 3, 4, 5],
513                "selectAll": true
514            }),
515        };
516
517        let json = serde_json::to_string(&action).unwrap();
518        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
519
520        assert_eq!(parsed.payload["ids"].as_array().unwrap().len(), 5);
521        assert_eq!(parsed.payload["selectAll"], true);
522    }
523
524    #[test]
525    fn test_action_payload_with_primitive_payload() {
526        let action = ActionPayload {
527            name: "setCount".to_string(),
528            payload: json!(42),
529        };
530
531        let json = serde_json::to_string(&action).unwrap();
532        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
533
534        assert_eq!(parsed.payload, 42);
535    }
536
537    #[test]
538    fn test_action_payload_roundtrip() {
539        let original = ActionPayload {
540            name: "complexAction".to_string(),
541            payload: json!({
542                "nested": {
543                    "deeply": {
544                        "value": "found"
545                    }
546                }
547            }),
548        };
549
550        let json = serde_json::to_string(&original).unwrap();
551        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
552
553        assert_eq!(parsed.name, original.name);
554        assert_eq!(
555            parsed.payload["nested"]["deeply"]["value"],
556            original.payload["nested"]["deeply"]["value"]
557        );
558    }
559
560    // =========================================================================
561    // SparseStateUpdate Tests
562    // =========================================================================
563
564    #[test]
565    fn test_sparse_state_update() {
566        let update = SparseStateUpdate {
567            paths: vec!["user.name".to_string(), "count".to_string()],
568            values: json!({
569                "user.name": "Bob",
570                "count": 42
571            }),
572        };
573
574        let json = serde_json::to_string(&update).unwrap();
575        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
576        assert_eq!(parsed.paths.len(), 2);
577        assert!(parsed.paths.contains(&"user.name".to_string()));
578        assert_eq!(parsed.values["count"], 42);
579    }
580
581    #[test]
582    fn test_sparse_state_update_empty() {
583        let update = SparseStateUpdate {
584            paths: vec![],
585            values: json!({}),
586        };
587
588        let json = serde_json::to_string(&update).unwrap();
589        assert!(json.contains("\"paths\":[]"));
590    }
591
592    #[test]
593    fn test_sparse_state_update_deeply_nested_paths() {
594        let update = SparseStateUpdate {
595            paths: vec![
596                "a.b.c.d".to_string(),
597                "x.y.z".to_string(),
598            ],
599            values: json!({
600                "a.b.c.d": "deep value",
601                "x.y.z": 123
602            }),
603        };
604
605        let json = serde_json::to_string(&update).unwrap();
606        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
607
608        assert_eq!(parsed.paths.len(), 2);
609        assert_eq!(parsed.values["a.b.c.d"], "deep value");
610        assert_eq!(parsed.values["x.y.z"], 123);
611    }
612
613    #[test]
614    fn test_sparse_state_update_complex_values() {
615        let update = SparseStateUpdate {
616            paths: vec!["user".to_string(), "items".to_string()],
617            values: json!({
618                "user": {"id": 1, "name": "Alice"},
619                "items": [{"id": 1}, {"id": 2}]
620            }),
621        };
622
623        let json = serde_json::to_string(&update).unwrap();
624        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
625
626        assert_eq!(parsed.values["user"]["id"], 1);
627        assert_eq!(parsed.values["items"].as_array().unwrap().len(), 2);
628    }
629
630    // =========================================================================
631    // WASI FFI Pattern Tests
632    // These tests verify that our types work correctly in FFI scenarios
633    // =========================================================================
634
635    #[test]
636    fn test_json_bytes_roundtrip() {
637        // Simulates WASI FFI pattern: struct -> JSON bytes -> struct
638        let config = ModuleConfig {
639            name: "TestModule".to_string(),
640            actions: vec!["act1".to_string()],
641            state_keys: vec!["key1".to_string()],
642            initial_state: json!({"key1": "value"}),
643        };
644
645        // Convert to bytes (what we'd pass across FFI)
646        let json_bytes = serde_json::to_vec(&config).unwrap();
647
648        // Parse from bytes (what we'd receive on the other side)
649        let parsed: ModuleConfig = serde_json::from_slice(&json_bytes).unwrap();
650
651        assert_eq!(parsed.name, config.name);
652        assert_eq!(parsed.actions, config.actions);
653    }
654
655    #[test]
656    fn test_action_dispatch_pattern() {
657        // Simulates receiving an action from the UI and serializing for host
658        let action = ActionPayload {
659            name: "submitForm".to_string(),
660            payload: json!({
661                "formData": {
662                    "username": "alice",
663                    "remember": true
664                }
665            }),
666        };
667
668        // Serialize to JSON (what we'd return to host)
669        let json = serde_json::to_string(&action).unwrap();
670
671        // Host would parse this JSON
672        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
673
674        assert_eq!(parsed.name, "submitForm");
675        assert_eq!(parsed.payload["formData"]["username"], "alice");
676    }
677
678    #[test]
679    fn test_state_update_pattern() {
680        // Simulates sparse state update from host
681        let update_json = r#"{
682            "paths": ["counter", "user.lastActive"],
683            "values": {
684                "counter": 10,
685                "user.lastActive": "2024-01-01T00:00:00Z"
686            }
687        }"#;
688
689        let update: SparseStateUpdate = serde_json::from_str(update_json).unwrap();
690
691        // Extract paths that changed
692        assert!(update.paths.contains(&"counter".to_string()));
693        assert!(update.paths.contains(&"user.lastActive".to_string()));
694
695        // Get values
696        assert_eq!(update.values["counter"], 10);
697    }
698
699    #[test]
700    fn test_error_result_pattern() {
701        // Simulates returning an error from engine to host
702        let result: FfiResult<String> = FfiResult::Error {
703            message: "Parse error at line 5: unexpected token".to_string(),
704        };
705
706        let json = serde_json::to_string(&result).unwrap();
707
708        // Host checks status
709        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
710        assert_eq!(parsed["status"], "error");
711        assert!(parsed["message"].as_str().unwrap().contains("line 5"));
712    }
713
714    #[test]
715    fn test_patches_array_pattern() {
716        // Simulates returning patches as JSON array
717        use crate::reconcile::Patch;
718
719        let patches = vec![
720            Patch::Create {
721                id: "node1".to_string(),
722                element_type: "Text".to_string(),
723                props: indexmap::IndexMap::new(),
724            },
725            Patch::SetText {
726                id: "node1".to_string(),
727                text: "Hello World".to_string(),
728            },
729        ];
730
731        let json = serde_json::to_string(&patches).unwrap();
732        let parsed: Vec<Patch> = serde_json::from_str(&json).unwrap();
733
734        assert_eq!(parsed.len(), 2);
735        match &parsed[0] {
736            Patch::Create { element_type, .. } => assert_eq!(element_type, "Text"),
737            _ => panic!("Expected Create patch"),
738        }
739    }
740}