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
9pub struct HypenApp {
43 context: Arc<GlobalContext>,
44 router: Arc<HypenRouter>,
45 components: ComponentRegistry,
46 routes: Vec<RouteEntry>,
47}
48
49struct 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 pub fn module<S: State>(name: impl Into<String>) -> ModuleBuilder<S> {
73 ModuleBuilder::new(name)
74 }
75
76 pub fn context(&self) -> &GlobalContext {
78 &self.context
79 }
80
81 pub fn router(&self) -> &HypenRouter {
83 &self.router
84 }
85
86 pub fn components(&self) -> &ComponentRegistry {
88 &self.components
89 }
90
91 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 pub fn navigate(&self, path: &str) {
101 self.router.push(path);
102 }
103
104 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
129pub struct HypenAppBuilder {
131 components: ComponentRegistry,
132 routes: Vec<RouteEntry>,
133 components_dirs: Vec<String>,
134}
135
136impl HypenAppBuilder {
137 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 if let Some(source) = definition.ui_source() {
154 self.components.register(definition.name(), source, None);
155 }
156 self
157 }
158
159 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 pub fn components_dir(mut self, dir: impl Into<String>) -> Self {
167 self.components_dirs.push(dir.into());
168 self
169 }
170
171 pub fn build(mut self) -> HypenApp {
173 for dir in &self.components_dirs {
175 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}