Skip to main content

hypen_server/
module.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6
7#[cfg(feature = "async")]
8use std::future::Future;
9#[cfg(feature = "async")]
10use std::pin::Pin;
11
12use crate::context::GlobalContext;
13use crate::error::{Result, SdkError};
14use crate::state::{State, StateContainer};
15
16/// A boxed, pinned, Send future — the return type of async handlers.
17#[cfg(feature = "async")]
18pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
19
20// ---------------------------------------------------------------------------
21// Callback signatures
22// ---------------------------------------------------------------------------
23
24/// Sync lifecycle callback: borrows state, may read global context.
25type SyncLifecycleFn<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
26
27/// Sync action handler: takes mutable state, optional raw JSON payload,
28/// and optional global context.
29type SyncActionFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
30
31/// Async lifecycle callback: takes owned state, returns owned state after `.await`.
32#[cfg(feature = "async")]
33type AsyncLifecycleFn<S> =
34    Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
35
36/// Async action handler: takes owned state, optional raw JSON payload,
37/// and optional global context. Returns owned state after `.await`.
38#[cfg(feature = "async")]
39type AsyncActionFn<S> =
40    Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
41
42/// Error handler: receives the error context and returns whether the error was handled.
43type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
44
45// One slot per concept (sync or async). The `Async` variant is cfg-gated so
46// the enum has just one arm when the feature is off; sync dispatch paths
47// only fire on `Sync`, async paths fire on either. This preserves the
48// original behavior where an async-only handler is invisible to sync callers.
49
50pub(crate) enum LifecycleHandler<S> {
51    Sync(SyncLifecycleFn<S>),
52    #[cfg(feature = "async")]
53    Async(AsyncLifecycleFn<S>),
54}
55
56pub(crate) enum ActionHandler<S> {
57    Sync(SyncActionFn<S>),
58    #[cfg(feature = "async")]
59    Async(AsyncActionFn<S>),
60}
61
62/// Context provided to error handlers.
63pub struct ErrorContext {
64    pub error: SdkError,
65    pub action_name: Option<String>,
66    pub lifecycle: Option<String>,
67}
68
69/// Result from an error handler.
70pub struct ErrorResult {
71    /// If true, the error is considered handled and won't propagate.
72    pub handled: bool,
73}
74
75/// Session information passed to disconnect/reconnect/expire handlers.
76pub use crate::remote::SessionInfo;
77
78/// Disconnect handler: fires when the last connection for a session drops.
79type DisconnectFn<S> = Box<dyn Fn(&S, &SessionInfo) + Send + Sync>;
80/// Reconnect handler: fires when a client resumes a suspended session.
81/// Receives the current state, session info, and the saved state snapshot.
82/// The handler should mutate `state` via the mutable ref if it wants to
83/// restore — by default the caller applies `saved_state` when the handler
84/// does not set `restored` to true.
85type ReconnectFn<S> = Box<dyn Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync>;
86/// Expire handler: fires when a suspended session's TTL elapses.
87type ExpireFn = Box<dyn Fn(&SessionInfo) + Send + Sync>;
88
89// ---------------------------------------------------------------------------
90// ModuleDefinition
91// ---------------------------------------------------------------------------
92
93/// An immutable, built module definition.
94///
95/// Created via `ModuleBuilder::build()`. Contains all state, handlers, and
96/// UI configuration needed to instantiate a running module.
97pub struct ModuleDefinition<S: State> {
98    pub(crate) name: String,
99    pub(crate) initial_state: S,
100    pub(crate) ui_source: Option<String>,
101    pub(crate) ui_file: Option<String>,
102    pub(crate) action_handlers: HashMap<String, ActionHandler<S>>,
103    pub(crate) on_created: Option<LifecycleHandler<S>>,
104    pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
105    #[allow(dead_code)]
106    pub(crate) on_error: Option<ErrorHandler>,
107    pub(crate) on_disconnect: Option<DisconnectFn<S>>,
108    pub(crate) on_reconnect: Option<ReconnectFn<S>>,
109    pub(crate) on_expire: Option<ExpireFn>,
110    pub(crate) persist: bool,
111    pub(crate) resource_map: indexmap::IndexMap<String, String>,
112}
113
114impl<S: State> ModuleDefinition<S> {
115    pub fn name(&self) -> &str {
116        &self.name
117    }
118
119    pub fn action_names(&self) -> Vec<String> {
120        self.action_handlers.keys().cloned().collect()
121    }
122
123    pub fn ui_source(&self) -> Option<&str> {
124        self.ui_source.as_deref()
125    }
126
127    pub fn is_persistent(&self) -> bool {
128        self.persist
129    }
130}
131
132// ---------------------------------------------------------------------------
133// ModuleBuilder (fluent API)
134// ---------------------------------------------------------------------------
135
136/// Fluent builder for constructing a `ModuleDefinition`.
137///
138/// # Example
139///
140/// ```rust,ignore
141/// use hypen_server::prelude::*;
142/// use serde::{Deserialize, Serialize};
143///
144/// #[derive(Clone, Default, Serialize, Deserialize)]
145/// struct Counter { count: i32 }
146///
147/// #[derive(Deserialize)]
148/// struct AddPayload { amount: i32 }
149///
150/// let module = ModuleBuilder::new("Counter")
151///     .state(Counter { count: 0 })
152///     .ui(r#"Column { Text("Count: @{state.count}") }"#)
153///     .on_action::<()>("increment", |state, _, _ctx| {
154///         state.count += 1;
155///     })
156///     .on_action::<AddPayload>("add", |state, payload, _ctx| {
157///         state.count += payload.amount;
158///     })
159///     .build();
160/// ```
161pub struct ModuleBuilder<S: State> {
162    name: String,
163    initial_state: Option<S>,
164    ui_source: Option<String>,
165    ui_file: Option<String>,
166    action_handlers: HashMap<String, ActionHandler<S>>,
167    on_created: Option<LifecycleHandler<S>>,
168    on_destroyed: Option<LifecycleHandler<S>>,
169    on_error: Option<ErrorHandler>,
170    on_disconnect: Option<DisconnectFn<S>>,
171    on_reconnect: Option<ReconnectFn<S>>,
172    on_expire: Option<ExpireFn>,
173    persist: bool,
174    resource_map: indexmap::IndexMap<String, String>,
175}
176
177impl<S: State> ModuleBuilder<S> {
178    pub fn new(name: impl Into<String>) -> Self {
179        Self {
180            name: name.into(),
181            initial_state: None,
182            ui_source: None,
183            ui_file: None,
184            action_handlers: HashMap::new(),
185            on_created: None,
186            on_destroyed: None,
187            on_error: None,
188            on_disconnect: None,
189            on_reconnect: None,
190            on_expire: None,
191            persist: false,
192            resource_map: indexmap::IndexMap::new(),
193        }
194    }
195
196    /// Set the initial state for this module.
197    pub fn state(mut self, initial: S) -> Self {
198        self.initial_state = Some(initial);
199        self
200    }
201
202    /// Set the Hypen DSL template as an inline string.
203    ///
204    /// ```rust,ignore
205    /// .ui(r#"
206    ///     Column {
207    ///         Text("Count: @{state.count}")
208    ///         Button("@actions.increment") { Text("+") }
209    ///     }
210    /// "#)
211    /// ```
212    pub fn ui(mut self, source: impl Into<String>) -> Self {
213        self.ui_source = Some(source.into());
214        self
215    }
216
217    /// Load the Hypen DSL template from a file path.
218    ///
219    /// The file will be read when the module is instantiated.
220    pub fn ui_file(mut self, path: impl Into<String>) -> Self {
221        self.ui_file = Some(path.into());
222        self
223    }
224
225    /// Register an action handler with a typed payload.
226    ///
227    /// The type parameter `A` determines how the action payload is
228    /// deserialized. Use `()` for actions that don't carry a payload.
229    ///
230    /// # Examples
231    ///
232    /// ```rust,ignore
233    /// // No payload — use ()
234    /// .on_action::<()>("increment", |state, _, _ctx| {
235    ///     state.count += 1;
236    /// })
237    ///
238    /// // Typed payload — any Deserialize type
239    /// #[derive(Deserialize)]
240    /// struct SetValue { value: i32 }
241    ///
242    /// .on_action::<SetValue>("set_value", |state, payload, _ctx| {
243    ///     state.count = payload.value;
244    /// })
245    ///
246    /// // Raw JSON access
247    /// .on_action::<serde_json::Value>("raw", |state, raw, _ctx| {
248    ///     if let Some(n) = raw.as_i64() { state.count = n as i32; }
249    /// })
250    /// ```
251    pub fn on_action<A>(
252        mut self,
253        name: impl Into<String>,
254        handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
255    ) -> Self
256    where
257        A: DeserializeOwned + 'static,
258    {
259        let wrapped: SyncActionFn<S> = Box::new(move |state, raw, ctx| {
260            let action = match raw {
261                Some(v) => serde_json::from_value::<A>(v.clone()).ok(),
262                None => serde_json::from_value::<A>(Value::Null).ok(),
263            };
264            if let Some(action) = action {
265                handler(state, action, ctx);
266            }
267        });
268        self.action_handlers
269            .insert(name.into(), ActionHandler::Sync(wrapped));
270        self
271    }
272
273    /// Called when the module is first mounted.
274    pub fn on_created<F>(mut self, handler: F) -> Self
275    where
276        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
277    {
278        self.on_created = Some(LifecycleHandler::Sync(Box::new(handler)));
279        self
280    }
281
282    /// Called when the module is destroyed/unmounted.
283    pub fn on_destroyed<F>(mut self, handler: F) -> Self
284    where
285        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
286    {
287        self.on_destroyed = Some(LifecycleHandler::Sync(Box::new(handler)));
288        self
289    }
290
291    /// Register an error handler for this module.
292    pub fn on_error<F>(mut self, handler: F) -> Self
293    where
294        F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
295    {
296        self.on_error = Some(Box::new(handler));
297        self
298    }
299
300    /// Called when the last WebSocket connection for a session drops
301    /// and the session is about to be suspended. The handler receives
302    /// the current state (read-only) and session information.
303    pub fn on_disconnect<F>(mut self, handler: F) -> Self
304    where
305        F: Fn(&S, &SessionInfo) + Send + Sync + 'static,
306    {
307        self.on_disconnect = Some(Box::new(handler));
308        self
309    }
310
311    /// Called when a client reconnects to a suspended session within the
312    /// TTL window. The handler receives a mutable reference to the
313    /// current (fresh) state, the session info, and the saved-state JSON.
314    /// If the handler wants to restore the saved state, it should
315    /// deserialize and write it into `state`; otherwise the caller will
316    /// apply the saved state automatically.
317    pub fn on_reconnect<F>(mut self, handler: F) -> Self
318    where
319        F: Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync + 'static,
320    {
321        self.on_reconnect = Some(Box::new(handler));
322        self
323    }
324
325    /// Called when a suspended session's TTL elapses without a reconnect.
326    /// The module is destroyed immediately after this handler returns.
327    pub fn on_expire<F>(mut self, handler: F) -> Self
328    where
329        F: Fn(&SessionInfo) + Send + Sync + 'static,
330    {
331        self.on_expire = Some(Box::new(handler));
332        self
333    }
334
335    /// Register a single SVG resource by name.
336    ///
337    /// ```rust,ignore
338    /// .resource("heart", r#"<svg viewBox="0 0 24 24"><path d="M20.84..."/></svg>"#)
339    /// ```
340    pub fn resource(mut self, name: impl Into<String>, svg: impl Into<String>) -> Self {
341        self.resource_map.insert(name.into(), svg.into());
342        self
343    }
344
345    /// Register multiple SVG resources from a map.
346    pub fn resources(mut self, map: indexmap::IndexMap<String, String>) -> Self {
347        self.resource_map.extend(map);
348        self
349    }
350
351    /// Load SVG resources from all `.svg` files in a directory.
352    ///
353    /// Each file becomes a resource named after its filename (without extension).
354    /// For example, `heart.svg` → `@resources.heart`.
355    pub fn resources_dir(mut self, path: impl AsRef<std::path::Path>) -> Self {
356        if let Ok(entries) = std::fs::read_dir(path.as_ref()) {
357            for entry in entries.flatten() {
358                let p = entry.path();
359                if p.extension().and_then(|e| e.to_str()) == Some("svg") {
360                    let name = p
361                        .file_stem()
362                        .and_then(|s| s.to_str())
363                        .unwrap_or("")
364                        .to_string();
365                    if let Ok(svg) = std::fs::read_to_string(&p) {
366                        self.resource_map.insert(name, svg);
367                    }
368                }
369            }
370        } else {
371            eprintln!(
372                "Warning: could not read resources dir: {}",
373                path.as_ref().display()
374            );
375        }
376        self
377    }
378
379
380    /// Load SVG resources from a JSON file (name → SVG string map).
381    ///
382    /// ```json
383    /// { "heart": "<svg>...</svg>", "search": "<svg>...</svg>" }
384    /// ```
385    pub fn resources_file(mut self, path: impl AsRef<std::path::Path>) -> Self {
386        match std::fs::read_to_string(path.as_ref()) {
387            Ok(json) => {
388                if let Ok(map) = serde_json::from_str::<indexmap::IndexMap<String, String>>(&json) {
389                    self.resource_map.extend(map);
390                } else {
391                    eprintln!(
392                        "Warning: could not parse resources file {}: expected {{name: svg}} map",
393                        path.as_ref().display()
394                    );
395                }
396            }
397            Err(e) => eprintln!(
398                "Warning: could not read resources file {}: {}",
399                path.as_ref().display(),
400                e
401            ),
402        }
403        self
404    }
405
406    pub fn persist(mut self) -> Self {
407        self.persist = true;
408        self
409    }
410
411    /// Register an async action handler with a typed payload.
412    ///
413    /// The handler takes **owned** state, performs async work, and returns
414    /// the (possibly mutated) state. This avoids holding `&mut` across
415    /// `.await` points.
416    ///
417    /// # Example
418    ///
419    /// ```rust,ignore
420    /// .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
421    ///     Box::pin(async move {
422    ///         state.count += payload.amount;
423    ///         state
424    ///     })
425    /// })
426    /// ```
427    #[cfg(feature = "async")]
428    pub fn on_action_async<A>(
429        mut self,
430        name: impl Into<String>,
431        handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
432    ) -> Self
433    where
434        A: DeserializeOwned + Send + 'static,
435    {
436        let wrapped: AsyncActionFn<S> = Box::new(move |state, raw, ctx| {
437            let action = match raw {
438                Some(v) => serde_json::from_value::<A>(v).ok(),
439                None => serde_json::from_value::<A>(Value::Null).ok(),
440            };
441            if let Some(action) = action {
442                handler(state, action, ctx)
443            } else {
444                Box::pin(async move { state })
445            }
446        });
447        self.action_handlers
448            .insert(name.into(), ActionHandler::Async(wrapped));
449        self
450    }
451
452    /// Register an async `on_created` lifecycle handler.
453    ///
454    /// Takes owned state and returns it after async work.
455    ///
456    /// ```rust,ignore
457    /// .on_created_async(|mut state, _ctx| {
458    ///     Box::pin(async move {
459    ///         // fetch data, initialize, etc.
460    ///         state
461    ///     })
462    /// })
463    /// ```
464    #[cfg(feature = "async")]
465    pub fn on_created_async(
466        mut self,
467        handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
468    ) -> Self {
469        self.on_created = Some(LifecycleHandler::Async(Box::new(handler)));
470        self
471    }
472
473    /// Register an async `on_destroyed` lifecycle handler.
474    ///
475    /// Takes owned state and returns it after async cleanup.
476    ///
477    /// ```rust,ignore
478    /// .on_destroyed_async(|state, _ctx| {
479    ///     Box::pin(async move {
480    ///         // cleanup, flush logs, etc.
481    ///         state
482    ///     })
483    /// })
484    /// ```
485    #[cfg(feature = "async")]
486    pub fn on_destroyed_async(
487        mut self,
488        handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
489    ) -> Self {
490        self.on_destroyed = Some(LifecycleHandler::Async(Box::new(handler)));
491        self
492    }
493
494    /// Consume the builder and produce an immutable `ModuleDefinition`.
495    pub fn build(self) -> ModuleDefinition<S> {
496        let initial_state = self
497            .initial_state
498            .expect("ModuleBuilder::state() must be called before build()");
499
500        ModuleDefinition {
501            name: self.name,
502            initial_state,
503            ui_source: self.ui_source,
504            ui_file: self.ui_file,
505            action_handlers: self.action_handlers,
506            on_created: self.on_created,
507            on_destroyed: self.on_destroyed,
508            on_error: self.on_error,
509            on_disconnect: self.on_disconnect,
510            on_reconnect: self.on_reconnect,
511            on_expire: self.on_expire,
512            persist: self.persist,
513            resource_map: self.resource_map,
514        }
515    }
516}
517
518// ---------------------------------------------------------------------------
519// ModuleInstance (running module)
520// ---------------------------------------------------------------------------
521
522/// A running module instance with live state.
523///
524/// Created from a `ModuleDefinition` when the module is mounted.
525/// Wraps a `hypen_engine::Engine` and manages state synchronization.
526pub struct ModuleInstance<S: State> {
527    definition: Arc<ModuleDefinition<S>>,
528    /// Arc-wrapped so engine-side action handlers (registered via
529    /// `engine.on_action(...)` in `new` / `new_with_components`) can
530    /// capture and mutate the state without holding the engine mutex.
531    state: Arc<Mutex<StateContainer<S>>>,
532    engine: Mutex<hypen_engine::Engine>,
533    mounted: Mutex<bool>,
534    global_context: Option<Arc<GlobalContext>>,
535}
536
537impl<S: State> ModuleInstance<S> {
538    /// Create a new module instance from a definition.
539    pub fn new(
540        definition: Arc<ModuleDefinition<S>>,
541        global_context: Option<Arc<GlobalContext>>,
542    ) -> Result<Self> {
543        let state_container = StateContainer::new(definition.initial_state.clone())?;
544        let mut engine = hypen_engine::Engine::new();
545
546        // Set up the engine module metadata
547        let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
548            .with_actions(definition.action_names())
549            .with_persist(definition.persist);
550
551        let initial_json = state_container.to_json()?;
552        let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
553        engine.set_module(engine_module);
554
555        // Register resources (name → raw SVG)
556        for (name, svg) in &definition.resource_map {
557            engine.register_resource(name, svg);
558        }
559
560        // Parse and load UI if provided
561        if let Some(ref source) = definition.ui_source {
562            Self::load_ui_source(&mut engine, source)?;
563        } else if let Some(ref path) = definition.ui_file {
564            let source = std::fs::read_to_string(path).map_err(|e| {
565                SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
566            })?;
567            Self::load_ui_source(&mut engine, &source)?;
568        }
569
570        let state = Arc::new(Mutex::new(state_container));
571        Self::register_action_handlers_with_engine(
572            &mut engine,
573            Arc::clone(&definition),
574            Arc::clone(&state),
575            global_context.clone(),
576        );
577
578        Ok(Self {
579            definition,
580            state,
581            engine: Mutex::new(engine),
582            mounted: Mutex::new(false),
583            global_context,
584        })
585    }
586
587    /// Create a new module instance with a component registry for resolving
588    /// child components in the UI template.
589    ///
590    /// This enables DSL like `Column { Feed {} }` where `Feed` is registered
591    /// in the `ComponentRegistry`. Without a registry, the engine cannot resolve
592    /// custom component references in the template.
593    ///
594    /// # Example
595    ///
596    /// ```rust,ignore
597    /// let mut registry = ComponentRegistry::new();
598    /// registry.register("Card", r#"Column { Text("Card") }"#, None);
599    ///
600    /// let instance = ModuleInstance::new_with_components(
601    ///     Arc::new(def),
602    ///     Some(ctx),
603    ///     &registry,
604    /// ).unwrap();
605    /// ```
606    pub fn new_with_components(
607        definition: Arc<ModuleDefinition<S>>,
608        global_context: Option<Arc<GlobalContext>>,
609        components: &crate::discovery::ComponentRegistry,
610    ) -> Result<Self> {
611        let state_container = StateContainer::new(definition.initial_state.clone())?;
612        let mut engine = hypen_engine::Engine::new();
613
614        // Set up the engine module metadata
615        let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
616            .with_actions(definition.action_names())
617            .with_persist(definition.persist);
618
619        let initial_json = state_container.to_json()?;
620        let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
621        engine.set_module(engine_module);
622
623        // Register resources (name → raw SVG) so the engine can resolve
624        // `@resources.xxx` references into concrete icon path data. Without
625        // this, Icon create patches carry a literal "@resources.xxx" string
626        // in props and renderers show nothing / a fallback glyph.
627        for (name, svg) in &definition.resource_map {
628            engine.register_resource(name, svg);
629        }
630
631        // Register component resolver so the engine can resolve child components
632        let entries: Vec<(String, String, String)> = components
633            .all()
634            .iter()
635            .map(|e| {
636                (
637                    e.name.clone(),
638                    e.source.clone(),
639                    e.path
640                        .as_ref()
641                        .map(|p| p.to_string_lossy().to_string())
642                        .unwrap_or_default(),
643                )
644            })
645            .collect();
646
647        engine.set_component_resolver(move |name, _ctx_path| {
648            entries.iter().find(|(n, _, _)| n == name).map(
649                |(_, source, path)| hypen_engine::ir::ResolvedComponent {
650                    source: source.clone(),
651                    path: path.clone(),
652                    passthrough: false,
653                    lazy: false,
654                },
655            )
656        });
657
658        // Parse and load UI if provided
659        if let Some(ref source) = definition.ui_source {
660            Self::load_ui_source(&mut engine, source)?;
661        } else if let Some(ref path) = definition.ui_file {
662            let source = std::fs::read_to_string(path).map_err(|e| {
663                SdkError::Component(format!("Failed to read UI file '{path}': {e}"))
664            })?;
665            Self::load_ui_source(&mut engine, &source)?;
666        }
667
668        let state = Arc::new(Mutex::new(state_container));
669        Self::register_action_handlers_with_engine(
670            &mut engine,
671            Arc::clone(&definition),
672            Arc::clone(&state),
673            global_context.clone(),
674        );
675
676        Ok(Self {
677            definition,
678            state,
679            engine: Mutex::new(engine),
680            mounted: Mutex::new(false),
681            global_context,
682        })
683    }
684
685    /// Register all sync action handlers from the definition with the engine
686    /// via [`Engine::on_action`]. Each registration captures the per-instance
687    /// state and global context Arcs and runs the typed handler when fired.
688    /// Async handlers are not registered here — they're invoked directly from
689    /// [`dispatch_action_async`].
690    fn register_action_handlers_with_engine(
691        engine: &mut hypen_engine::Engine,
692        definition: Arc<ModuleDefinition<S>>,
693        state: Arc<Mutex<StateContainer<S>>>,
694        global_context: Option<Arc<GlobalContext>>,
695    ) {
696        for (action_name, handler) in definition.action_handlers.iter() {
697            // Skip async-only handlers — the engine's action dispatcher is
698            // sync, so async handlers are still invoked directly from
699            // `dispatch_action_async`.
700            #[cfg(feature = "async")]
701            if matches!(handler, ActionHandler::Async(_)) {
702                continue;
703            }
704            // Sync handlers route through the engine. The closure captures
705            // Arcs of the definition (to look up the typed handler), the
706            // state (to mutate), and the global context. The user-visible
707            // `dispatch_action` calls `engine.dispatch_action(...)`, which
708            // fires this closure inside the engine's action-scope latch.
709            let definition = Arc::clone(&definition);
710            let state = Arc::clone(&state);
711            let global_context = global_context.clone();
712            let action_name_owned = action_name.clone();
713            engine.on_action(action_name.clone(), move |action| {
714                if let Some(ActionHandler::Sync(handler)) =
715                    definition.action_handlers.get(&action_name_owned)
716                {
717                    let ctx = global_context.as_deref();
718                    let mut state_guard = state.lock().unwrap();
719                    handler(state_guard.get_mut(), action.payload.as_ref(), ctx);
720                }
721            });
722            // Suppress unused-variable warning when the `async` cfg branch
723            // above is the only consumer.
724            let _ = handler;
725        }
726    }
727
728    fn load_ui_source(engine: &mut hypen_engine::Engine, source: &str) -> Result<()> {
729        let doc = hypen_parser::parse_document(source).map_err(|e| {
730            SdkError::Engine(hypen_engine::EngineError::ParseError {
731                source: source.chars().take(80).collect(),
732                message: format!("{e:?}"),
733            })
734        })?;
735        let component = doc
736            .components
737            .first()
738            .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
739        let ir_node = hypen_engine::ast_to_ir_node(component);
740        engine.render_ir_node(&ir_node);
741        Ok(())
742    }
743
744    /// Mount the module (triggers `on_created` if it was registered as sync).
745    ///
746    /// Async-only lifecycle handlers are silently skipped here — call
747    /// [`mount_async`](Self::mount_async) to invoke them.
748    pub fn mount(&self) {
749        let mut mounted = self.mounted.lock().unwrap();
750        if !*mounted {
751            *mounted = true;
752            if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_created {
753                let state = self.state.lock().unwrap();
754                let ctx = self.global_context.as_deref();
755                handler(state.get(), ctx);
756            }
757        }
758    }
759
760    /// Unmount the module (triggers `on_destroyed` if it was registered as sync).
761    ///
762    /// Async-only lifecycle handlers are silently skipped here — call
763    /// [`unmount_async`](Self::unmount_async) to invoke them.
764    pub fn unmount(&self) {
765        let mut mounted = self.mounted.lock().unwrap();
766        if *mounted {
767            if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_destroyed {
768                let state = self.state.lock().unwrap();
769                let ctx = self.global_context.as_deref();
770                handler(state.get(), ctx);
771            }
772            *mounted = false;
773        }
774    }
775
776    /// Dispatch an action by name with an optional payload.
777    ///
778    /// Only sync handlers run here. Actions registered as async-only return
779    /// `ActionNotFound` — call [`dispatch_action_async`](Self::dispatch_action_async)
780    /// for async handlers.
781    ///
782    /// The dispatch routes through `engine.dispatch_action(...)`, which fires
783    /// the per-action closure registered in `register_action_handlers_with_engine`
784    /// during construction. That closure runs the typed handler against the
785    /// per-instance state. After dispatch returns, this method diffs the
786    /// state against its pre-handler snapshot and pushes any changed paths
787    /// to the engine via `engine.update_state(None, patch)`.
788    pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
789        let name = name.into();
790
791        // `__hypen_bind` is the engine-level reserved action used by renderers
792        // for two-way binding. It carries `{path, value}` and writes the value
793        // back into module state at the dotted path. No user handler is
794        // involved — see ENGINE_CONTRACT.md §13.
795        if name == "__hypen_bind" {
796            return self.handle_bind_action(payload);
797        }
798
799        // Reject async-only handlers up front (they can't be invoked from
800        // a sync engine.on_action callback). Sync handlers are registered
801        // on the engine in `register_action_handlers_with_engine`, so an
802        // unknown action that has no engine handler also surfaces here as
803        // `ActionNotFound` — but we let `engine.dispatch_action` produce
804        // that error itself for consistency.
805        #[cfg(feature = "async")]
806        if matches!(
807            self.definition.action_handlers.get(&name),
808            Some(ActionHandler::Async(_))
809        ) {
810            return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
811                name,
812            )));
813        }
814
815        // Snapshot state before handler runs (the engine fires the registered
816        // closure synchronously inside dispatch_action; the closure mutates
817        // our state in place via the captured Arc).
818        {
819            let mut state = self.state.lock().unwrap();
820            state.take_snapshot()?;
821        }
822
823        // Build the action and dispatch through the engine. The engine's
824        // action-scope latch is set during the call, and the registered
825        // closure runs the typed handler against `self.state` (via the
826        // captured Arc).
827        let mut action = hypen_engine::dispatch::Action::new(name.clone());
828        if let Some(p) = payload {
829            action = action.with_payload(p);
830        }
831        {
832            let mut engine = self.engine.lock().unwrap();
833            engine
834                .dispatch_action(action)
835                .map_err(SdkError::Engine)?;
836        }
837
838        // Diff state and notify engine of changes
839        self.sync_state_to_engine()?;
840
841        Ok(())
842    }
843
844    /// Get a clone of the current state.
845    pub fn get_state(&self) -> S {
846        self.state.lock().unwrap().get().clone()
847    }
848
849    /// Get the current state as JSON.
850    pub fn get_state_json(&self) -> Result<Value> {
851        self.state.lock().unwrap().to_json()
852    }
853
854    /// Set the render callback for receiving patches.
855    pub fn on_patches<F>(&self, callback: F)
856    where
857        F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
858    {
859        let mut engine = self.engine.lock().unwrap();
860        engine.set_render_callback(callback);
861    }
862
863    /// Check if the module is currently mounted.
864    pub fn is_mounted(&self) -> bool {
865        *self.mounted.lock().unwrap()
866    }
867
868    /// Name of this module.
869    pub fn name(&self) -> &str {
870        &self.definition.name
871    }
872
873    /// Mount the module asynchronously. Runs whichever variant of
874    /// `on_created` was registered (sync or async).
875    #[cfg(feature = "async")]
876    pub async fn mount_async(&self) {
877        {
878            let mut mounted = self.mounted.lock().unwrap();
879            if *mounted {
880                return;
881            }
882            *mounted = true;
883        }
884
885        match &self.definition.on_created {
886            Some(LifecycleHandler::Async(handler)) => {
887                let current_state = self.state.lock().unwrap().get().clone();
888                let ctx = self.global_context.clone();
889                let new_state = handler(current_state, ctx).await;
890                *self.state.lock().unwrap().get_mut() = new_state;
891            }
892            Some(LifecycleHandler::Sync(handler)) => {
893                let state = self.state.lock().unwrap();
894                let ctx = self.global_context.as_deref();
895                handler(state.get(), ctx);
896            }
897            None => {}
898        }
899    }
900
901    /// Unmount the module asynchronously. Runs whichever variant of
902    /// `on_destroyed` was registered (sync or async).
903    #[cfg(feature = "async")]
904    pub async fn unmount_async(&self) {
905        {
906            let mounted = self.mounted.lock().unwrap();
907            if !*mounted {
908                return;
909            }
910        }
911
912        match &self.definition.on_destroyed {
913            Some(LifecycleHandler::Async(handler)) => {
914                let current_state = self.state.lock().unwrap().get().clone();
915                let ctx = self.global_context.clone();
916                let new_state = handler(current_state, ctx).await;
917                *self.state.lock().unwrap().get_mut() = new_state;
918            }
919            Some(LifecycleHandler::Sync(handler)) => {
920                let state = self.state.lock().unwrap();
921                let ctx = self.global_context.as_deref();
922                handler(state.get(), ctx);
923            }
924            None => {}
925        }
926
927        *self.mounted.lock().unwrap() = false;
928    }
929
930    /// Dispatch an action asynchronously. Runs whichever variant of the
931    /// handler was registered (sync or async).
932    #[cfg(feature = "async")]
933    pub async fn dispatch_action_async(
934        &self,
935        name: impl Into<String>,
936        payload: Option<Value>,
937    ) -> Result<()> {
938        let name = name.into();
939
940        // `__hypen_bind` is synchronous regardless of async dispatch — see
941        // the comment in `dispatch_action`.
942        if name == "__hypen_bind" {
943            return self.handle_bind_action(payload);
944        }
945
946        // Snapshot state before handler runs
947        {
948            let mut state = self.state.lock().unwrap();
949            state.take_snapshot()?;
950        }
951
952        match self.definition.action_handlers.get(&name) {
953            Some(ActionHandler::Async(handler)) => {
954                let current_state = self.state.lock().unwrap().get().clone();
955                let ctx = self.global_context.clone();
956                let new_state = handler(current_state, payload, ctx).await;
957                *self.state.lock().unwrap().get_mut() = new_state;
958            }
959            Some(ActionHandler::Sync(handler)) => {
960                let ctx = self.global_context.as_deref();
961                let mut state = self.state.lock().unwrap();
962                handler(state.get_mut(), payload.as_ref(), ctx);
963            }
964            None => {
965                return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
966                    name,
967                )));
968            }
969        }
970
971        self.sync_state_to_engine()?;
972        Ok(())
973    }
974
975    /// Internal: handle a `__hypen_bind` action from a renderer.
976    ///
977    /// Parses `{path, value}` from the payload, snapshots the current state,
978    /// applies the bind via JSON round-trip through `S` (so a path that
979    /// references a field not present on `S` is rejected with
980    /// [`SdkError::StateSerde`]), and syncs the resulting state back to the
981    /// engine. See ENGINE_CONTRACT.md §13 for the cross-SDK contract.
982    fn handle_bind_action(&self, payload: Option<Value>) -> Result<()> {
983        let payload = payload.ok_or_else(|| SdkError::ActionPayload {
984            action: "__hypen_bind".into(),
985            message: "missing payload".into(),
986        })?;
987        let obj = payload.as_object().ok_or_else(|| SdkError::ActionPayload {
988            action: "__hypen_bind".into(),
989            message: "payload must be an object".into(),
990        })?;
991        let path = obj
992            .get("path")
993            .and_then(|p| p.as_str())
994            .ok_or_else(|| SdkError::ActionPayload {
995                action: "__hypen_bind".into(),
996                message: "missing 'path' string field".into(),
997            })?
998            .to_string();
999        let value = obj.get("value").cloned().unwrap_or(Value::Null);
1000
1001        {
1002            let mut state = self.state.lock().unwrap();
1003            state.take_snapshot()?;
1004            let new_typed: S = crate::state::apply_bind(state.get(), &path, value)?;
1005            *state.get_mut() = new_typed;
1006        }
1007
1008        self.sync_state_to_engine()
1009    }
1010
1011    /// Internal: compare state against pre-handler snapshot and push changes
1012    /// to the engine.
1013    ///
1014    /// `ModuleInstance` installs its module via `engine.set_module(...)`, which
1015    /// puts it in the engine's primary slot. The corresponding write path
1016    /// uses `None` as the scope, not `Some(self.definition.name)` — passing a
1017    /// non-empty scope routes through `EngineCore::update_state`'s named-module
1018    /// lookup, finds nothing, and silently no-ops the entire update (no dirty
1019    /// marking, no patches emitted). The previous comment here incorrectly
1020    /// claimed `effective_scope` rescued this; `effective_scope` is a read-side
1021    /// filter applied during reconciliation, not a write-side router on
1022    /// `update_state`.
1023    fn sync_state_to_engine(&self) -> Result<()> {
1024        let state = self.state.lock().unwrap();
1025        let paths = state.changed_paths()?;
1026
1027        if !paths.is_empty() {
1028            let patch = state.diff_patch()?;
1029            drop(state); // release lock before engine lock
1030
1031            let mut engine = self.engine.lock().unwrap();
1032            engine.update_state(None, patch);
1033        }
1034
1035        Ok(())
1036    }
1037}
1038
1039/// Create a nested module instance and register its state in the GlobalContext.
1040///
1041/// This creates a `ModuleInstance` from the given definition, registers its
1042/// initial state in the `GlobalContext` under the module's lowercase name,
1043/// and mounts the instance.
1044///
1045/// This is the Rust equivalent of the TypeScript SDK's `createNestedModuleInstances()`.
1046///
1047/// # Example
1048///
1049/// ```rust,ignore
1050/// use hypen_server::prelude::*;
1051///
1052/// let ctx = Arc::new(GlobalContext::new());
1053/// let def = Arc::new(ModuleBuilder::<MyState>::new("Feed")
1054///     .state(MyState::default())
1055///     .build());
1056///
1057/// let instance = create_nested_instance(def, ctx.clone()).unwrap();
1058/// assert!(ctx.has_module("feed"));
1059/// ```
1060pub fn create_nested_instance<S: State>(
1061    definition: Arc<ModuleDefinition<S>>,
1062    context: Arc<GlobalContext>,
1063) -> Result<ModuleInstance<S>> {
1064    let instance = ModuleInstance::new(definition, Some(context.clone()))?;
1065    let name = instance.name().to_lowercase();
1066    let state_json = instance.get_state_json()?;
1067    context.register_module_state(&name, state_json);
1068    instance.mount();
1069    Ok(instance)
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use serde::{Deserialize, Serialize};
1076    use std::sync::atomic::{AtomicI32, Ordering};
1077
1078    #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1079    struct TestState {
1080        count: i32,
1081        name: String,
1082    }
1083
1084    #[test]
1085    fn test_module_builder_action() {
1086        let def = ModuleBuilder::<TestState>::new("Test")
1087            .state(TestState {
1088                count: 0,
1089                name: "Alice".into(),
1090            })
1091            .on_action::<()>("increment", |state, _, _ctx| {
1092                state.count += 1;
1093            })
1094            .build();
1095
1096        assert_eq!(def.name(), "Test");
1097        assert!(def.action_names().contains(&"increment".to_string()));
1098    }
1099
1100    #[test]
1101    fn test_module_builder_with_ui() {
1102        let def = ModuleBuilder::<TestState>::new("Test")
1103            .state(TestState::default())
1104            .ui(r#"Column { Text("Hello") }"#)
1105            .build();
1106
1107        assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
1108    }
1109
1110    #[test]
1111    fn test_module_instance_dispatch() {
1112        let def = ModuleBuilder::<TestState>::new("Test")
1113            .state(TestState {
1114                count: 0,
1115                name: "Alice".into(),
1116            })
1117            .on_action::<()>("increment", |state, _, _ctx| {
1118                state.count += 1;
1119            })
1120            .on_action::<String>("set_name", |state, name, _ctx| {
1121                state.name = name;
1122            })
1123            .build();
1124
1125        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1126        instance.mount();
1127
1128        instance.dispatch_action("increment", None).unwrap();
1129        assert_eq!(instance.get_state().count, 1);
1130
1131        instance.dispatch_action("increment", None).unwrap();
1132        assert_eq!(instance.get_state().count, 2);
1133
1134        instance
1135            .dispatch_action("set_name", Some(serde_json::json!("Bob")))
1136            .unwrap();
1137        assert_eq!(instance.get_state().name, "Bob");
1138    }
1139
1140    #[test]
1141    fn test_module_lifecycle() {
1142        let created = Arc::new(AtomicI32::new(0));
1143        let destroyed = Arc::new(AtomicI32::new(0));
1144
1145        let created_clone = created.clone();
1146        let destroyed_clone = destroyed.clone();
1147
1148        let def = ModuleBuilder::<TestState>::new("Test")
1149            .state(TestState::default())
1150            .on_created(move |_state, _ctx| {
1151                created_clone.fetch_add(1, Ordering::SeqCst);
1152            })
1153            .on_destroyed(move |_state, _ctx| {
1154                destroyed_clone.fetch_add(1, Ordering::SeqCst);
1155            })
1156            .build();
1157
1158        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1159
1160        assert_eq!(created.load(Ordering::SeqCst), 0);
1161        instance.mount();
1162        assert_eq!(created.load(Ordering::SeqCst), 1);
1163
1164        // Mounting again should be idempotent
1165        instance.mount();
1166        assert_eq!(created.load(Ordering::SeqCst), 1);
1167
1168        instance.unmount();
1169        assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1170
1171        // Unmounting again should be idempotent
1172        instance.unmount();
1173        assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1174    }
1175
1176    #[test]
1177    fn test_module_unknown_action() {
1178        let def = ModuleBuilder::<TestState>::new("Test")
1179            .state(TestState::default())
1180            .build();
1181
1182        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1183        let result = instance.dispatch_action("nonexistent", None);
1184        assert!(result.is_err());
1185    }
1186
1187    #[test]
1188    fn test_module_persist_flag() {
1189        let def = ModuleBuilder::<TestState>::new("Test")
1190            .state(TestState::default())
1191            .persist()
1192            .build();
1193
1194        assert!(def.is_persistent());
1195    }
1196
1197    #[test]
1198    fn test_module_typed_payload() {
1199        #[derive(Deserialize)]
1200        struct AddPayload {
1201            amount: i32,
1202        }
1203
1204        let def = ModuleBuilder::<TestState>::new("TypedTest")
1205            .state(TestState {
1206                count: 10,
1207                name: "test".into(),
1208            })
1209            .on_action::<AddPayload>("add", |state, payload, _ctx| {
1210                state.count += payload.amount;
1211            })
1212            .on_action::<()>("reset", |state, _, _ctx| {
1213                state.count = 0;
1214            })
1215            .build();
1216
1217        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1218        instance.mount();
1219
1220        instance
1221            .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1222            .unwrap();
1223        assert_eq!(instance.get_state().count, 15);
1224
1225        instance.dispatch_action("reset", None).unwrap();
1226        assert_eq!(instance.get_state().count, 0);
1227    }
1228
1229    #[test]
1230    fn test_module_multiple_typed_actions() {
1231        #[derive(Deserialize)]
1232        struct AddPayload {
1233            amount: i32,
1234        }
1235
1236        #[derive(Deserialize)]
1237        struct MultiplyPayload {
1238            factor: i32,
1239        }
1240
1241        let def = ModuleBuilder::<TestState>::new("Mixed")
1242            .state(TestState {
1243                count: 10,
1244                name: "test".into(),
1245            })
1246            .on_action::<()>("reset", |state, _, _ctx| {
1247                state.count = 0;
1248            })
1249            .on_action::<AddPayload>("add", |state, payload, _ctx| {
1250                state.count += payload.amount;
1251            })
1252            .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
1253                state.count *= payload.factor;
1254            })
1255            .build();
1256
1257        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1258        instance.mount();
1259
1260        instance.dispatch_action("reset", None).unwrap();
1261        assert_eq!(instance.get_state().count, 0);
1262
1263        instance
1264            .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1265            .unwrap();
1266        assert_eq!(instance.get_state().count, 5);
1267
1268        instance
1269            .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
1270            .unwrap();
1271        assert_eq!(instance.get_state().count, 15);
1272    }
1273
1274    #[test]
1275    #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
1276    fn test_module_builder_panics_without_state() {
1277        let _def = ModuleBuilder::<TestState>::new("Test").build();
1278    }
1279
1280    #[test]
1281    fn test_module_invalid_ui_source() {
1282        let def = ModuleBuilder::<TestState>::new("Test")
1283            .state(TestState::default())
1284            .ui("this is not valid {{{{ hypen")
1285            .build();
1286
1287        let result = ModuleInstance::new(Arc::new(def), None);
1288        assert!(result.is_err());
1289    }
1290
1291    #[test]
1292    fn test_module_payload_type_mismatch_is_noop() {
1293        #[derive(Deserialize)]
1294        struct Expected {
1295            #[allow(dead_code)]
1296            value: i32,
1297        }
1298
1299        let def = ModuleBuilder::<TestState>::new("Test")
1300            .state(TestState {
1301                count: 42,
1302                name: "test".into(),
1303            })
1304            .on_action::<Expected>("set", |state, payload, _ctx| {
1305                state.count = payload.value;
1306            })
1307            .build();
1308
1309        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1310        instance.mount();
1311
1312        // Send wrong payload shape — handler should silently skip
1313        instance
1314            .dispatch_action("set", Some(serde_json::json!("wrong type")))
1315            .unwrap();
1316        assert_eq!(instance.get_state().count, 42); // unchanged
1317    }
1318
1319    #[test]
1320    fn test_module_duplicate_action_last_wins() {
1321        let def = ModuleBuilder::<TestState>::new("Test")
1322            .state(TestState {
1323                count: 0,
1324                name: "test".into(),
1325            })
1326            .on_action::<()>("act", |state, _, _ctx| {
1327                state.count += 1;
1328            })
1329            .on_action::<()>("act", |state, _, _ctx| {
1330                state.count += 100;
1331            })
1332            .build();
1333
1334        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1335        instance.dispatch_action("act", None).unwrap();
1336        assert_eq!(instance.get_state().count, 100); // second handler wins
1337    }
1338
1339    #[test]
1340    fn test_module_ui_file() {
1341        let dir = std::env::temp_dir().join("hypen_test_ui_file");
1342        let _ = std::fs::remove_dir_all(&dir);
1343        std::fs::create_dir_all(&dir).unwrap();
1344
1345        let path = dir.join("counter.hypen");
1346        std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
1347
1348        let def = ModuleBuilder::<TestState>::new("Test")
1349            .state(TestState::default())
1350            .ui_file(path.to_str().unwrap())
1351            .build();
1352
1353        let instance = ModuleInstance::new(Arc::new(def), None);
1354        assert!(instance.is_ok());
1355
1356        let _ = std::fs::remove_dir_all(&dir);
1357    }
1358
1359    #[test]
1360    fn test_module_ui_file_not_found() {
1361        let def = ModuleBuilder::<TestState>::new("Test")
1362            .state(TestState::default())
1363            .ui_file("/tmp/hypen_no_such_file.hypen")
1364            .build();
1365
1366        let result = ModuleInstance::new(Arc::new(def), None);
1367        assert!(result.is_err());
1368    }
1369
1370    #[test]
1371    fn test_module_dispatch_without_mount() {
1372        let def = ModuleBuilder::<TestState>::new("Test")
1373            .state(TestState {
1374                count: 0,
1375                name: "test".into(),
1376            })
1377            .on_action::<()>("inc", |state, _, _ctx| {
1378                state.count += 1;
1379            })
1380            .build();
1381
1382        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1383        // Don't mount — dispatch should still work
1384        instance.dispatch_action("inc", None).unwrap();
1385        assert_eq!(instance.get_state().count, 1);
1386    }
1387
1388    #[test]
1389    fn test_module_raw_json_action() {
1390        let def = ModuleBuilder::<TestState>::new("RawTest")
1391            .state(TestState {
1392                count: 0,
1393                name: "test".into(),
1394            })
1395            .on_action::<Value>("set_count", |state, payload, _ctx| {
1396                if let Some(n) = payload.as_i64() {
1397                    state.count = n as i32;
1398                }
1399            })
1400            .build();
1401
1402        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1403        instance.mount();
1404
1405        instance
1406            .dispatch_action("set_count", Some(serde_json::json!(42)))
1407            .unwrap();
1408        assert_eq!(instance.get_state().count, 42);
1409    }
1410
1411    #[test]
1412    fn test_nested_module_registers_in_context() {
1413        let ctx = Arc::new(GlobalContext::new());
1414
1415        let def = Arc::new(
1416            ModuleBuilder::<TestState>::new("Feed")
1417                .state(TestState {
1418                    count: 0,
1419                    name: "feed".into(),
1420                })
1421                .build(),
1422        );
1423
1424        let instance = create_nested_instance(def, ctx.clone()).unwrap();
1425
1426        // Should be registered in context under lowercase name
1427        assert!(ctx.has_module("feed"));
1428        let state = ctx.get_module_state("feed").unwrap();
1429        assert_eq!(state["name"], "feed");
1430
1431        instance.unmount();
1432    }
1433
1434    #[test]
1435    fn test_nested_module_actions_work() {
1436        let ctx = Arc::new(GlobalContext::new());
1437
1438        let def = Arc::new(
1439            ModuleBuilder::<TestState>::new("Counter")
1440                .state(TestState {
1441                    count: 0,
1442                    name: String::new(),
1443                })
1444                .on_action::<()>("increment", |state, _, _| {
1445                    state.count += 1;
1446                })
1447                .build(),
1448        );
1449
1450        let instance = create_nested_instance(def, ctx.clone()).unwrap();
1451        instance.dispatch_action("increment", None).unwrap();
1452        assert_eq!(instance.get_state().count, 1);
1453
1454        instance.unmount();
1455    }
1456
1457    #[test]
1458    fn test_multiple_nested_modules() {
1459        let ctx = Arc::new(GlobalContext::new());
1460
1461        let feed_def = Arc::new(
1462            ModuleBuilder::<TestState>::new("Feed")
1463                .state(TestState {
1464                    count: 0,
1465                    name: "feed".into(),
1466                })
1467                .build(),
1468        );
1469        let cart_def = Arc::new(
1470            ModuleBuilder::<TestState>::new("Cart")
1471                .state(TestState {
1472                    count: 5,
1473                    name: "cart".into(),
1474                })
1475                .build(),
1476        );
1477
1478        let _feed = create_nested_instance(feed_def, ctx.clone()).unwrap();
1479        let _cart = create_nested_instance(cart_def, ctx.clone()).unwrap();
1480
1481        assert!(ctx.has_module("feed"));
1482        assert!(ctx.has_module("cart"));
1483        assert_eq!(ctx.module_names().len(), 2);
1484
1485        let global = ctx.global_state();
1486        assert_eq!(global["feed"]["name"], "feed");
1487        assert_eq!(global["cart"]["count"], 5);
1488    }
1489
1490    #[test]
1491    fn test_new_with_components_resolves_child() {
1492        use crate::discovery::ComponentRegistry;
1493
1494        let mut registry = ComponentRegistry::new();
1495        registry.register("Card", r#"Column { Text("Card content") }"#, None);
1496
1497        let def = ModuleBuilder::<TestState>::new("Parent")
1498            .state(TestState {
1499                count: 0,
1500                name: "parent".into(),
1501            })
1502            // Template references the "Card" component
1503            .ui(r#"Column { Card {} }"#)
1504            .build();
1505
1506        // With components — should succeed and resolve Card
1507        let instance =
1508            ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1509        instance.mount();
1510        assert_eq!(instance.get_state().name, "parent");
1511    }
1512
1513    #[test]
1514    fn test_new_with_components_empty_registry() {
1515        use crate::discovery::ComponentRegistry;
1516
1517        let registry = ComponentRegistry::new();
1518
1519        let def = ModuleBuilder::<TestState>::new("Simple")
1520            .state(TestState::default())
1521            .ui(r#"Column { Text("Hello") }"#)
1522            .build();
1523
1524        let instance =
1525            ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1526        instance.mount();
1527        assert!(instance.is_mounted());
1528    }
1529
1530    /// Regression test: `new_with_components` must register `resource_map`
1531    /// with the engine, otherwise `Icon(@resources.xxx)` references are not
1532    /// resolved and renderers see the raw reference string. This mirrors the
1533    /// same bug that was present in the Go server's sendSessionAndInitialTree.
1534    #[test]
1535    fn test_new_with_components_registers_resources() {
1536        use crate::discovery::ComponentRegistry;
1537
1538        let registry = ComponentRegistry::new();
1539        let heart_svg = r#"<svg viewBox="0 0 24 24"><path d="M12 21s-7-4.5-7-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-7 11-7 11z" stroke="currentColor"/></svg>"#;
1540
1541        let def = ModuleBuilder::<TestState>::new("WithIcons")
1542            .state(TestState::default())
1543            .ui(r#"Icon(@resources.heart)"#)
1544            .resource("heart", heart_svg)
1545            .build();
1546
1547        let instance =
1548            ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1549
1550        // Directly inspect the engine's resource registry — this is the
1551        // smoking-gun check for the fix. Before the fix, the registry was
1552        // empty here because new_with_components skipped the registration
1553        // loop that `new` has.
1554        let engine = instance.engine.lock().unwrap();
1555        let resolved = engine.resource_registry().resolve("heart");
1556        assert!(
1557            resolved.is_some(),
1558            "heart resource was not registered with the engine in new_with_components — \
1559             Icon(@resources.heart) would render as a raw reference string"
1560        );
1561        let data = resolved.unwrap();
1562        assert!(
1563            !data.paths.is_empty(),
1564            "resolved heart icon has no parsed paths"
1565        );
1566        assert!(
1567            data.paths[0].d.starts_with("M12 21"),
1568            "resolved heart path d did not round-trip: {:?}",
1569            data.paths[0].d
1570        );
1571    }
1572
1573    // -----------------------------------------------------------------------
1574    // __hypen_bind two-way binding tests
1575    //
1576    // The `__hypen_bind` action is dispatched by renderers when a form
1577    // control's value changes. The SDK auto-handles it by writing the
1578    // payload's `value` into module state at the dotted `path`. See
1579    // ENGINE_CONTRACT.md §13 for the cross-SDK contract.
1580    // -----------------------------------------------------------------------
1581
1582    #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1583    struct BindState {
1584        name: String,
1585        count: i32,
1586        nested: Nested,
1587    }
1588
1589    #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1590    struct Nested {
1591        flag: bool,
1592    }
1593
1594    #[test]
1595    fn test_hypen_bind_writes_value_at_path() {
1596        let def = ModuleBuilder::<BindState>::new("BindTest")
1597            .state(BindState::default())
1598            .build();
1599        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1600
1601        instance
1602            .dispatch_action(
1603                "__hypen_bind",
1604                Some(serde_json::json!({"path": "name", "value": "Alice"})),
1605            )
1606            .unwrap();
1607
1608        assert_eq!(instance.get_state().name, "Alice");
1609    }
1610
1611    #[test]
1612    fn test_hypen_bind_writes_typed_number() {
1613        let def = ModuleBuilder::<BindState>::new("BindTest")
1614            .state(BindState::default())
1615            .build();
1616        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1617
1618        instance
1619            .dispatch_action(
1620                "__hypen_bind",
1621                Some(serde_json::json!({"path": "count", "value": 42})),
1622            )
1623            .unwrap();
1624
1625        assert_eq!(instance.get_state().count, 42);
1626    }
1627
1628    #[test]
1629    fn test_hypen_bind_writes_nested_path() {
1630        let def = ModuleBuilder::<BindState>::new("BindTest")
1631            .state(BindState::default())
1632            .build();
1633        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1634
1635        instance
1636            .dispatch_action(
1637                "__hypen_bind",
1638                Some(serde_json::json!({"path": "nested.flag", "value": true})),
1639            )
1640            .unwrap();
1641
1642        assert!(instance.get_state().nested.flag);
1643    }
1644
1645    #[test]
1646    fn test_hypen_bind_invalid_path_returns_error() {
1647        // Binding to a field that doesn't exist on the typed state should
1648        // surface as a state-serde error rather than silently corrupting state.
1649        let def = ModuleBuilder::<BindState>::new("BindTest")
1650            .state(BindState {
1651                name: "before".into(),
1652                count: 0,
1653                nested: Nested::default(),
1654            })
1655            .build();
1656        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1657
1658        let result = instance.dispatch_action(
1659            "__hypen_bind",
1660            Some(serde_json::json!({"path": "name", "value": 42})), // wrong type for `name`
1661        );
1662        assert!(result.is_err(), "type-mismatched bind should fail");
1663        // State must be untouched on failure.
1664        assert_eq!(instance.get_state().name, "before");
1665    }
1666
1667    #[test]
1668    fn test_hypen_bind_missing_path_returns_error() {
1669        let def = ModuleBuilder::<BindState>::new("BindTest")
1670            .state(BindState::default())
1671            .build();
1672        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1673
1674        let result = instance.dispatch_action(
1675            "__hypen_bind",
1676            Some(serde_json::json!({"value": "missing path"})),
1677        );
1678        assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1679    }
1680
1681    #[test]
1682    fn test_hypen_bind_missing_payload_returns_error() {
1683        let def = ModuleBuilder::<BindState>::new("BindTest")
1684            .state(BindState::default())
1685            .build();
1686        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1687
1688        let result = instance.dispatch_action("__hypen_bind", None);
1689        assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1690    }
1691
1692    // -----------------------------------------------------------------------
1693    // Async handler tests (behind "async" feature)
1694    // -----------------------------------------------------------------------
1695
1696    #[cfg(feature = "async")]
1697    mod async_tests {
1698        use super::*;
1699
1700        #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1701        struct AsyncState {
1702            count: i32,
1703            name: String,
1704        }
1705
1706        #[tokio::test]
1707        async fn test_async_action_handler() {
1708            let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1709                .state(AsyncState {
1710                    count: 0,
1711                    name: "test".into(),
1712                })
1713                .on_action_async::<()>("increment", |mut state, _, _ctx| {
1714                    Box::pin(async move {
1715                        state.count += 1;
1716                        state
1717                    })
1718                })
1719                .build();
1720
1721            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1722            instance.mount();
1723
1724            instance
1725                .dispatch_action_async("increment", None)
1726                .await
1727                .unwrap();
1728            assert_eq!(instance.get_state().count, 1);
1729
1730            instance
1731                .dispatch_action_async("increment", None)
1732                .await
1733                .unwrap();
1734            assert_eq!(instance.get_state().count, 2);
1735        }
1736
1737        #[tokio::test]
1738        async fn test_async_typed_payload() {
1739            #[derive(Deserialize)]
1740            struct AddPayload {
1741                amount: i32,
1742            }
1743
1744            let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1745                .state(AsyncState {
1746                    count: 10,
1747                    name: "test".into(),
1748                })
1749                .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1750                    Box::pin(async move {
1751                        state.count += payload.amount;
1752                        state
1753                    })
1754                })
1755                .build();
1756
1757            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1758            instance.mount();
1759
1760            instance
1761                .dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
1762                .await
1763                .unwrap();
1764            assert_eq!(instance.get_state().count, 15);
1765        }
1766
1767        #[tokio::test]
1768        async fn test_async_falls_back_to_sync() {
1769            let def = ModuleBuilder::<AsyncState>::new("Fallback")
1770                .state(AsyncState {
1771                    count: 0,
1772                    name: "test".into(),
1773                })
1774                .on_action::<()>("sync_inc", |state, _, _ctx| {
1775                    state.count += 1;
1776                })
1777                .build();
1778
1779            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1780
1781            // dispatch_action_async should fall back to the sync handler
1782            instance
1783                .dispatch_action_async("sync_inc", None)
1784                .await
1785                .unwrap();
1786            assert_eq!(instance.get_state().count, 1);
1787        }
1788
1789        #[tokio::test]
1790        async fn test_async_on_created() {
1791            let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1792                .state(AsyncState {
1793                    count: 0,
1794                    name: "test".into(),
1795                })
1796                .on_created_async(|mut state, _ctx| {
1797                    Box::pin(async move {
1798                        state.count = 42;
1799                        state.name = "initialized".into();
1800                        state
1801                    })
1802                })
1803                .build();
1804
1805            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1806            instance.mount_async().await;
1807
1808            assert_eq!(instance.get_state().count, 42);
1809            assert_eq!(instance.get_state().name, "initialized");
1810        }
1811
1812        #[tokio::test]
1813        async fn test_async_on_destroyed() {
1814            let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1815            let destroyed_clone = destroyed.clone();
1816
1817            let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1818                .state(AsyncState {
1819                    count: 0,
1820                    name: "test".into(),
1821                })
1822                .on_destroyed_async(move |state, _ctx| {
1823                    let flag = destroyed_clone.clone();
1824                    Box::pin(async move {
1825                        flag.store(true, std::sync::atomic::Ordering::SeqCst);
1826                        state
1827                    })
1828                })
1829                .build();
1830
1831            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1832            instance.mount();
1833            assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1834
1835            instance.unmount_async().await;
1836            assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1837            assert!(!instance.is_mounted());
1838        }
1839
1840        #[tokio::test]
1841        async fn test_async_mount_idempotent() {
1842            let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1843            let cc = call_count.clone();
1844
1845            let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1846                .state(AsyncState::default())
1847                .on_created_async(move |state, _ctx| {
1848                    let count = cc.clone();
1849                    Box::pin(async move {
1850                        count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1851                        state
1852                    })
1853                })
1854                .build();
1855
1856            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1857            instance.mount_async().await;
1858            instance.mount_async().await; // second call is noop
1859
1860            assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1861        }
1862
1863        #[tokio::test]
1864        async fn test_async_dispatch_unknown_action() {
1865            let def = ModuleBuilder::<AsyncState>::new("Unknown")
1866                .state(AsyncState::default())
1867                .build();
1868
1869            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1870            let result = instance.dispatch_action_async("nonexistent", None).await;
1871            assert!(result.is_err());
1872        }
1873
1874        #[tokio::test]
1875        async fn test_async_mixed_sync_and_async_actions() {
1876            #[derive(Deserialize)]
1877            struct SetName {
1878                name: String,
1879            }
1880
1881            let def = ModuleBuilder::<AsyncState>::new("Mixed")
1882                .state(AsyncState {
1883                    count: 0,
1884                    name: "init".into(),
1885                })
1886                .on_action::<()>("sync_inc", |state, _, _ctx| {
1887                    state.count += 1;
1888                })
1889                .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
1890                    Box::pin(async move {
1891                        state.name = payload.name;
1892                        state
1893                    })
1894                })
1895                .build();
1896
1897            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1898            instance.mount();
1899
1900            // Use async dispatch for both sync and async handlers
1901            instance
1902                .dispatch_action_async("sync_inc", None)
1903                .await
1904                .unwrap();
1905            assert_eq!(instance.get_state().count, 1);
1906
1907            instance
1908                .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
1909                .await
1910                .unwrap();
1911            assert_eq!(instance.get_state().name, "Alice");
1912        }
1913    }
1914}