1use anyhow::{Context, Result, bail};
2use std::cmp::Ordering;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RouteInfo {
9 pub relative_path: PathBuf,
11 pub file_path: PathBuf,
13 pub pattern: String,
15 pub variant_name: String,
17 pub params: Vec<String>,
19 pub layout_chain: Vec<PathBuf>,
21 pub is_fallback: bool,
23}
24
25impl RouteInfo {
26 pub fn cmp_priority(&self, other: &Self) -> Ordering {
31 let self_segments: Vec<&str> = self.pattern.split('/').filter(|s| !s.is_empty()).collect();
32 let other_segments: Vec<&str> = other.pattern.split('/').filter(|s| !s.is_empty()).collect();
33
34 let min_len = self_segments.len().min(other_segments.len());
35 for i in 0..min_len {
36 let s_dyn = self_segments[i].starts_with(':');
37 let o_dyn = other_segments[i].starts_with(':');
38
39 match (s_dyn, o_dyn) {
40 (false, true) => return Ordering::Less, (true, false) => return Ordering::Greater,
42 _ => {}
43 }
44 }
45
46 self_segments.len().cmp(&other_segments.len()).reverse()
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct RouteTree {
54 pub routes: Vec<RouteInfo>,
56 pub fallback: Option<RouteInfo>,
58}
59
60impl RouteTree {
61 pub fn scan(routes_dir: &Path) -> Result<Self> {
64 if !routes_dir.exists() || !routes_dir.is_dir() {
65 return Ok(RouteTree::default());
66 }
67
68 let mut tree = RouteTree::default();
69 let layouts = Vec::new();
70 let path_segments = Vec::new();
71 let variant_parts = Vec::new();
72
73 tree.scan_dir(routes_dir, routes_dir, &layouts, &path_segments, &variant_parts)?;
74
75 tree.validate()?;
76 tree.sort_by_priority();
77
78 Ok(tree)
79 }
80
81 fn scan_dir(
82 &mut self,
83 current_dir: &Path,
84 routes_root: &Path,
85 parent_layouts: &[PathBuf],
86 path_segments: &[PathSegment],
87 variant_parts: &[String],
88 ) -> Result<()> {
89 let entries = std::fs::read_dir(current_dir)
90 .with_context(|| format!("failed to read routes directory {:?}", current_dir))?;
91
92 let mut files = Vec::new();
93 let mut dirs = Vec::new();
94
95 for entry in entries {
96 let entry = entry.with_context(|| format!("failed to read entry in {:?}", current_dir))?;
97 let path = entry.path();
98 if path.is_dir() {
99 dirs.push(path);
100 } else if path.extension().is_some_and(|ext| ext == "fui") {
101 files.push(path);
102 }
103 }
104
105 dirs.sort();
107 files.sort();
108
109 let layout_file = current_dir.join("layout.fui");
111 let has_local_layout = layout_file.is_file();
112
113 let mut current_layouts = parent_layouts.to_vec();
114 if has_local_layout {
115 let is_stateful = if let Ok(source) = std::fs::read_to_string(&layout_file)
116 && let Ok(ast) = crate::parser::parse(&source)
117 {
118 ast.state_type.is_some()
119 } else {
120 false
121 };
122
123 if !is_stateful {
124 let rel_layout = layout_file
125 .strip_prefix(routes_root)
126 .unwrap_or(&layout_file)
127 .to_path_buf();
128 current_layouts.push(rel_layout);
129 }
130 }
131
132 for file in files {
134 let file_name = file.file_name().unwrap_or_default().to_string_lossy();
135 let stem = file.file_stem().unwrap_or_default().to_string_lossy();
136
137 if file_name == "layout.fui" {
138 continue; }
140
141 let rel_path = file.strip_prefix(routes_root).unwrap_or(&file).to_path_buf();
142
143 if file_name == "fallback.fui" {
144 if let Some(existing) = &self.fallback {
145 bail!(
146 "multiple fallback routes found: '{:?}' and '{:?}'",
147 existing.file_path,
148 file
149 );
150 }
151 self.fallback = Some(RouteInfo {
152 relative_path: rel_path,
153 file_path: file,
154 pattern: "*".to_string(),
155 variant_name: "Fallback".to_string(),
156 params: Vec::new(),
157 layout_chain: current_layouts.clone(),
158 is_fallback: true,
159 });
160 continue;
161 }
162
163 let mut file_path_segments = path_segments.to_vec();
165 let mut file_variant_parts = variant_parts.to_vec();
166
167 if stem == "index" {
168 } else {
170 let parsed_segment = parse_segment(&stem, &file)?;
171 match parsed_segment {
172 PathSegment::Static(name) => {
173 file_variant_parts.push(to_pascal_case(&name));
174 file_path_segments.push(PathSegment::Static(name));
175 }
176 PathSegment::Dynamic(param) => {
177 file_variant_parts.push(to_pascal_case(¶m));
178 file_path_segments.push(PathSegment::Dynamic(param));
179 }
180 PathSegment::Group(group_name) => {
181 bail!(
182 "files cannot represent route groups: standalone route file '{:?}' has group syntax '({})'",
183 file,
184 group_name
185 );
186 }
187 }
188 }
189
190 let pattern = build_pattern(&file_path_segments);
191 let params = extract_params(&file_path_segments);
192 let variant_name = if file_variant_parts.is_empty() {
193 "Index".to_string()
194 } else {
195 file_variant_parts.join("")
196 };
197
198 self.routes.push(RouteInfo {
199 relative_path: rel_path,
200 file_path: file,
201 pattern,
202 variant_name,
203 params,
204 layout_chain: current_layouts.clone(),
205 is_fallback: false,
206 });
207 }
208
209 for dir in dirs {
211 let dir_name = dir.file_name().unwrap_or_default().to_string_lossy();
212 let parsed_segment = parse_segment(&dir_name, &dir)?;
213
214 let mut sub_path_segments = path_segments.to_vec();
215 let mut sub_variant_parts = variant_parts.to_vec();
216
217 match parsed_segment {
218 PathSegment::Static(name) => {
219 sub_variant_parts.push(to_pascal_case(&name));
220 sub_path_segments.push(PathSegment::Static(name));
221 }
222 PathSegment::Dynamic(param) => {
223 sub_variant_parts.push(to_pascal_case(¶m));
224 sub_path_segments.push(PathSegment::Dynamic(param));
225 }
226 PathSegment::Group(_) => {
227 }
229 }
230
231 self.scan_dir(
232 &dir,
233 routes_root,
234 ¤t_layouts,
235 &sub_path_segments,
236 &sub_variant_parts,
237 )?;
238 }
239
240 Ok(())
241 }
242
243 pub fn validate(&self) -> Result<()> {
248 let mut patterns: HashMap<&str, &RouteInfo> = HashMap::new();
249 let mut variants: HashMap<&str, &RouteInfo> = HashMap::new();
250
251 if let Some(ref fb) = self.fallback {
252 patterns.insert(fb.pattern.as_str(), fb);
253 variants.insert(fb.variant_name.as_str(), fb);
254 }
255
256 for route in &self.routes {
257 let mut seen_params = std::collections::HashSet::new();
259 for param in &route.params {
260 if !seen_params.insert(param) {
261 bail!(
262 "duplicate dynamic parameter '{param}' in route '{}' ('{:?}')",
263 route.pattern,
264 route.file_path
265 );
266 }
267 }
268
269 if let Some(existing) = patterns.get(route.pattern.as_str()) {
271 bail!(
272 "duplicated route pattern '{}' found in '{:?}' and '{:?}'",
273 route.pattern,
274 existing.file_path,
275 route.file_path
276 );
277 }
278 patterns.insert(&route.pattern, route);
279
280 if let Some(existing) = variants.get(route.variant_name.as_str()) {
282 bail!(
283 "duplicated route variant name '{}' found in '{:?}' and '{:?}'",
284 route.variant_name,
285 existing.file_path,
286 route.file_path
287 );
288 }
289 variants.insert(&route.variant_name, route);
290 }
291
292 let mut layouts: std::collections::HashSet<&Path> = std::collections::HashSet::new();
294 for route in &self.routes {
295 for layout in &route.layout_chain {
296 layouts.insert(layout.as_path());
297 }
298 }
299 if let Some(ref fb) = self.fallback {
300 for layout in &fb.layout_chain {
301 layouts.insert(layout.as_path());
302 }
303 }
304
305 let mut layout_names: HashMap<String, &Path> = HashMap::new();
306 for layout in layouts {
307 let fn_name = layout_fn_name(layout);
308 if let Some(existing) = layout_names.get(&fn_name) {
309 bail!(
310 "duplicate generated layout function name '{}' from distinct layouts '{:?}' and '{:?}'",
311 fn_name,
312 existing,
313 layout
314 );
315 }
316 layout_names.insert(fn_name, layout);
317 }
318
319 Ok(())
320 }
321
322 pub fn sort_by_priority(&mut self) {
324 self.routes.sort_by(|a, b| a.cmp_priority(b));
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
329enum PathSegment {
330 Static(String),
331 Dynamic(String),
332 Group(String),
333}
334
335fn parse_segment(stem: &str, path: &Path) -> Result<PathSegment> {
336 if stem.starts_with('(') && stem.ends_with(')') {
337 let group_name = &stem[1..stem.len() - 1];
338 if group_name.is_empty() {
339 bail!("invalid route group syntax '()' in '{:?}'", path);
340 }
341 Ok(PathSegment::Group(group_name.to_string()))
342 } else if stem.starts_with('[') || stem.ends_with(']') {
343 if stem.starts_with('[') && stem.ends_with(']') {
344 let param_name = &stem[1..stem.len() - 1];
345 if !is_valid_param_name(param_name) {
346 bail!(
347 "invalid dynamic parameter syntax '[{}]' in '{:?}': parameter name must be a valid non-empty identifier",
348 param_name,
349 path
350 );
351 }
352 Ok(PathSegment::Dynamic(param_name.to_string()))
353 } else {
354 bail!("invalid dynamic parameter syntax '{}' in '{:?}'", stem, path);
355 }
356 } else {
357 Ok(PathSegment::Static(stem.to_string()))
358 }
359}
360
361fn is_valid_param_name(name: &str) -> bool {
362 const RUST_KEYWORDS: &[&str] = &[
363 "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn",
364 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self",
365 "Self", "static", "struct", "super", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
366 "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try", "typeof", "unsized", "virtual",
367 "yield",
368 ];
369
370 if name.is_empty() || RUST_KEYWORDS.contains(&name) {
371 return false;
372 }
373 let mut chars = name.chars();
374 let first = match chars.next() {
375 Some(c) => c,
376 None => return false,
377 };
378 if !first.is_ascii_alphabetic() && first != '_' {
379 return false;
380 }
381 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
382}
383
384fn build_pattern(segments: &[PathSegment]) -> String {
385 let mut pattern = String::new();
386 for seg in segments {
387 match seg {
388 PathSegment::Static(s) => {
389 pattern.push('/');
390 pattern.push_str(s);
391 }
392 PathSegment::Dynamic(d) => {
393 pattern.push('/');
394 pattern.push(':');
395 pattern.push_str(d);
396 }
397 PathSegment::Group(_) => {}
398 }
399 }
400 if pattern.is_empty() { "/".to_string() } else { pattern }
401}
402
403fn extract_params(segments: &[PathSegment]) -> Vec<String> {
404 segments
405 .iter()
406 .filter_map(|seg| match seg {
407 PathSegment::Dynamic(d) => Some(d.clone()),
408 _ => None,
409 })
410 .collect()
411}
412
413pub fn to_pascal_case(s: &str) -> String {
415 let mut result = String::new();
416 let mut capitalize_next = true;
417
418 for c in s.chars() {
419 if c == '_' || c == '-' || c == ' ' {
420 capitalize_next = true;
421 } else if c.is_ascii_alphanumeric() {
422 if capitalize_next {
423 result.push(c.to_ascii_uppercase());
424 capitalize_next = false;
425 } else {
426 result.push(c);
427 }
428 }
429 }
430
431 if result.is_empty() { "Route".to_string() } else { result }
432}
433
434fn validate_layout_outlet_count(layout_abs: &Path) -> Result<(), String> {
435 let source = std::fs::read_to_string(layout_abs).map_err(|e| format!("failed to read layout file: {e}"))?;
436 let doc = crate::parser::parse(&source)?;
437
438 fn count_outlets(el: &crate::parser::Element) -> usize {
439 let mut count = if el.name == "RouterOutlet" { 1 } else { 0 };
440 for child in &el.children {
441 if let crate::parser::Node::Element(child_el) = child {
442 count += count_outlets(child_el);
443 }
444 }
445 count
446 }
447
448 let count = count_outlets(&doc.root);
449 if count != 1 {
450 return Err(format!(
451 "layout file must contain exactly one <RouterOutlet /> (found {})",
452 count
453 ));
454 }
455 Ok(())
456}
457
458pub fn generate_router_code(tree: &RouteTree) -> String {
465 let mut out = String::new();
466
467 out.push_str("/// Generated Route enum representing all application screens.\n");
468 out.push_str("#[derive(Debug, Clone, PartialEq, Eq)]\n");
469 out.push_str("pub enum Route {\n");
470
471 for route in &tree.routes {
472 out.push_str(&format!(" /// Pattern: {}\n", route.pattern));
473 if route.params.is_empty() {
474 out.push_str(&format!(" {},\n", route.variant_name));
475 } else {
476 out.push_str(&format!(" {} {{\n", route.variant_name));
477 for param in &route.params {
478 out.push_str(&format!(" {}: String,\n", param));
479 }
480 out.push_str(" },\n");
481 }
482 }
483
484 if let Some(fb) = &tree.fallback {
485 out.push_str(" /// Fallback route\n");
486 out.push_str(&format!(" {},\n", fb.variant_name));
487 }
488
489 out.push_str("}\n\n");
490
491 out.push_str("impl Route {\n");
493 out.push_str(" /// Parse a deep link URI or path into a Route.\n");
494 out.push_str(" pub fn from_uri(uri: &str) -> Option<Self> {\n");
495 out.push_str(" let path = if let Some(pos) = uri.find(\"://\") {\n");
496 out.push_str(" &uri[pos + 3..]\n");
497 out.push_str(" } else {\n");
498 out.push_str(" uri\n");
499 out.push_str(" };\n");
500 out.push_str(" let path = path.split('?').next().unwrap_or(path).split('#').next().unwrap_or(path);\n");
501 out.push_str(" let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();\n\n");
502
503 for route in &tree.routes {
504 let pattern_segs: Vec<&str> = route.pattern.split('/').filter(|s| !s.is_empty()).collect();
505 if pattern_segs.is_empty() {
506 out.push_str(" if segs.is_empty() {\n");
507 out.push_str(&format!(" return Some(Route::{});\n", route.variant_name));
508 out.push_str(" }\n");
509 } else {
510 let mut pattern_match = String::new();
511 pattern_match.push('[');
512 for (i, seg) in pattern_segs.iter().enumerate() {
513 if i > 0 {
514 pattern_match.push_str(", ");
515 }
516 if let Some(param) = seg.strip_prefix(':') {
517 pattern_match.push_str(param);
518 } else {
519 pattern_match.push_str(&format!("{:?}", seg));
520 }
521 }
522 pattern_match.push(']');
523
524 out.push_str(&format!(" if let {} = segs.as_slice() {{\n", pattern_match));
525 if route.params.is_empty() {
526 out.push_str(&format!(" return Some(Route::{});\n", route.variant_name));
527 } else {
528 out.push_str(&format!(" return Some(Route::{} {{\n", route.variant_name));
529 for param in &route.params {
530 out.push_str(&format!(" {}: (*{}).to_string(),\n", param, param));
531 }
532 out.push_str(" });\n");
533 }
534 out.push_str(" }\n");
535 }
536 }
537
538 if tree.fallback.is_some() {
539 out.push_str(" Some(Route::Fallback)\n");
540 } else {
541 out.push_str(" None\n");
542 }
543
544 out.push_str(" }\n\n");
545
546 out.push_str(" /// Get canonical path for this route.\n");
548 out.push_str(" pub fn path(&self) -> String {\n");
549 out.push_str(" match self {\n");
550 for route in &tree.routes {
551 if route.params.is_empty() {
552 out.push_str(&format!(
553 " Route::{} => {:?}.to_string(),\n",
554 route.variant_name, route.pattern
555 ));
556 } else {
557 let mut fmt_str = route.pattern.clone();
558 let mut args = String::new();
559 for param in &route.params {
560 fmt_str = fmt_str.replace(&format!(":{}", param), &format!("{{{}}}", param));
561 args.push_str(&format!(", {} = {}", param, param));
562 }
563 out.push_str(&format!(
564 " Route::{} {{ {} }} => format!({:?}{}),\n",
565 route.variant_name,
566 route.params.join(", "),
567 fmt_str,
568 args
569 ));
570 }
571 }
572 if let Some(fb) = &tree.fallback {
573 out.push_str(&format!(
574 " Route::{} => \"*\".to_string(),\n",
575 fb.variant_name
576 ));
577 }
578 out.push_str(" }\n");
579 out.push_str(" }\n\n");
580
581 out.push_str(" /// Render the view hierarchy corresponding to this route.\n");
583 out.push_str(" pub fn render(&self) -> AnyElement {\n");
584 out.push_str(" match self {\n");
585
586 for route in &tree.routes {
587 let target_fn = format!("render_{}", crate::codegen::to_snake_case(&route.variant_name));
588
589 let mut target_call = if route.params.is_empty() {
590 format!("{}()", target_fn)
591 } else {
592 let param_args: Vec<String> = route.params.iter().map(|p| format!("{}.as_str()", p)).collect();
593 format!("{}({})", target_fn, param_args.join(", "))
594 };
595
596 for layout_rel in route.layout_chain.iter().rev() {
598 let mut routes_root = route.file_path.clone();
599 for _ in route.relative_path.components() {
600 routes_root.pop();
601 }
602 let layout_abs = routes_root.join(layout_rel);
603 if let Err(err) = validate_layout_outlet_count(&layout_abs) {
604 panic!(
605 "route-level diagnostic: layout file '{}' is invalid: {}",
606 layout_rel.display(),
607 err
608 );
609 }
610 let layout_fn = layout_fn_name(layout_rel);
611 target_call = format!("{}({})", layout_fn, target_call);
612 }
613
614 if route.params.is_empty() {
615 out.push_str(&format!(
616 " Route::{} => {}.into_any_element(),\n",
617 route.variant_name, target_call
618 ));
619 } else {
620 out.push_str(&format!(
621 " Route::{} {{ {} }} => {}.into_any_element(),\n",
622 route.variant_name,
623 route.params.join(", "),
624 target_call
625 ));
626 }
627 }
628
629 if let Some(fb) = &tree.fallback {
630 let mut target_call = "render_fallback()".to_string();
631 for layout_rel in fb.layout_chain.iter().rev() {
632 let mut routes_root = fb.file_path.clone();
633 for _ in fb.relative_path.components() {
634 routes_root.pop();
635 }
636 let layout_abs = routes_root.join(layout_rel);
637 if let Err(err) = validate_layout_outlet_count(&layout_abs) {
638 panic!(
639 "route-level diagnostic: layout file '{}' is invalid: {}",
640 layout_rel.display(),
641 err
642 );
643 }
644 let layout_fn = layout_fn_name(layout_rel);
645 target_call = format!("{}({})", layout_fn, target_call);
646 }
647 out.push_str(&format!(
648 " Route::{} => {}.into_any_element(),\n",
649 fb.variant_name, target_call
650 ));
651 }
652
653 out.push_str(" }\n");
654 out.push_str(" }\n");
655 out.push_str("}\n\n");
656
657 out.push_str("/// Render the active route outlet.\n");
658 out.push_str("pub fn render_router_outlet(route: &Route) -> AnyElement {\n");
659 out.push_str(" route.render()\n");
660 out.push_str("}\n\n");
661
662 out
663}
664
665fn layout_fn_name(layout_rel: &Path) -> String {
666 let parent = layout_rel.parent().unwrap_or_else(|| Path::new(""));
667 let parent_str = parent.to_string_lossy();
668 if parent_str.is_empty() {
669 "render_layout".to_string()
670 } else {
671 let pascal = to_pascal_case(&parent_str.replace('/', "_"));
672 format!("render_{}_layout", crate::codegen::to_snake_case(&pascal))
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use std::sync::atomic::{AtomicUsize, Ordering};
680
681 static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
682
683 fn test_routes_dir() -> (PathBuf, PathBuf) {
684 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
685 let pid = std::process::id();
686 let root = std::env::temp_dir().join(format!("fncc_router_test_{pid}_{id}"));
687 let routes = root.join("routes");
688 std::fs::create_dir_all(&routes).unwrap();
689 (root, routes)
690 }
691
692 #[test]
693 fn test_scan_basic_routes() {
694 let (root, routes) = test_routes_dir();
695 std::fs::write(routes.join("index.fui"), "<Text>Home</Text>").unwrap();
696 std::fs::write(routes.join("settings.fui"), "<Text>Settings</Text>").unwrap();
697
698 let tree = RouteTree::scan(&routes).unwrap();
699
700 assert_eq!(tree.routes.len(), 2);
701 let home = tree.routes.iter().find(|r| r.pattern == "/").unwrap();
702 assert_eq!(home.variant_name, "Index");
703
704 let settings = tree.routes.iter().find(|r| r.pattern == "/settings").unwrap();
705 assert_eq!(settings.variant_name, "Settings");
706
707 std::fs::remove_dir_all(&root).unwrap();
708 }
709
710 #[test]
711 fn test_scan_dynamic_and_groups() {
712 let (root, routes) = test_routes_dir();
713
714 let app_dir = routes.join("(app)");
716 std::fs::create_dir_all(&app_dir).unwrap();
717 std::fs::write(app_dir.join("dashboard.fui"), "<Text>Dashboard</Text>").unwrap();
718
719 let users_dir = routes.join("users");
721 std::fs::create_dir_all(&users_dir).unwrap();
722 std::fs::write(users_dir.join("[id].fui"), "<Text>User</Text>").unwrap();
723
724 let tree = RouteTree::scan(&routes).unwrap();
725
726 let dash = tree.routes.iter().find(|r| r.pattern == "/dashboard").unwrap();
727 assert_eq!(dash.variant_name, "Dashboard");
728
729 let user = tree.routes.iter().find(|r| r.pattern == "/users/:id").unwrap();
730 assert_eq!(user.variant_name, "UsersId");
731 assert_eq!(user.params, vec!["id"]);
732
733 std::fs::remove_dir_all(&root).unwrap();
734 }
735
736 #[test]
737 fn test_scan_layouts() {
738 let (root, routes) = test_routes_dir();
739
740 std::fs::write(routes.join("layout.fui"), "<RootLayout />").unwrap();
741 std::fs::write(routes.join("index.fui"), "<Home />").unwrap();
742
743 let dash_dir = routes.join("dashboard");
744 std::fs::create_dir_all(&dash_dir).unwrap();
745 std::fs::write(dash_dir.join("layout.fui"), "<DashLayout />").unwrap();
746 std::fs::write(dash_dir.join("index.fui"), "<Dash />").unwrap();
747
748 let tree = RouteTree::scan(&routes).unwrap();
749
750 let home = tree.routes.iter().find(|r| r.pattern == "/").unwrap();
751 assert_eq!(home.layout_chain, vec![PathBuf::from("layout.fui")]);
752
753 let dash = tree.routes.iter().find(|r| r.pattern == "/dashboard").unwrap();
754 assert_eq!(
755 dash.layout_chain,
756 vec![PathBuf::from("layout.fui"), PathBuf::from("dashboard/layout.fui")]
757 );
758
759 std::fs::remove_dir_all(&root).unwrap();
760 }
761
762 #[test]
763 fn test_fallback_route() {
764 let (root, routes) = test_routes_dir();
765
766 std::fs::write(routes.join("fallback.fui"), "<NotFound />").unwrap();
767
768 let tree = RouteTree::scan(&routes).unwrap();
769
770 assert!(tree.fallback.is_some());
771 let fb = tree.fallback.unwrap();
772 assert_eq!(fb.variant_name, "Fallback");
773 assert_eq!(fb.pattern, "*");
774
775 std::fs::remove_dir_all(&root).unwrap();
776 }
777
778 #[test]
779 fn test_validation_duplicate_pattern_errors() {
780 let (root, routes) = test_routes_dir();
781
782 std::fs::write(routes.join("users.fui"), "<Users />").unwrap();
783 let users_dir = routes.join("users");
784 std::fs::create_dir_all(&users_dir).unwrap();
785 std::fs::write(users_dir.join("index.fui"), "<UsersIndex />").unwrap();
786
787 let res = RouteTree::scan(&routes);
788 assert!(res.is_err());
789 let err = res.unwrap_err().to_string();
790 assert!(err.contains("duplicated route pattern '/users'"));
791
792 std::fs::remove_dir_all(&root).unwrap();
793 }
794
795 #[test]
796 fn test_validation_invalid_param_syntax_errors() {
797 let (root, routes) = test_routes_dir();
798
799 let users_dir = routes.join("users");
800 std::fs::create_dir_all(&users_dir).unwrap();
801 std::fs::write(users_dir.join("[123].fui"), "<Invalid />").unwrap();
802
803 let res = RouteTree::scan(&routes);
804 assert!(res.is_err());
805 let err = res.unwrap_err().to_string();
806 assert!(err.contains("invalid dynamic parameter syntax"));
807
808 std::fs::remove_dir_all(&root).unwrap();
809 }
810
811 #[test]
812 fn test_generate_router_code() {
813 let (root, routes) = test_routes_dir();
814
815 std::fs::write(routes.join("layout.fui"), "<RootLayout><RouterOutlet /></RootLayout>").unwrap();
816 std::fs::write(routes.join("index.fui"), "<Home />").unwrap();
817 std::fs::write(routes.join("settings.fui"), "<Settings />").unwrap();
818
819 let users_dir = routes.join("users");
820 std::fs::create_dir_all(&users_dir).unwrap();
821 std::fs::write(users_dir.join("[id].fui"), "<UserDetail />").unwrap();
822
823 std::fs::write(routes.join("fallback.fui"), "<NotFound />").unwrap();
824
825 let tree = RouteTree::scan(&routes).unwrap();
826 let code = generate_router_code(&tree);
827
828 assert!(code.contains("pub enum Route"));
829 assert!(code.contains("Index,"));
830 assert!(code.contains("Settings,"));
831 assert!(code.contains("UsersId {"));
832 assert!(code.contains("id: String,"));
833 assert!(code.contains("Fallback,"));
834
835 assert!(code.contains("pub fn from_uri(uri: &str) -> Option<Self>"));
836 assert!(code.contains("if let [\"settings\"] = segs.as_slice()"));
837 assert!(code.contains("if let [\"users\", id] = segs.as_slice()"));
838
839 assert!(code.contains("pub fn path(&self) -> String"));
840 assert!(code.contains("pub fn render(&self) -> AnyElement"));
841 assert!(code.contains("pub fn render_router_outlet(route: &Route) -> AnyElement"));
842
843 std::fs::remove_dir_all(&root).unwrap();
844 }
845
846 #[test]
847 fn test_scan_group_file_error() {
848 let (root, routes) = test_routes_dir();
849
850 std::fs::write(routes.join("(app).fui"), "<Invalid />").unwrap();
851
852 let res = RouteTree::scan(&routes);
853 assert!(res.is_err());
854 let err = res.unwrap_err().to_string();
855 assert!(err.contains("files cannot represent route groups"));
856
857 std::fs::remove_dir_all(&root).unwrap();
858 }
859
860 #[test]
861 fn test_route_priority_static_over_dynamic() {
862 let (root, routes) = test_routes_dir();
863
864 let users_dir = routes.join("users");
865 std::fs::create_dir_all(&users_dir).unwrap();
866 std::fs::write(users_dir.join("settings.fui"), "<Text>Settings</Text>").unwrap();
867 std::fs::write(users_dir.join("[id].fui"), "<Text>UserDetail</Text>").unwrap();
868
869 let tree = RouteTree::scan(&routes).unwrap();
870
871 assert_eq!(tree.routes.len(), 2);
872 assert_eq!(tree.routes[0].pattern, "/users/settings");
873 assert_eq!(tree.routes[1].pattern, "/users/:id");
874
875 std::fs::remove_dir_all(&root).unwrap();
876 }
877
878 #[test]
879 #[should_panic]
880 fn test_layout_validation_duplicate_outlets() {
881 let (root, routes) = test_routes_dir();
882
883 std::fs::write(
884 routes.join("layout.fui"),
885 "<Stack><RouterOutlet /><RouterOutlet /></Stack>",
886 )
887 .unwrap();
888 std::fs::write(routes.join("index.fui"), "<Home />").unwrap();
889
890 let tree = RouteTree::scan(&routes).unwrap();
891 let _code = generate_router_code(&tree);
892
893 std::fs::remove_dir_all(&root).unwrap();
894 }
895}