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