Skip to main content

hypen_server/
app.rs

1use std::sync::Arc;
2
3use crate::context::GlobalContext;
4use crate::discovery::ComponentRegistry;
5use crate::module::{ModuleBuilder, ModuleDefinition, ModuleInstance};
6use crate::router::HypenRouter;
7use crate::state::State;
8
9/// Top-level application builder and registry.
10///
11/// `HypenApp` is the entry point for building Hypen applications. It provides:
12/// - A shorthand for creating module builders
13/// - A component registry for custom components
14/// - A global context and router
15/// - Route-based module management
16///
17/// # Example
18///
19/// ```rust,ignore
20/// use hypen_server::prelude::*;
21/// use serde::{Deserialize, Serialize};
22///
23/// #[derive(Clone, Default, Serialize, Deserialize)]
24/// struct Home { title: String }
25///
26/// #[derive(Clone, Default, Serialize, Deserialize)]
27/// struct Counter { count: i32 }
28///
29/// let app = HypenApp::builder()
30///     .route("/", HypenApp::module::<Home>("Home")
31///         .state(Home { title: "Welcome".into() })
32///         .ui(r#"Column { Text("${state.title}") }"#)
33///         .build())
34///     .route("/counter", HypenApp::module::<Counter>("Counter")
35///         .state(Counter { count: 0 })
36///         .ui(r#"Column { Text("${state.count}") }"#)
37///         .on_action::<()>("increment", |state, _, _| { state.count += 1; })
38///         .build())
39///     .components_dir("./components")
40///     .build();
41/// ```
42pub struct HypenApp {
43    context: Arc<GlobalContext>,
44    router: Arc<HypenRouter>,
45    components: ComponentRegistry,
46    routes: Vec<RouteEntry>,
47}
48
49/// A route entry mapping a path pattern to a module name.
50struct RouteEntry {
51    pattern: String,
52    module_name: String,
53}
54
55impl HypenApp {
56    pub fn builder() -> HypenAppBuilder {
57        HypenAppBuilder {
58            components: ComponentRegistry::new(),
59            routes: Vec::new(),
60            components_dirs: Vec::new(),
61        }
62    }
63
64    /// Shorthand to start building a module definition.
65    ///
66    /// ```rust,ignore
67    /// let module = HypenApp::module::<MyState>("MyModule")
68    ///     .state(MyState::default())
69    ///     .on_action::<()>("do_thing", |state, _, _ctx| { /* ... */ })
70    ///     .build();
71    /// ```
72    pub fn module<S: State>(name: impl Into<String>) -> ModuleBuilder<S> {
73        ModuleBuilder::new(name)
74    }
75
76    /// Access the global context.
77    pub fn context(&self) -> &GlobalContext {
78        &self.context
79    }
80
81    /// Access the router.
82    pub fn router(&self) -> &HypenRouter {
83        &self.router
84    }
85
86    /// Access the component registry.
87    pub fn components(&self) -> &ComponentRegistry {
88        &self.components
89    }
90
91    /// Instantiate a module by its definition, connecting it to this app's context.
92    pub fn instantiate<S: State>(
93        &self,
94        definition: Arc<ModuleDefinition<S>>,
95    ) -> crate::error::Result<ModuleInstance<S>> {
96        ModuleInstance::new(definition, Some(Arc::clone(&self.context)))
97    }
98
99    /// Navigate to a route.
100    pub fn navigate(&self, path: &str) {
101        self.router.push(path);
102    }
103
104    /// Get the active route pattern match for a given path.
105    pub fn match_route(&self, path: &str) -> Option<(&str, &str)> {
106        for entry in &self.routes {
107            if self.router.match_path(&entry.pattern, path).is_some() {
108                return Some((&entry.pattern, &entry.module_name));
109            }
110        }
111        None
112    }
113}
114
115impl Default for HypenApp {
116    fn default() -> Self {
117        let context = Arc::new(GlobalContext::new());
118        let router = Arc::new(HypenRouter::new());
119        context.set_router(Arc::clone(&router));
120        Self {
121            context,
122            router,
123            components: ComponentRegistry::new(),
124            routes: Vec::new(),
125        }
126    }
127}
128
129/// Builder for `HypenApp`.
130pub struct HypenAppBuilder {
131    components: ComponentRegistry,
132    routes: Vec<RouteEntry>,
133    components_dirs: Vec<String>,
134}
135
136impl HypenAppBuilder {
137    /// Add a route mapping a URL pattern to a module definition.
138    ///
139    /// The module name is used to identify which module to mount when
140    /// the route matches.
141    pub fn route<S: State>(
142        mut self,
143        pattern: impl Into<String>,
144        definition: ModuleDefinition<S>,
145    ) -> Self {
146        let pattern = pattern.into();
147        let name = definition.name().to_string();
148        self.routes.push(RouteEntry {
149            pattern,
150            module_name: name,
151        });
152        // Store the definition's UI source in the component registry if present
153        if let Some(source) = definition.ui_source() {
154            self.components.register(definition.name(), source, None);
155        }
156        self
157    }
158
159    /// Register a component from inline DSL source.
160    pub fn component(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
161        self.components.register(name, source, None);
162        self
163    }
164
165    /// Load components from a directory of `.hypen` files.
166    pub fn components_dir(mut self, dir: impl Into<String>) -> Self {
167        self.components_dirs.push(dir.into());
168        self
169    }
170
171    /// Build the `HypenApp`.
172    pub fn build(mut self) -> HypenApp {
173        // Load component directories
174        for dir in &self.components_dirs {
175            // Best-effort: log errors but don't fail the build
176            if let Err(e) = self.components.load_dir(dir) {
177                eprintln!("[hypen-server] Warning: failed to load components from {dir}: {e}");
178            }
179        }
180
181        let context = Arc::new(GlobalContext::new());
182        let router = Arc::new(HypenRouter::new());
183        context.set_router(Arc::clone(&router));
184
185        HypenApp {
186            context,
187            router,
188            components: self.components,
189            routes: self.routes,
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use serde::{Deserialize, Serialize};
198
199    #[derive(Clone, Default, Serialize, Deserialize)]
200    struct TestState {
201        value: i32,
202    }
203
204    #[test]
205    fn test_module_shorthand() {
206        let def = HypenApp::module::<TestState>("MyModule")
207            .state(TestState { value: 42 })
208            .on_action::<()>("inc", |state, _, _| {
209                state.value += 1;
210            })
211            .build();
212
213        assert_eq!(def.name(), "MyModule");
214    }
215
216    #[test]
217    fn test_app_builder() {
218        let app = HypenApp::builder()
219            .component("Button", r#"Button { Text("Click") }"#)
220            .build();
221
222        assert!(app.components().has("Button"));
223    }
224
225    #[test]
226    fn test_app_with_routes() {
227        let app = HypenApp::builder()
228            .route(
229                "/",
230                HypenApp::module::<TestState>("Home")
231                    .state(TestState::default())
232                    .build(),
233            )
234            .route(
235                "/about",
236                HypenApp::module::<TestState>("About")
237                    .state(TestState::default())
238                    .build(),
239            )
240            .build();
241
242        let (_, module) = app.match_route("/").unwrap();
243        assert_eq!(module, "Home");
244
245        let (_, module) = app.match_route("/about").unwrap();
246        assert_eq!(module, "About");
247
248        assert!(app.match_route("/nonexistent").is_none());
249    }
250
251    #[test]
252    fn test_app_navigate() {
253        let app = HypenApp::default();
254        app.navigate("/users/42");
255        assert_eq!(app.router().current_path(), "/users/42");
256    }
257
258    #[test]
259    fn test_components_dir() {
260        let dir = std::env::temp_dir().join("hypen_test_app_components");
261        let _ = std::fs::remove_dir_all(&dir);
262        std::fs::create_dir_all(&dir).unwrap();
263
264        std::fs::write(dir.join("my-widget.hypen"), r#"Column { Text("Widget") }"#).unwrap();
265
266        let app = HypenApp::builder()
267            .components_dir(dir.to_str().unwrap())
268            .build();
269
270        assert!(app.components().has("MyWidget"));
271
272        let _ = std::fs::remove_dir_all(&dir);
273    }
274
275    #[test]
276    fn test_instantiate_module() {
277        let app = HypenApp::default();
278
279        let def = HypenApp::module::<TestState>("Test")
280            .state(TestState { value: 10 })
281            .on_action::<()>("double", |state, _, _| {
282                state.value *= 2;
283            })
284            .build();
285
286        let instance = app.instantiate(Arc::new(def)).unwrap();
287        instance.mount();
288        assert_eq!(instance.get_state().value, 10);
289
290        instance.dispatch_action("double", None).unwrap();
291        assert_eq!(instance.get_state().value, 20);
292    }
293}