Skip to main content

webui_handler/
route_matcher.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Iterative path template matcher for server-side route resolution.
5//!
6//! Matches request paths against route path templates without regex.
7//! Supports `:param`, `:param?` (optional), and `*splat` segments.
8
9use std::borrow::Cow;
10use std::collections::HashMap;
11use webui_protocol::{web_ui_fragment::Fragment, WebUIProtocol};
12
13/// Result of matching a request path against a route path template.
14#[derive(Debug, Clone)]
15pub struct RouteMatch {
16    /// Bound parameter values from the path.
17    pub params: HashMap<String, String>,
18    /// How many literal (non-param) segments matched exactly.
19    pub specificity: usize,
20    /// How many request path segments were consumed by the match.
21    /// Used by nested routes to compute the child route base.
22    pub consumed_segments: usize,
23}
24
25/// A single parsed segment from a path template.
26#[derive(Debug, Clone)]
27enum SegmentPattern {
28    /// Literal segment (e.g., "users").
29    Literal(String),
30    /// Named parameter (e.g., `:id`).
31    Param(String),
32    /// Optional named parameter (e.g., `:id?`).
33    OptionalParam(String),
34    /// Splat / catch-all (e.g., `*rest` or `*`).
35    Splat(String),
36}
37
38/// Immutable index of pre-compiled authored route patterns.
39///
40/// Absolute paths are matched from the request root. Relative paths reuse the
41/// same compiled suffix and begin matching after the already-consumed parent
42/// route segments, so request values never become cache keys.
43#[derive(Debug)]
44pub(crate) struct CompiledRouteIndex {
45    patterns: HashMap<String, Vec<SegmentPattern>>,
46}
47
48impl CompiledRouteIndex {
49    /// Compile every authored route path in the protocol.
50    #[must_use]
51    pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
52        let mut patterns = HashMap::new();
53        let mut pending = Vec::new();
54
55        for fragment_list in protocol.fragments.values() {
56            for fragment in &fragment_list.fragments {
57                if let Some(Fragment::Route(route)) = fragment.fragment.as_ref() {
58                    pending.push(route);
59                }
60            }
61        }
62
63        while let Some(route) = pending.pop() {
64            patterns
65                .entry(route.path.clone())
66                .or_insert_with(|| parse_template(&route.path));
67            pending.extend(route.children.iter());
68        }
69
70        Self { patterns }
71    }
72
73    fn get(&self, template: &str) -> Option<&[SegmentPattern]> {
74        self.patterns.get(template).map(Vec::as_slice)
75    }
76}
77
78/// Match a route template against pre-split request path segments.
79///
80/// Relative templates are matched as precompiled suffixes after the parent
81/// route base. This avoids constructing and compiling request-specific paths.
82pub(crate) fn match_route_indexed_with_segments(
83    index: &CompiledRouteIndex,
84    template: &str,
85    route_base: &str,
86    request_segments: &[&str],
87    exact: bool,
88) -> Option<RouteMatch> {
89    let patterns = index.get(template)?;
90    let base_segments = if template.is_empty() || is_relative_path(template) {
91        route_base
92            .split('/')
93            .filter(|segment| !segment.is_empty())
94            .count()
95    } else {
96        0
97    };
98    let remaining = request_segments.get(base_segments..)?;
99    let mut route_match = try_match(patterns, remaining, exact)?;
100    route_match.specificity += base_segments;
101    route_match.consumed_segments += base_segments;
102    Some(route_match)
103}
104
105/// Split a request path into segments, filtering empty parts.
106///
107/// Intended to be called once before matching sibling routes.
108pub fn split_request_path(path: &str) -> Vec<&str> {
109    split_path(path)
110}
111
112/// Parse a path template string into segment patterns.
113fn parse_template(template: &str) -> Vec<SegmentPattern> {
114    let mut segments = Vec::new();
115    let template = template.strip_prefix("./").unwrap_or(template);
116
117    for part in template.split('/') {
118        if part.is_empty() {
119            continue;
120        }
121
122        if let Some(rest) = part.strip_prefix('*') {
123            let name = if rest.is_empty() {
124                "rest".to_string()
125            } else {
126                rest.to_string()
127            };
128            segments.push(SegmentPattern::Splat(name));
129        } else if let Some(rest) = part.strip_prefix(':') {
130            if let Some(name) = rest.strip_suffix('?') {
131                segments.push(SegmentPattern::OptionalParam(name.to_string()));
132            } else {
133                segments.push(SegmentPattern::Param(rest.to_string()));
134            }
135        } else {
136            segments.push(SegmentPattern::Literal(part.to_string()));
137        }
138    }
139
140    segments
141}
142
143/// Split a request path into segments, filtering empty parts.
144fn split_path(path: &str) -> Vec<&str> {
145    path.split('/').filter(|s| !s.is_empty()).collect()
146}
147
148/// Convert an ASCII hex digit to its numeric value.
149pub(crate) fn hex_val(b: u8) -> Option<u8> {
150    match b {
151        b'0'..=b'9' => Some(b - b'0'),
152        b'a'..=b'f' => Some(b - b'a' + 10),
153        b'A'..=b'F' => Some(b - b'A' + 10),
154        _ => None,
155    }
156}
157
158/// Percent-decode a URL path segment (e.g. `%2F` → `/`, `%20` → ` `).
159///
160/// Returns `None` if the input contains malformed percent-encoding (a `%` not
161/// followed by two hex digits) or if the decoded bytes are not valid UTF-8.
162fn percent_decode(input: &str) -> Option<String> {
163    let bytes = input.as_bytes();
164    if !bytes.contains(&b'%') {
165        return Some(input.to_owned());
166    }
167    let mut out = Vec::with_capacity(bytes.len());
168    let mut i = 0;
169    while i < bytes.len() {
170        if bytes[i] == b'%' {
171            if i + 2 >= bytes.len() {
172                return None;
173            }
174            let hi = hex_val(bytes[i + 1])?;
175            let lo = hex_val(bytes[i + 2])?;
176            out.push(hi << 4 | lo);
177            i += 3;
178        } else {
179            out.push(bytes[i]);
180            i += 1;
181        }
182    }
183    String::from_utf8(out).ok()
184}
185
186/// Validate and percent-decode a single route parameter segment.
187///
188/// Returns `None` (rejecting the match) if the segment:
189/// - contains malformed percent-encoding,
190/// - decodes to `..` (path traversal), or
191/// - contains a NUL byte (`\0`).
192fn validate_param(value: &str) -> Option<String> {
193    let decoded = percent_decode(value)?;
194    if decoded == ".." || decoded.bytes().any(|b| b == 0) {
195        return None;
196    }
197    Some(decoded)
198}
199
200/// Try matching a request path against pre-parsed route template patterns.
201///
202/// Returns `Some(RouteMatch)` if the path matches, `None` otherwise.
203fn try_match(
204    patterns: &[SegmentPattern],
205    request_segments: &[&str],
206    exact: bool,
207) -> Option<RouteMatch> {
208    let mut params = HashMap::new();
209    let mut specificity: usize = 0;
210    let mut req_idx = 0;
211    let mut pat_idx = 0;
212
213    while pat_idx < patterns.len() {
214        match &patterns[pat_idx] {
215            SegmentPattern::Literal(expected) => {
216                if req_idx >= request_segments.len() {
217                    return None;
218                }
219                if request_segments[req_idx] != expected.as_str() {
220                    return None;
221                }
222                specificity += 1;
223                req_idx += 1;
224            }
225            SegmentPattern::Param(name) => {
226                if req_idx >= request_segments.len() {
227                    return None;
228                }
229                params.insert(name.clone(), validate_param(request_segments[req_idx])?);
230                req_idx += 1;
231            }
232            SegmentPattern::OptionalParam(name) => {
233                if req_idx < request_segments.len() {
234                    params.insert(name.clone(), validate_param(request_segments[req_idx])?);
235                    req_idx += 1;
236                }
237                // Optional — ok to skip
238            }
239            SegmentPattern::Splat(name) => {
240                // Splat consumes all remaining segments; validate each individually.
241                let remaining = &request_segments[req_idx..];
242                let mut decoded_parts = Vec::with_capacity(remaining.len());
243                for seg in remaining {
244                    decoded_parts.push(validate_param(seg)?);
245                }
246                params.insert(name.clone(), decoded_parts.join("/"));
247                req_idx = request_segments.len();
248            }
249        }
250        pat_idx += 1;
251    }
252
253    // If exact matching required, all request segments must be consumed
254    if exact && req_idx < request_segments.len() {
255        return None;
256    }
257
258    // All pattern segments must be consumed (non-optional ones already checked)
259    // But remaining patterns must all be optional
260    while pat_idx < patterns.len() {
261        match &patterns[pat_idx] {
262            SegmentPattern::OptionalParam(_) | SegmentPattern::Splat(_) => {}
263            _ => return None,
264        }
265        pat_idx += 1;
266    }
267
268    Some(RouteMatch {
269        params,
270        specificity,
271        consumed_segments: req_idx,
272    })
273}
274
275/// Match a single route template against a request path.
276///
277/// Returns `Some(RouteMatch)` if the path matches, `None` otherwise.
278/// Used by the SSR route pruning to check individual `<f-route>` elements.
279pub fn match_single_route(template: &str, request_path: &str, exact: bool) -> Option<RouteMatch> {
280    let request_segments = split_path(request_path);
281    let patterns = parse_template(template);
282    try_match(&patterns, &request_segments, exact)
283}
284
285/// Check whether a route path is relative (does NOT start with `/`).
286pub fn is_relative_path(path: &str) -> bool {
287    !path.is_empty() && !path.starts_with('/')
288}
289
290/// Resolve a route path against a base path.
291///
292/// - Relative paths (`topics/:id` or `./topics/:id`) are prepended with the base.
293/// - Absolute paths (`/topics/:id`) are returned unchanged.
294///
295/// `"topics/:id"` + `"/sections/1"` → `"/sections/1/topics/:id"`
296/// `"./topics/:id"` + `"/sections/1"` → `"/sections/1/topics/:id"`
297/// `"/topics/:id"` + `"/sections/1"` → `"/topics/:id"`
298pub fn resolve_route_path(path: &str, route_base: &str) -> String {
299    resolve_route_path_cow(path, route_base).into_owned()
300}
301
302/// Resolve a route path while borrowing absolute paths that need no changes.
303pub(crate) fn resolve_route_path_cow<'a>(path: &'a str, route_base: &str) -> Cow<'a, str> {
304    // An empty path means "match at the parent level" — resolve to base.
305    if path.is_empty() {
306        return Cow::Owned(route_base.to_string());
307    }
308
309    if !is_relative_path(path) {
310        return Cow::Borrowed(path);
311    }
312
313    // Strip leading "./" if present
314    let relative = path.strip_prefix("./").unwrap_or(path);
315    if relative.is_empty() {
316        return Cow::Owned(route_base.to_string());
317    }
318
319    let mut resolved = String::with_capacity(route_base.len() + relative.len() + 1);
320    resolved.push_str(route_base);
321    if !route_base.ends_with('/') {
322        resolved.push('/');
323    }
324    resolved.push_str(relative);
325    Cow::Owned(resolved)
326}
327
328/// Compute the new route base from a matched route's consumed request segments.
329///
330/// Given request path `/sections/1/topics/react` and consumed=2,
331/// returns `/sections/1`.
332pub fn compute_route_base(request_path: &str, consumed_segments: usize) -> String {
333    if consumed_segments == 0 {
334        return "/".to_string();
335    }
336
337    let mut base = String::with_capacity(request_path.len());
338    let mut remaining = consumed_segments;
339    for part in request_path
340        .split('/')
341        .filter(|segment| !segment.is_empty())
342    {
343        if remaining == 0 {
344            break;
345        }
346        base.push('/');
347        base.push_str(part);
348        remaining -= 1;
349    }
350    if base.is_empty() {
351        base.push('/');
352    }
353    base
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
360
361    #[test]
362    fn test_match_single_route_exact() {
363        let m = match_single_route("/contacts", "/contacts", true).unwrap();
364        assert!(m.params.is_empty());
365    }
366
367    #[test]
368    fn test_match_single_route_param() {
369        let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
370        assert_eq!(m.params["id"], "42");
371    }
372
373    #[test]
374    fn test_match_single_route_no_match_extra() {
375        assert!(match_single_route("/contacts", "/contacts/123", true).is_none());
376    }
377
378    #[test]
379    fn test_match_single_route_multiple_params() {
380        let m = match_single_route("/profile/:id/view/:section", "/profile/123/view/bio", true)
381            .unwrap();
382        assert_eq!(m.params["id"], "123");
383        assert_eq!(m.params["section"], "bio");
384    }
385
386    #[test]
387    fn test_match_single_route_splat() {
388        let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
389        assert_eq!(m.params["path"], "docs/readme.md");
390    }
391
392    #[test]
393    fn test_match_single_route_optional_param_present() {
394        let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
395        assert_eq!(m.params["query"], "hello");
396    }
397
398    #[test]
399    fn test_match_single_route_optional_param_absent() {
400        let m = match_single_route("/search/:query?", "/search", true).unwrap();
401        assert!(!m.params.contains_key("query"));
402    }
403
404    #[test]
405    fn test_parse_template_segments() {
406        let segs = parse_template("/users/:id/posts/:postId");
407        assert_eq!(segs.len(), 4);
408        assert!(matches!(&segs[0], SegmentPattern::Literal(s) if s == "users"));
409        assert!(matches!(&segs[1], SegmentPattern::Param(s) if s == "id"));
410        assert!(matches!(&segs[2], SegmentPattern::Literal(s) if s == "posts"));
411        assert!(matches!(&segs[3], SegmentPattern::Param(s) if s == "postId"));
412    }
413
414    #[test]
415    fn test_match_single_route_non_exact_prefix() {
416        let m = match_single_route("/", "/anything", false).unwrap();
417        assert!(m.params.is_empty());
418    }
419
420    // ── Consumed segments tests ──
421
422    #[test]
423    fn test_consumed_segments_exact() {
424        let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
425        assert_eq!(m.consumed_segments, 2);
426    }
427
428    #[test]
429    fn test_consumed_segments_prefix() {
430        let m = match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
431        assert_eq!(m.consumed_segments, 2);
432    }
433
434    #[test]
435    fn test_consumed_segments_root() {
436        let m = match_single_route("/", "/", true).unwrap();
437        assert_eq!(m.consumed_segments, 0);
438    }
439
440    #[test]
441    fn test_consumed_segments_splat() {
442        let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
443        assert_eq!(m.consumed_segments, 3);
444    }
445
446    // ── Relative path resolution tests ──
447
448    #[test]
449    fn test_is_relative_path() {
450        assert!(is_relative_path("./sections/:id"));
451        assert!(is_relative_path("./"));
452        assert!(is_relative_path("sections/:id")); // bare relative
453        assert!(is_relative_path("topics/:topicId")); // bare relative
454        assert!(!is_relative_path("/sections/:id")); // absolute
455        assert!(!is_relative_path("")); // empty
456    }
457
458    #[test]
459    fn test_resolve_route_path_dotslash_relative() {
460        assert_eq!(
461            resolve_route_path("./topics/:id", "/sections/1"),
462            "/sections/1/topics/:id"
463        );
464    }
465
466    #[test]
467    fn test_resolve_route_path_bare_relative() {
468        assert_eq!(
469            resolve_route_path("topics/:id", "/sections/1"),
470            "/sections/1/topics/:id"
471        );
472    }
473
474    #[test]
475    fn test_resolve_route_path_relative_at_root() {
476        assert_eq!(resolve_route_path("./sections/:id", "/"), "/sections/:id");
477        assert_eq!(resolve_route_path("sections/:id", "/"), "/sections/:id");
478    }
479
480    #[test]
481    fn test_resolve_route_path_absolute_unchanged() {
482        assert_eq!(
483            resolve_route_path("/sections/:id", "/some/base"),
484            "/sections/:id"
485        );
486    }
487
488    #[test]
489    fn test_resolve_route_path_deep_nesting() {
490        assert_eq!(
491            resolve_route_path("./lessons/:lessonId", "/sections/1/topics/react"),
492            "/sections/1/topics/react/lessons/:lessonId"
493        );
494        assert_eq!(
495            resolve_route_path("lessons/:lessonId", "/sections/1/topics/react"),
496            "/sections/1/topics/react/lessons/:lessonId"
497        );
498    }
499
500    #[test]
501    fn test_resolve_route_path_empty_resolves_to_base() {
502        assert_eq!(resolve_route_path("", "/search"), "/search");
503        assert_eq!(resolve_route_path("", "/"), "/");
504        assert_eq!(resolve_route_path("", "/a/b/c"), "/a/b/c");
505    }
506
507    #[test]
508    fn test_empty_child_route_matches_parent_path_exactly() {
509        // Simulates <route path="search"> <route path="" exact /> when visiting /search
510        let resolved = resolve_route_path("", "/search");
511        let m = match_single_route(&resolved, "/search", true);
512        assert!(m.is_some(), "empty child route must match parent path");
513        let m = m.unwrap();
514        assert_eq!(m.consumed_segments, 1);
515    }
516
517    // ── Compute route base tests ──
518
519    #[test]
520    fn test_compute_route_base_two_segments() {
521        assert_eq!(
522            compute_route_base("/sections/1/topics/react", 2),
523            "/sections/1"
524        );
525    }
526
527    #[test]
528    fn test_compute_route_base_zero() {
529        assert_eq!(compute_route_base("/sections/1", 0), "/");
530    }
531
532    #[test]
533    fn test_compute_route_base_all_segments() {
534        assert_eq!(
535            compute_route_base("/sections/1/topics/react", 4),
536            "/sections/1/topics/react"
537        );
538    }
539
540    #[test]
541    fn test_compute_route_base_exceeds_segments() {
542        assert_eq!(compute_route_base("/sections/1", 10), "/sections/1");
543    }
544
545    #[test]
546    fn test_param_rejects_traversal() {
547        // Exact `..` segment is path traversal — must reject.
548        assert!(match_single_route("/files/:name", "/files/..", true).is_none());
549    }
550
551    #[test]
552    fn test_param_allows_double_dot_prefix() {
553        // `..something` is NOT traversal (not a standalone `..` segment).
554        let m = match_single_route("/files/:name", "/files/..something", true).unwrap();
555        assert_eq!(m.params["name"], "..something");
556    }
557
558    #[test]
559    fn test_param_rejects_null_bytes() {
560        assert!(match_single_route("/users/:id", "/users/test\0injected", true).is_none());
561    }
562
563    #[test]
564    fn test_splat_rejects_traversal() {
565        assert!(
566            match_single_route("/files/*path", "/files/docs/../../../etc/passwd", false).is_none()
567        );
568    }
569
570    #[test]
571    fn test_param_rejects_encoded_traversal() {
572        // %2e%2e decodes to `..`
573        assert!(match_single_route("/files/:name", "/files/%2e%2e", true).is_none());
574    }
575
576    #[test]
577    fn test_param_rejects_mixed_encoded_traversal() {
578        // .%2e decodes to `..`
579        assert!(match_single_route("/files/:name", "/files/.%2e", true).is_none());
580        // %2e. decodes to `..`
581        assert!(match_single_route("/files/:name", "/files/%2e.", true).is_none());
582    }
583
584    #[test]
585    fn test_param_rejects_encoded_null() {
586        assert!(match_single_route("/users/:id", "/users/test%00injected", true).is_none());
587    }
588
589    #[test]
590    fn test_param_rejects_malformed_percent() {
591        assert!(match_single_route("/users/:id", "/users/100%", true).is_none());
592        assert!(match_single_route("/users/:id", "/users/100%ZZ", true).is_none());
593    }
594
595    #[test]
596    fn test_param_decodes_valid_percent_encoding() {
597        let m = match_single_route("/files/:name", "/files/hello%20world", true).unwrap();
598        assert_eq!(m.params["name"], "hello world");
599    }
600
601    #[test]
602    fn test_splat_decodes_segments() {
603        let m = match_single_route("/files/*path", "/files/my%20docs/read%20me.md", false).unwrap();
604        assert_eq!(m.params["path"], "my docs/read me.md");
605    }
606
607    #[test]
608    fn test_splat_rejects_encoded_traversal() {
609        assert!(
610            match_single_route("/files/*path", "/files/docs/%2e%2e/etc/passwd", false).is_none()
611        );
612    }
613
614    // ── Parity golden tests ──
615    // These lock down route-matching semantics that must stay consistent
616    // between the Rust server and the TypeScript client.
617
618    #[test]
619    fn parity_root_exact() {
620        let m = match_single_route("/", "/", true).unwrap();
621        assert!(m.params.is_empty());
622        assert_eq!(m.consumed_segments, 0);
623        assert_eq!(m.specificity, 0);
624    }
625
626    #[test]
627    fn parity_root_prefix_matches_anything() {
628        let m = match_single_route("/", "/any/path/here", false).unwrap();
629        assert_eq!(m.consumed_segments, 0);
630    }
631
632    #[test]
633    fn parity_root_exact_rejects_non_root() {
634        assert!(match_single_route("/", "/something", true).is_none());
635    }
636
637    #[test]
638    fn parity_optional_param_present() {
639        let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
640        assert_eq!(m.params["query"], "hello");
641        assert_eq!(m.consumed_segments, 2);
642    }
643
644    #[test]
645    fn parity_optional_param_absent() {
646        let m = match_single_route("/search/:query?", "/search", true).unwrap();
647        assert!(!m.params.contains_key("query"));
648        assert_eq!(m.consumed_segments, 1);
649    }
650
651    #[test]
652    fn parity_splat_captures_all_remaining() {
653        let m = match_single_route("/files/*path", "/files/a/b/c/d.txt", false).unwrap();
654        assert_eq!(m.params["path"], "a/b/c/d.txt");
655        assert_eq!(m.consumed_segments, 5);
656    }
657
658    #[test]
659    fn parity_splat_captures_empty() {
660        let m = match_single_route("/files/*path", "/files", false).unwrap();
661        assert_eq!(m.params["path"], "");
662        assert_eq!(m.consumed_segments, 1);
663    }
664
665    #[test]
666    fn parity_unnamed_splat_defaults_to_rest() {
667        let m = match_single_route("/files/*", "/files/a/b", false).unwrap();
668        assert_eq!(m.params["rest"], "a/b");
669    }
670
671    #[test]
672    fn parity_percent_encoded_space() {
673        let m = match_single_route("/items/:name", "/items/hello%20world", true).unwrap();
674        assert_eq!(m.params["name"], "hello world");
675    }
676
677    #[test]
678    fn parity_percent_encoded_slash() {
679        let m = match_single_route("/items/:name", "/items/a%2Fb", true).unwrap();
680        assert_eq!(m.params["name"], "a/b");
681    }
682
683    #[test]
684    fn parity_unicode_path() {
685        let m = match_single_route("/pages/:title", "/pages/caf%C3%A9", true).unwrap();
686        assert_eq!(m.params["title"], "café");
687    }
688
689    #[test]
690    fn parity_specificity_literal_beats_param() {
691        // "/contacts/add" (specificity 2) must beat "/contacts/:id" (specificity 1)
692        let add = match_single_route("/contacts/add", "/contacts/add", true).unwrap();
693        let param = match_single_route("/contacts/:id", "/contacts/add", true).unwrap();
694        assert!(add.specificity > param.specificity);
695    }
696
697    #[test]
698    fn parity_equal_specificity_first_wins() {
699        // Two patterns with same specificity — first declared wins.
700        // This test validates the contract at the caller level (find_best_route_match).
701        let m1 = match_single_route("/items/:a", "/items/x", true).unwrap();
702        let m2 = match_single_route("/items/:b", "/items/x", true).unwrap();
703        assert_eq!(m1.specificity, m2.specificity, "equal specificity expected");
704    }
705
706    #[test]
707    fn parity_relative_path_resolution() {
708        assert_eq!(
709            resolve_route_path("./topics/:id", "/sections/1"),
710            "/sections/1/topics/:id"
711        );
712        assert_eq!(
713            resolve_route_path("topics/:id", "/sections/1"),
714            "/sections/1/topics/:id"
715        );
716        assert_eq!(resolve_route_path("/absolute", "/any/base"), "/absolute");
717        assert_eq!(resolve_route_path("", "/base"), "/base");
718        assert_eq!(resolve_route_path("./", "/base"), "/base");
719    }
720
721    #[test]
722    fn parity_compute_route_base_various() {
723        assert_eq!(compute_route_base("/", 0), "/");
724        assert_eq!(compute_route_base("/a/b/c", 1), "/a");
725        assert_eq!(compute_route_base("/a/b/c", 2), "/a/b");
726        assert_eq!(compute_route_base("/a/b/c", 3), "/a/b/c");
727        assert_eq!(compute_route_base("/a/b/c", 99), "/a/b/c");
728    }
729
730    #[test]
731    fn parity_nested_route_matching() {
732        // Simulate: parent route "/sections/:id" matches "/sections/1/topics/react"
733        let parent =
734            match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
735        assert_eq!(parent.params["id"], "1");
736        assert_eq!(parent.consumed_segments, 2);
737
738        // Child route is relative: "topics/:topicId" resolved against base "/sections/1"
739        let base = compute_route_base("/sections/1/topics/react", parent.consumed_segments);
740        assert_eq!(base, "/sections/1");
741
742        let child_path = resolve_route_path("./topics/:topicId", &base);
743        assert_eq!(child_path, "/sections/1/topics/:topicId");
744
745        let child = match_single_route(&child_path, "/sections/1/topics/react", true).unwrap();
746        assert_eq!(child.params["topicId"], "react");
747        assert_eq!(child.consumed_segments, 4);
748    }
749
750    #[test]
751    fn compiled_index_matches_relative_suffix_without_request_key() {
752        let child = WebUiFragmentRoute {
753            path: "topics/:topicId".to_string(),
754            fragment_id: "topic-page".to_string(),
755            exact: true,
756            ..Default::default()
757        };
758        let parent = WebUiFragmentRoute {
759            path: "/sections/:sectionId".to_string(),
760            fragment_id: "section-page".to_string(),
761            children: vec![child],
762            ..Default::default()
763        };
764        let protocol = WebUIProtocol::new(HashMap::from([(
765            "index.html".to_string(),
766            FragmentList {
767                fragments: vec![WebUIFragment::route_from(parent)],
768                contains_boundary: false,
769            },
770        )]));
771        let index = CompiledRouteIndex::new(&protocol);
772        let request_segments = split_request_path("/sections/1/topics/react");
773
774        let route_match = match_route_indexed_with_segments(
775            &index,
776            "topics/:topicId",
777            "/sections/1",
778            &request_segments,
779            true,
780        )
781        .expect("relative child route should match");
782
783        assert_eq!(route_match.params["topicId"], "react");
784        assert_eq!(route_match.consumed_segments, 4);
785        assert_eq!(route_match.specificity, 3);
786        assert_eq!(index.patterns.len(), 2);
787    }
788
789    #[test]
790    fn parity_non_exact_prefix_with_extra_segments() {
791        let m = match_single_route("/app", "/app/dashboard/settings", false).unwrap();
792        assert_eq!(m.consumed_segments, 1);
793        assert_eq!(m.specificity, 1);
794    }
795
796    #[test]
797    fn parity_exact_rejects_extra_segments() {
798        assert!(match_single_route("/app", "/app/dashboard", true).is_none());
799    }
800
801    #[test]
802    fn parity_multiple_optional_params() {
803        let m = match_single_route("/search/:q?/:page?", "/search", true).unwrap();
804        assert!(!m.params.contains_key("q"));
805        assert!(!m.params.contains_key("page"));
806
807        let m = match_single_route("/search/:q?/:page?", "/search/rust", true).unwrap();
808        assert_eq!(m.params["q"], "rust");
809        assert!(!m.params.contains_key("page"));
810
811        let m = match_single_route("/search/:q?/:page?", "/search/rust/2", true).unwrap();
812        assert_eq!(m.params["q"], "rust");
813        assert_eq!(m.params["page"], "2");
814    }
815}