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