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