Skip to main content

sova_core/router/
path.rs

1pub fn normalize_path(path: &str) -> String {
2    if path.is_empty() {
3        return "/".into();
4    }
5    if path.starts_with('/') {
6        path.to_string()
7    } else {
8        format!("/{path}")
9    }
10}
11
12pub(super) fn normalize_prefix(prefix: &str) -> String {
13    if prefix.is_empty() || prefix == "/" {
14        return String::new();
15    }
16    let mut p = normalize_path(prefix);
17    while p.ends_with('/') && p.len() > 1 {
18        p.pop();
19    }
20    p
21}
22
23pub fn join_paths(prefix: &str, path: &str) -> String {
24    let path = if path.is_empty() { "/" } else { path };
25    if prefix.is_empty() {
26        return normalize_path(path);
27    }
28    if path == "/" {
29        return prefix.to_string();
30    }
31    let path = path.strip_prefix('/').unwrap_or(path);
32    format!("{prefix}/{path}")
33}
34
35/// Convert Express `:id` to OpenAPI `{id}`.
36///
37/// Returns `None` when the path contains a wildcard (`*…`) — those are not
38/// expressible as OpenAPI path items.
39pub fn to_brace_path(path: &str) -> Option<String> {
40    let path = normalize_path(path);
41    if path == "/" {
42        return Some("/".into());
43    }
44    let mut out = String::new();
45    for seg in path.trim_matches('/').split('/') {
46        if seg.starts_with('*') {
47            return None;
48        }
49        out.push('/');
50        out.push_str(&colon_params_to_braces(seg));
51    }
52    Some(out)
53}
54
55/// Convert Express `:id` / `*path` to matchit `{id}` / `{*path}`.
56///
57/// Also rewrites inline params (`/sitemap-:n.xml` → `/sitemap-{n}.xml`).
58pub(crate) fn to_matchit_path(path: &str) -> String {
59    let path = normalize_path(path);
60    if path == "/" {
61        return "/".into();
62    }
63    path.trim_matches('/')
64        .split('/')
65        .map(|seg| {
66            if let Some(name) = seg.strip_prefix('*') {
67                if name.is_empty() {
68                    "{*path}".to_string()
69                } else {
70                    format!("{{*{name}}}")
71                }
72            } else {
73                colon_params_to_braces(seg)
74            }
75        })
76        .fold(String::new(), |mut acc, seg| {
77            acc.push('/');
78            acc.push_str(&seg);
79            acc
80        })
81}
82
83/// `:id` → `{id}`; `sitemap-:n.xml` → `sitemap-{n}.xml`.
84fn colon_params_to_braces(seg: &str) -> String {
85    let bytes = seg.as_bytes();
86    let mut out = String::with_capacity(seg.len() + 2);
87    let mut i = 0;
88    while i < bytes.len() {
89        if bytes[i] == b':' {
90            let start = i + 1;
91            let mut end = start;
92            while end < bytes.len()
93                && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
94            {
95                end += 1;
96            }
97            if end > start {
98                out.push('{');
99                out.push_str(&seg[start..end]);
100                out.push('}');
101                i = end;
102                continue;
103            }
104        }
105        out.push(bytes[i] as char);
106        i += 1;
107    }
108    out
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn brace_path_params() {
117        assert_eq!(to_brace_path("/users/:id").as_deref(), Some("/users/{id}"));
118        assert_eq!(to_brace_path("/").as_deref(), Some("/"));
119        assert_eq!(
120            to_brace_path("/sitemap-:n.xml").as_deref(),
121            Some("/sitemap-{n}.xml")
122        );
123    }
124
125    #[test]
126    fn matchit_inline_param() {
127        assert_eq!(to_matchit_path("/sitemap-:n.xml"), "/sitemap-{n}.xml");
128        assert_eq!(to_matchit_path("/users/:id"), "/users/{id}");
129    }
130
131    #[test]
132    fn brace_path_rejects_wildcard() {
133        assert!(to_brace_path("/files/*path").is_none());
134    }
135}