frust_widgets/nav/
path.rs1use std::collections::BTreeMap;
21
22pub type RouteParams = BTreeMap<String, String>;
25
26#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Location {
31 pub path: String,
34 pub segments: Vec<String>,
36 pub query: RouteParams,
39}
40
41impl Location {
42 pub fn parse(raw: &str) -> Location {
49 let (path_part, query_part) = match raw.split_once('?') {
50 Some((p, q)) => (p, Some(q)),
51 None => (raw, None),
52 };
53
54 let segments: Vec<String> = path_part
55 .split('/')
56 .filter(|s| !s.is_empty())
57 .map(percent_decode)
58 .collect();
59
60 let path = if segments.is_empty() {
61 "/".to_string()
62 } else {
63 format!("/{}", segments.join("/"))
64 };
65
66 let mut query = RouteParams::new();
67 if let Some(q) = query_part {
68 for pair in q.split('&').filter(|s| !s.is_empty()) {
69 let (k, v) = match pair.split_once('=') {
70 Some((k, v)) => (percent_decode(k), percent_decode(v)),
71 None => (percent_decode(pair), String::new()),
72 };
73 query.insert(k, v);
74 }
75 }
76
77 Location {
78 path,
79 segments,
80 query,
81 }
82 }
83
84 pub fn location_string(&self) -> String {
88 if self.query.is_empty() {
89 self.path.clone()
90 } else {
91 let query: Vec<String> = self
92 .query
93 .iter()
94 .map(|(k, v)| {
95 if v.is_empty() {
96 percent_encode(k)
97 } else {
98 format!("{}={}", percent_encode(k), percent_encode(v))
99 }
100 })
101 .collect();
102 format!("{}?{}", self.path, query.join("&"))
103 }
104 }
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
109enum Segment {
110 Static(String),
112 Param(String),
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct PathPattern {
123 segments: Vec<Segment>,
124}
125
126impl PathPattern {
127 pub fn parse(pattern: &str) -> PathPattern {
130 let segments = pattern
131 .split('/')
132 .filter(|s| !s.is_empty())
133 .map(|s| match s.strip_prefix(':') {
134 Some(name) => Segment::Param(name.to_string()),
135 None => Segment::Static(s.to_string()),
136 })
137 .collect();
138 PathPattern { segments }
139 }
140
141 pub fn len(&self) -> usize {
143 self.segments.len()
144 }
145
146 pub fn is_empty(&self) -> bool {
149 self.segments.is_empty()
150 }
151
152 pub fn match_prefix(&self, segs: &[String]) -> Option<(usize, RouteParams)> {
162 if segs.len() < self.segments.len() {
163 return None;
164 }
165 let mut params = RouteParams::new();
166 for (pat, seg) in self.segments.iter().zip(segs.iter()) {
167 match pat {
168 Segment::Static(s) => {
169 if s != seg {
170 return None;
171 }
172 }
173 Segment::Param(name) => {
174 params.insert(name.clone(), seg.clone());
175 }
176 }
177 }
178 Some((self.segments.len(), params))
179 }
180}
181
182fn percent_decode(s: &str) -> String {
186 if !s.contains('%') {
187 return s.to_string();
188 }
189 let bytes = s.as_bytes();
190 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
191 let mut i = 0;
192 while i < bytes.len() {
193 if bytes[i] == b'%' && i + 2 < bytes.len() {
194 let hi = (bytes[i + 1] as char).to_digit(16);
195 let lo = (bytes[i + 2] as char).to_digit(16);
196 if let (Some(hi), Some(lo)) = (hi, lo) {
197 out.push((hi * 16 + lo) as u8);
198 i += 3;
199 continue;
200 }
201 }
202 out.push(bytes[i]);
203 i += 1;
204 }
205 String::from_utf8_lossy(&out).into_owned()
207}
208
209fn percent_encode(s: &str) -> String {
214 let mut out = String::with_capacity(s.len());
215 for b in s.bytes() {
216 match b {
217 b'%' | b'?' | b'&' | b'=' | b'#' | b'/' | b' ' => {
218 out.push('%');
219 out.push_str(&format!("{b:02X}"));
220 }
221 _ => out.push(b as char),
222 }
223 }
224 out
225}
226
227pub(super) fn encode_segment(s: &str) -> String {
230 percent_encode(s)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn segs(parts: &[&str]) -> Vec<String> {
238 parts.iter().map(|s| s.to_string()).collect()
239 }
240
241 #[test]
242 fn parses_static_path() {
243 let loc = Location::parse("/users/list");
244 assert_eq!(loc.path, "/users/list");
245 assert_eq!(loc.segments, segs(&["users", "list"]));
246 assert!(loc.query.is_empty());
247 }
248
249 #[test]
250 fn root_path_normalizes() {
251 let loc = Location::parse("/");
252 assert_eq!(loc.path, "/");
253 assert!(loc.segments.is_empty());
254 assert_eq!(Location::parse("").path, "/");
256 }
257
258 #[test]
259 fn trailing_slash_is_tolerated() {
260 assert_eq!(Location::parse("/users/").segments, segs(&["users"]));
261 assert_eq!(Location::parse("/users").segments, segs(&["users"]));
262 assert_eq!(
263 Location::parse("/users/").location_string(),
264 Location::parse("/users").location_string()
265 );
266 }
267
268 #[test]
269 fn parses_query() {
270 let loc = Location::parse("/users/42?tab=posts&sort=asc");
271 assert_eq!(loc.segments, segs(&["users", "42"]));
272 assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
273 assert_eq!(loc.query.get("sort").map(String::as_str), Some("asc"));
274 assert_eq!(loc.location_string(), "/users/42?sort=asc&tab=posts");
276 }
277
278 #[test]
279 fn query_key_without_value() {
280 let loc = Location::parse("/search?debug");
281 assert_eq!(loc.query.get("debug").map(String::as_str), Some(""));
282 }
283
284 #[test]
285 fn percent_decodes_segments_and_query() {
286 let loc = Location::parse("/notes/hello%20world?q=a%26b");
287 assert_eq!(loc.segments, segs(&["notes", "hello world"]));
288 assert_eq!(loc.query.get("q").map(String::as_str), Some("a&b"));
289 }
290
291 #[test]
292 fn matches_static_pattern() {
293 let pat = PathPattern::parse("/users");
294 let (consumed, params) = pat.match_prefix(&segs(&["users"])).expect("matches");
295 assert_eq!(consumed, 1);
296 assert!(params.is_empty());
297 assert!(pat.match_prefix(&segs(&["posts"])).is_none());
298 }
299
300 #[test]
301 fn captures_param() {
302 let pat = PathPattern::parse("/users/:id");
303 let (consumed, params) = pat.match_prefix(&segs(&["users", "42"])).expect("matches");
304 assert_eq!(consumed, 2);
305 assert_eq!(params.get("id").map(String::as_str), Some("42"));
306 }
307
308 #[test]
309 fn match_prefix_leaves_remaining_segments() {
310 let parent = PathPattern::parse("/users");
313 let (consumed, _) = parent
314 .match_prefix(&segs(&["users", "42", "posts"]))
315 .expect("prefix matches");
316 assert_eq!(consumed, 1);
317 }
318
319 #[test]
320 fn too_few_segments_fails() {
321 let pat = PathPattern::parse("/users/:id");
322 assert!(pat.match_prefix(&segs(&["users"])).is_none());
323 }
324
325 #[test]
326 fn empty_pattern_consumes_nothing() {
327 let pat = PathPattern::parse("/");
328 assert!(pat.is_empty());
329 let (consumed, params) = pat.match_prefix(&segs(&["anything"])).expect("matches");
330 assert_eq!(consumed, 0);
331 assert!(params.is_empty());
332 }
333}