Skip to main content

proxima_primitives/pipe/
path_pattern.rs

1#![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}