proxima_primitives/pipe/
path_pattern.rs1#![cfg(feature = "alloc")]
2
3use alloc::collections::BTreeMap;
4use alloc::string::String;
5use alloc::string::ToString;
6use alloc::vec::Vec;
7
8#[derive(Debug, Clone)]
9pub struct PathPattern {
10 segments: Vec<Segment>,
11 raw: String,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15enum Segment {
16 Literal(String),
17 Param(String),
18 Wildcard(String),
19}
20
21impl PathPattern {
22 pub fn parse(pattern: &str) -> Self {
23 let mut segments = Vec::new();
24 for raw in pattern.trim_start_matches('/').split('/') {
25 if raw.is_empty() {
26 continue;
27 }
28 if let Some(name) = raw
29 .strip_prefix('{')
30 .and_then(|inner| inner.strip_suffix('}'))
31 {
32 if let Some(rest) = name.strip_prefix('*') {
33 segments.push(Segment::Wildcard(rest.to_string()));
34 } else {
35 segments.push(Segment::Param(name.to_string()));
36 }
37 continue;
38 }
39 segments.push(Segment::Literal(raw.to_string()));
40 }
41 Self {
42 segments,
43 raw: pattern.to_string(),
44 }
45 }
46
47 #[must_use]
48 pub fn raw(&self) -> &str {
49 &self.raw
50 }
51
52 #[must_use]
53 pub fn matches(&self, path: &str) -> Option<BTreeMap<String, String>> {
54 let mut params = BTreeMap::new();
55 let segments_in: Vec<&str> = path
56 .trim_start_matches('/')
57 .split('/')
58 .filter(|segment| !segment.is_empty())
59 .collect();
60 let mut input_index = 0usize;
61 for (pattern_index, segment) in self.segments.iter().enumerate() {
62 match segment {
63 Segment::Literal(literal) => {
64 if input_index >= segments_in.len() || segments_in[input_index] != literal {
65 return None;
66 }
67 input_index += 1;
68 }
69 Segment::Param(name) => {
70 if input_index >= segments_in.len() {
71 return None;
72 }
73 params.insert(name.clone(), segments_in[input_index].to_string());
74 input_index += 1;
75 }
76 Segment::Wildcard(name) => {
77 let remainder = segments_in[input_index..].join("/");
78 let key = if name.is_empty() {
79 "wildcard".to_string()
80 } else {
81 name.clone()
82 };
83 params.insert(key, remainder);
84 input_index = segments_in.len();
85 if pattern_index != self.segments.len() - 1 {
86 return None;
87 }
88 }
89 }
90 }
91 if input_index != segments_in.len() {
92 return None;
93 }
94 Some(params)
95 }
96}
97
98#[cfg(test)]
99#[allow(clippy::unwrap_used, clippy::expect_used)]
100mod tests {
101 use super::*;
102
103 #[proxima::test]
104 #[case::root("/", "/", true)]
105 #[case::literal_match("/users", "/users", true)]
106 #[case::trailing_slash("/users", "/users/", true)]
107 #[case::literal_mismatch("/users", "/posts", false)]
108 #[case::extra_segment("/users", "/users/42", false)]
109 async fn literal_paths(#[case] pattern: &str, #[case] input: &str, #[case] expected: bool) {
110 let parsed = PathPattern::parse(pattern);
111 assert_eq!(parsed.matches(input).is_some(), expected);
112 }
113
114 #[test]
115 fn param_segment_captures_value() {
116 let pattern = PathPattern::parse("/users/{id}");
117 let params = pattern.matches("/users/42").expect("pattern should match");
118 assert_eq!(params.get("id"), Some(&"42".into()));
119 }
120
121 #[test]
122 fn multiple_params_capture_all() {
123 let pattern = PathPattern::parse("/users/{user}/posts/{post}");
124 let params = pattern
125 .matches("/users/alice/posts/12")
126 .expect("pattern should match");
127 assert_eq!(params.get("user"), Some(&"alice".into()));
128 assert_eq!(params.get("post"), Some(&"12".into()));
129 }
130
131 #[test]
132 fn named_wildcard_captures_remainder() {
133 let pattern = PathPattern::parse("/files/{*rest}");
134 let params = pattern
135 .matches("/files/2026/05/03/notes.md")
136 .expect("pattern should match");
137 assert_eq!(params.get("rest"), Some(&"2026/05/03/notes.md".into()));
138 }
139
140 #[test]
141 fn wildcard_only_at_tail() {
142 let pattern = PathPattern::parse("/files/{*rest}/extra");
143 assert!(pattern.matches("/files/anything/extra").is_none());
144 }
145
146 #[test]
147 fn param_must_consume_all_input() {
148 let pattern = PathPattern::parse("/users/{id}");
149 assert!(pattern.matches("/users/42/extra").is_none());
150 }
151}