Skip to main content

use_route/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A normalized route segment.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct RouteSegment {
7    pub value: String,
8}
9
10/// A captured route parameter.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RouteParam {
13    pub name: String,
14    pub value: String,
15}
16
17/// Normalizes repeated slashes and removes trailing slashes except for the root route.
18#[must_use]
19pub fn normalize_route(input: &str) -> String {
20    let trimmed = input.trim();
21    if trimmed.is_empty() {
22        return String::from("/");
23    }
24
25    let absolute = trimmed.starts_with('/');
26    let segments = trimmed
27        .split('/')
28        .filter(|segment| !segment.is_empty())
29        .collect::<Vec<_>>();
30
31    if segments.is_empty() {
32        return String::from("/");
33    }
34
35    let joined = segments.join("/");
36    if absolute {
37        format!("/{joined}")
38    } else {
39        joined
40    }
41}
42
43/// Joins two route fragments with exactly one slash.
44#[must_use]
45pub fn join_routes(a: &str, b: &str) -> String {
46    let absolute = a.trim().starts_with('/');
47    let segments = a
48        .split('/')
49        .chain(b.split('/'))
50        .filter(|segment| !segment.trim().is_empty())
51        .collect::<Vec<_>>();
52
53    if segments.is_empty() {
54        return if absolute {
55            String::from("/")
56        } else {
57            String::new()
58        };
59    }
60
61    let joined = segments.join("/");
62    if absolute {
63        format!("/{joined}")
64    } else {
65        joined
66    }
67}
68
69/// Splits a route into normalized segments.
70#[must_use]
71pub fn split_route(input: &str) -> Vec<RouteSegment> {
72    let normalized = normalize_route(input);
73    normalized
74        .split('/')
75        .filter(|segment| !segment.is_empty())
76        .map(|segment| RouteSegment {
77            value: segment.to_string(),
78        })
79        .collect()
80}
81
82/// Returns `true` when the route is absolute.
83#[must_use]
84pub fn is_absolute_route(input: &str) -> bool {
85    input.trim().starts_with('/')
86}
87
88/// Returns the number of non-empty route segments.
89#[must_use]
90pub fn route_depth(input: &str) -> usize {
91    split_route(input).len()
92}
93
94/// Returns `true` when a simple `:param` pattern matches the path.
95#[must_use]
96pub fn matches_route_pattern(pattern: &str, path: &str) -> bool {
97    let pattern_segments = split_route(pattern);
98    let path_segments = split_route(path);
99    if pattern_segments.len() != path_segments.len() {
100        return false;
101    }
102
103    pattern_segments
104        .iter()
105        .zip(path_segments.iter())
106        .all(|(pattern_segment, path_segment)| {
107            pattern_segment.value.starts_with(':') || pattern_segment.value == path_segment.value
108        })
109}
110
111/// Extracts named route parameters when a pattern matches.
112#[must_use]
113pub fn extract_route_params(pattern: &str, path: &str) -> Vec<RouteParam> {
114    if !matches_route_pattern(pattern, path) {
115        return Vec::new();
116    }
117
118    split_route(pattern)
119        .into_iter()
120        .zip(split_route(path))
121        .filter_map(|(pattern_segment, path_segment)| {
122            pattern_segment
123                .value
124                .strip_prefix(':')
125                .filter(|name| !name.is_empty())
126                .map(|name| RouteParam {
127                    name: name.to_string(),
128                    value: path_segment.value,
129                })
130        })
131        .collect()
132}