Skip to main content

fncc_core/
router.rs

1use anyhow::{Context, Result, bail};
2use std::cmp::Ordering;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6/// Information about a single route discovered by scanning the routes directory.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RouteInfo {
9    /// Relative path from the routes root directory (e.g., "users/[id].fui").
10    pub relative_path: PathBuf,
11    /// Absolute file path to the `.fui` file.
12    pub file_path: PathBuf,
13    /// Canonical URL pattern (e.g., "/", "/settings", "/users/:id").
14    pub pattern: String,
15    /// Generated PascalCase enum variant name (e.g., "Index", "Settings", "UsersId").
16    pub variant_name: String,
17    /// Dynamic parameter names extracted from path segments (e.g., ["id"]).
18    pub params: Vec<String>,
19    /// Relative paths to layout files wrapping this route, from outer to inner.
20    pub layout_chain: Vec<PathBuf>,
21    /// True if this is the application fallback screen (`fallback.fui`).
22    pub is_fallback: bool,
23}
24
25impl RouteInfo {
26    /// Compare priority for route resolution.
27    /// Priority order (highest to lowest):
28    /// 1. Positional static segments take precedence over dynamic segments.
29    /// 2. Greater total segment count.
30    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, // static wins over dynamic
41                (true, false) => return Ordering::Greater,
42                _ => {}
43            }
44        }
45
46        // More specific segment count wins
47        self_segments.len().cmp(&other_segments.len()).reverse()
48    }
49}
50
51/// Scanned route tree containing all discovered routes and layout hierarchy.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct RouteTree {
54    /// Discovered routes sorted by resolution priority.
55    pub routes: Vec<RouteInfo>,
56    /// Fallback route (`fallback.fui`), if present.
57    pub fallback: Option<RouteInfo>,
58}
59
60impl RouteTree {
61    /// Scan the `routes` directory (typically `src/ui/routes/` or `<ui_dir>/routes`)
62    /// and build a validated `RouteTree`.
63    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        // Sort for deterministic traversal order
106        dirs.sort();
107        files.sort();
108
109        // Check if current directory has a layout.fui
110        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        // Process files in current directory
133        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; // Layouts are processed via current_layouts chain
139            }
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            // Route processing
164            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                // Index route represents the directory itself
169            } 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(&param));
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        // Process subdirectories
210        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(&param));
224                    sub_path_segments.push(PathSegment::Dynamic(param));
225                }
226                PathSegment::Group(_) => {
227                    // Group directories are pathless — do not alter path or variant name
228                }
229            }
230
231            self.scan_dir(
232                &dir,
233                routes_root,
234                &current_layouts,
235                &sub_path_segments,
236                &sub_variant_parts,
237            )?;
238        }
239
240        Ok(())
241    }
242
243    /// Perform compile-time validations on the discovered routes:
244    /// - Duplicated route patterns
245    /// - Duplicated enum variant names
246    /// - Duplicate parameter names in a single route
247    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            // Validate duplicate parameter names within single route
258            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            // Validate duplicate route pattern
270            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            // Validate duplicate route variant name
281            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        // Validate unique layout function names
293        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    /// Sort routes by priority: static routes first, then dynamic routes.
323    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
413/// Convert a string segment to PascalCase for enum variant names.
414pub 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
458/// Generate Rust code for the compile-time router:
459/// - `pub enum Route` with variants for all screens and fallback
460/// - `Route::from_uri(&str) -> Option<Route>` for deep link parsing
461/// - `Route::path(&self) -> String` for path serialization
462/// - `Route::render(&self) -> impl IntoElement` for rendering screen + nested layouts
463/// - `pub fn render_router_outlet(route: &Route) -> impl IntoElement`
464pub 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    // impl Route
492    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    // pub fn path(&self) -> String
547    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    // pub fn render(&self) -> AnyElement
582    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        // Wrap target_call in layout_chain from innermost to outermost
597        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        // (app)/dashboard.fui
715        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        // users/[id].fui
720        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}