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