Skip to main content

barbacane_lib/router/
trie.rs

1use std::collections::HashMap;
2
3/// The routing trie. Maps HTTP paths + methods to route matches.
4#[derive(Debug, Default)]
5pub struct Router {
6    root: Node,
7}
8
9/// A single node in the prefix trie.
10#[derive(Debug, Default)]
11struct Node {
12    /// Static children keyed by segment name.
13    static_children: HashMap<String, Node>,
14    /// Parameter child (at most one per node).
15    param_child: Option<Box<ParamNode>>,
16    /// Wildcard child — matches all remaining path segments joined by `/`.
17    /// Only valid at a terminal position (no further trie nodes after it).
18    wildcard_child: Option<Box<WildcardNode>>,
19    /// Method-to-route mapping at this terminal node.
20    methods: HashMap<String, RouteEntry>,
21}
22
23/// A parameter segment node.
24#[derive(Debug)]
25struct ParamNode {
26    /// Parameter name (e.g. "id").
27    name: String,
28    /// The subtree below this parameter.
29    node: Node,
30}
31
32/// A wildcard segment node (e.g. `{path+}`).
33///
34/// Matches all remaining path segments joined by `/` and captures the result
35/// as a single parameter value.
36#[derive(Debug)]
37struct WildcardNode {
38    /// Parameter name (e.g. "path" from `{path+}`).
39    name: String,
40    /// Terminal node — holds the method map.
41    node: Node,
42}
43
44/// A matched route entry.
45#[derive(Debug, Clone)]
46pub struct RouteEntry {
47    /// Index into the compiled operations list.
48    pub operation_index: usize,
49}
50
51/// The result of a route lookup.
52#[derive(Debug)]
53pub enum RouteMatch {
54    /// Matched a path and method.
55    Found {
56        entry: RouteEntry,
57        params: Vec<(String, String)>,
58    },
59    /// Path matched but method is not allowed.
60    MethodNotAllowed { allowed: Vec<String> },
61    /// No path matched.
62    NotFound,
63}
64
65/// A parsed path segment.
66#[derive(Debug, Clone)]
67enum Segment {
68    Static(String),
69    Param(String),
70    /// Matches all remaining segments joined by `/`. Must be the last segment.
71    Wildcard(String),
72}
73
74impl Router {
75    /// Create a new empty router.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Insert a route into the trie.
81    ///
82    /// Path should be a template like "/users/{id}/orders".
83    /// Method should be uppercase (e.g. "GET", "POST").
84    pub fn insert(&mut self, path: &str, method: &str, entry: RouteEntry) {
85        let segments = parse_path_template(path);
86        let node = self.traverse_or_create(&segments);
87        node.methods.insert(method.to_uppercase(), entry);
88    }
89
90    /// Look up a request path and method.
91    ///
92    /// Path should be an actual request path (not a template).
93    /// Method should be uppercase.
94    pub fn lookup(&self, path: &str, method: &str) -> RouteMatch {
95        let normalized = normalize_path(path);
96        let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
97
98        let mut params = Vec::new();
99        match self.traverse_and_match(&self.root, &segments, &mut params) {
100            Some(node) => {
101                if let Some(entry) = node.methods.get(&method.to_uppercase()) {
102                    RouteMatch::Found {
103                        entry: entry.clone(),
104                        params,
105                    }
106                } else if node.methods.is_empty() {
107                    RouteMatch::NotFound
108                } else {
109                    let mut allowed: Vec<String> = node.methods.keys().cloned().collect();
110                    allowed.sort();
111                    RouteMatch::MethodNotAllowed { allowed }
112                }
113            }
114            None => RouteMatch::NotFound,
115        }
116    }
117
118    /// Traverse or create nodes for a path template.
119    fn traverse_or_create(&mut self, segments: &[Segment]) -> &mut Node {
120        let mut current = &mut self.root;
121
122        for segment in segments {
123            current = match segment {
124                Segment::Static(name) => current.static_children.entry(name.clone()).or_default(),
125                Segment::Param(name) => {
126                    if current.param_child.is_none() {
127                        current.param_child = Some(Box::new(ParamNode {
128                            name: name.clone(),
129                            node: Node::default(),
130                        }));
131                    }
132                    &mut current.param_child.as_mut().expect("just set above").node
133                }
134                Segment::Wildcard(name) => {
135                    if current.wildcard_child.is_none() {
136                        current.wildcard_child = Some(Box::new(WildcardNode {
137                            name: name.clone(),
138                            node: Node::default(),
139                        }));
140                    }
141                    &mut current
142                        .wildcard_child
143                        .as_mut()
144                        .expect("just set above")
145                        .node
146                }
147            };
148        }
149
150        current
151    }
152
153    /// Traverse the trie matching actual path segments, capturing parameters.
154    /// Returns the terminal node if the path matches, None otherwise.
155    fn traverse_and_match<'a>(
156        &'a self,
157        node: &'a Node,
158        segments: &[&str],
159        params: &mut Vec<(String, String)>,
160    ) -> Option<&'a Node> {
161        if segments.is_empty() {
162            return Some(node);
163        }
164
165        let segment = segments[0];
166        let remaining = &segments[1..];
167
168        // Static children take precedence (most specific match).
169        if let Some(child) = node.static_children.get(segment) {
170            if let Some(result) = self.traverse_and_match(child, remaining, params) {
171                return Some(result);
172            }
173        }
174
175        // Try single-segment parameter child.
176        if let Some(param_child) = &node.param_child {
177            let param_len = params.len();
178            params.push((param_child.name.clone(), segment.to_string()));
179
180            if let Some(result) = self.traverse_and_match(&param_child.node, remaining, params) {
181                return Some(result);
182            }
183
184            // Backtrack if this path didn't work.
185            params.truncate(param_len);
186        }
187
188        // Try wildcard child — consumes all remaining segments (including the current one).
189        if let Some(wildcard_child) = &node.wildcard_child {
190            let joined = std::iter::once(segment)
191                .chain(remaining.iter().copied())
192                .collect::<Vec<_>>()
193                .join("/");
194            let param_len = params.len();
195            params.push((wildcard_child.name.clone(), joined));
196
197            if let Some(result) = self.traverse_and_match(&wildcard_child.node, &[], params) {
198                return Some(result);
199            }
200
201            params.truncate(param_len);
202        }
203
204        None
205    }
206}
207
208/// Parse a path template into segments.
209fn parse_path_template(path: &str) -> Vec<Segment> {
210    path.split('/')
211        .filter(|s| !s.is_empty())
212        .map(|s| {
213            if s.starts_with('{') && s.ends_with('}') {
214                let inner = &s[1..s.len() - 1];
215                if let Some(base) = inner.strip_suffix('+') {
216                    Segment::Wildcard(base.to_string())
217                } else {
218                    Segment::Param(inner.to_string())
219                }
220            } else {
221                Segment::Static(s.to_string())
222            }
223        })
224        .collect()
225}
226
227/// Normalize a request path: strip trailing slashes, collapse double slashes.
228pub fn normalize_path(path: &str) -> String {
229    let mut normalized = String::with_capacity(path.len());
230    let mut prev_slash = false;
231
232    for ch in path.chars() {
233        if ch == '/' {
234            if !prev_slash {
235                normalized.push('/');
236            }
237            prev_slash = true;
238        } else {
239            normalized.push(ch);
240            prev_slash = false;
241        }
242    }
243
244    // Strip trailing slash (but keep root "/")
245    if normalized.len() > 1 && normalized.ends_with('/') {
246        normalized.pop();
247    }
248
249    if normalized.is_empty() {
250        "/".to_string()
251    } else {
252        normalized
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    // === Normalization tests ===
261
262    #[test]
263    fn normalize_strips_trailing_slash() {
264        assert_eq!(normalize_path("/users/"), "/users");
265    }
266
267    #[test]
268    fn normalize_collapses_double_slashes() {
269        assert_eq!(normalize_path("/users//123"), "/users/123");
270    }
271
272    #[test]
273    fn normalize_preserves_root() {
274        assert_eq!(normalize_path("/"), "/");
275    }
276
277    #[test]
278    fn normalize_combined() {
279        assert_eq!(normalize_path("/users//123//orders/"), "/users/123/orders");
280    }
281
282    // === Routing tests ===
283
284    #[test]
285    fn route_static_path() {
286        let mut router = Router::new();
287        router.insert("/health", "GET", RouteEntry { operation_index: 0 });
288
289        match router.lookup("/health", "GET") {
290            RouteMatch::Found { entry, params } => {
291                assert_eq!(entry.operation_index, 0);
292                assert!(params.is_empty());
293            }
294            _ => panic!("expected Found"),
295        }
296    }
297
298    #[test]
299    fn route_with_parameter() {
300        let mut router = Router::new();
301        router.insert("/users/{id}", "GET", RouteEntry { operation_index: 0 });
302
303        match router.lookup("/users/123", "GET") {
304            RouteMatch::Found { entry, params } => {
305                assert_eq!(entry.operation_index, 0);
306                assert_eq!(params, vec![("id".to_string(), "123".to_string())]);
307            }
308            _ => panic!("expected Found"),
309        }
310    }
311
312    #[test]
313    fn route_with_multiple_parameters() {
314        let mut router = Router::new();
315        router.insert(
316            "/users/{userId}/orders/{orderId}",
317            "GET",
318            RouteEntry { operation_index: 0 },
319        );
320
321        match router.lookup("/users/42/orders/99", "GET") {
322            RouteMatch::Found { entry, params } => {
323                assert_eq!(entry.operation_index, 0);
324                assert_eq!(
325                    params,
326                    vec![
327                        ("userId".to_string(), "42".to_string()),
328                        ("orderId".to_string(), "99".to_string()),
329                    ]
330                );
331            }
332            _ => panic!("expected Found"),
333        }
334    }
335
336    #[test]
337    fn route_not_found() {
338        let mut router = Router::new();
339        router.insert("/users", "GET", RouteEntry { operation_index: 0 });
340
341        match router.lookup("/posts", "GET") {
342            RouteMatch::NotFound => {}
343            _ => panic!("expected NotFound"),
344        }
345    }
346
347    #[test]
348    fn route_method_not_allowed() {
349        let mut router = Router::new();
350        router.insert("/users", "GET", RouteEntry { operation_index: 0 });
351        router.insert("/users", "POST", RouteEntry { operation_index: 1 });
352
353        match router.lookup("/users", "DELETE") {
354            RouteMatch::MethodNotAllowed { allowed } => {
355                assert!(allowed.contains(&"GET".to_string()));
356                assert!(allowed.contains(&"POST".to_string()));
357            }
358            _ => panic!("expected MethodNotAllowed"),
359        }
360    }
361
362    #[test]
363    fn static_takes_precedence_over_param() {
364        let mut router = Router::new();
365        router.insert("/users/me", "GET", RouteEntry { operation_index: 0 });
366        router.insert("/users/{id}", "GET", RouteEntry { operation_index: 1 });
367
368        // "/users/me" should match the static route
369        match router.lookup("/users/me", "GET") {
370            RouteMatch::Found { entry, params } => {
371                assert_eq!(entry.operation_index, 0);
372                assert!(params.is_empty());
373            }
374            _ => panic!("expected Found for static"),
375        }
376
377        // "/users/123" should match the param route
378        match router.lookup("/users/123", "GET") {
379            RouteMatch::Found { entry, params } => {
380                assert_eq!(entry.operation_index, 1);
381                assert_eq!(params, vec![("id".to_string(), "123".to_string())]);
382            }
383            _ => panic!("expected Found for param"),
384        }
385    }
386
387    #[test]
388    fn route_root_path() {
389        let mut router = Router::new();
390        router.insert("/", "GET", RouteEntry { operation_index: 0 });
391
392        match router.lookup("/", "GET") {
393            RouteMatch::Found { entry, .. } => {
394                assert_eq!(entry.operation_index, 0);
395            }
396            _ => panic!("expected Found for root"),
397        }
398    }
399
400    #[test]
401    fn route_normalizes_request_path() {
402        let mut router = Router::new();
403        router.insert("/users/{id}", "GET", RouteEntry { operation_index: 0 });
404
405        // Trailing slash should still match
406        match router.lookup("/users/123/", "GET") {
407            RouteMatch::Found { params, .. } => {
408                assert_eq!(params, vec![("id".to_string(), "123".to_string())]);
409            }
410            _ => panic!("expected Found"),
411        }
412
413        // Double slashes should still match
414        match router.lookup("/users//456", "GET") {
415            RouteMatch::Found { params, .. } => {
416                assert_eq!(params, vec![("id".to_string(), "456".to_string())]);
417            }
418            _ => panic!("expected Found"),
419        }
420    }
421
422    #[test]
423    fn multiple_methods_same_path() {
424        let mut router = Router::new();
425        router.insert("/users", "GET", RouteEntry { operation_index: 0 });
426        router.insert("/users", "POST", RouteEntry { operation_index: 1 });
427        router.insert("/users", "DELETE", RouteEntry { operation_index: 2 });
428
429        match router.lookup("/users", "GET") {
430            RouteMatch::Found { entry, .. } => assert_eq!(entry.operation_index, 0),
431            _ => panic!("expected Found for GET"),
432        }
433
434        match router.lookup("/users", "POST") {
435            RouteMatch::Found { entry, .. } => assert_eq!(entry.operation_index, 1),
436            _ => panic!("expected Found for POST"),
437        }
438
439        match router.lookup("/users", "DELETE") {
440            RouteMatch::Found { entry, .. } => assert_eq!(entry.operation_index, 2),
441            _ => panic!("expected Found for DELETE"),
442        }
443    }
444
445    // === Wildcard parameter tests ===
446
447    #[test]
448    fn wildcard_matches_single_segment() {
449        let mut router = Router::new();
450        router.insert("/files/{name+}", "GET", RouteEntry { operation_index: 0 });
451
452        match router.lookup("/files/readme.txt", "GET") {
453            RouteMatch::Found { entry, params } => {
454                assert_eq!(entry.operation_index, 0);
455                assert_eq!(params, vec![("name".to_string(), "readme.txt".to_string())]);
456            }
457            _ => panic!("expected Found"),
458        }
459    }
460
461    #[test]
462    fn wildcard_matches_multiple_segments() {
463        let mut router = Router::new();
464        router.insert("/files/{path+}", "GET", RouteEntry { operation_index: 0 });
465
466        match router.lookup("/files/a/b/c/file.txt", "GET") {
467            RouteMatch::Found { entry, params } => {
468                assert_eq!(entry.operation_index, 0);
469                assert_eq!(
470                    params,
471                    vec![("path".to_string(), "a/b/c/file.txt".to_string())]
472                );
473            }
474            _ => panic!("expected Found"),
475        }
476    }
477
478    #[test]
479    fn wildcard_with_prefix_param() {
480        let mut router = Router::new();
481        router.insert(
482            "/files/{bucket}/{key+}",
483            "GET",
484            RouteEntry { operation_index: 0 },
485        );
486
487        // Multi-segment wildcard value
488        match router.lookup("/files/my-bucket/folder/sub/file.txt", "GET") {
489            RouteMatch::Found { entry, params } => {
490                assert_eq!(entry.operation_index, 0);
491                assert_eq!(
492                    params,
493                    vec![
494                        ("bucket".to_string(), "my-bucket".to_string()),
495                        ("key".to_string(), "folder/sub/file.txt".to_string()),
496                    ]
497                );
498            }
499            _ => panic!("expected Found for multi-segment"),
500        }
501
502        // Single-segment wildcard value — this is the S3 proxy case
503        match router.lookup("/files/my-bucket/file.txt", "GET") {
504            RouteMatch::Found { entry, params } => {
505                assert_eq!(entry.operation_index, 0);
506                assert_eq!(
507                    params,
508                    vec![
509                        ("bucket".to_string(), "my-bucket".to_string()),
510                        ("key".to_string(), "file.txt".to_string()),
511                    ]
512                );
513            }
514            _ => panic!("expected Found for single-segment"),
515        }
516    }
517
518    #[test]
519    fn static_takes_precedence_over_wildcard() {
520        let mut router = Router::new();
521        router.insert("/files/special", "GET", RouteEntry { operation_index: 0 });
522        router.insert("/files/{path+}", "GET", RouteEntry { operation_index: 1 });
523
524        // Static wins for exact match
525        match router.lookup("/files/special", "GET") {
526            RouteMatch::Found { entry, params } => {
527                assert_eq!(entry.operation_index, 0);
528                assert!(params.is_empty());
529            }
530            _ => panic!("expected Found for static"),
531        }
532
533        // Wildcard wins for multi-segment
534        match router.lookup("/files/other/file.txt", "GET") {
535            RouteMatch::Found { entry, params } => {
536                assert_eq!(entry.operation_index, 1);
537                assert_eq!(
538                    params,
539                    vec![("path".to_string(), "other/file.txt".to_string())]
540                );
541            }
542            _ => panic!("expected Found for wildcard"),
543        }
544    }
545
546    #[test]
547    fn param_takes_precedence_over_wildcard() {
548        let mut router = Router::new();
549        router.insert("/files/{name}", "GET", RouteEntry { operation_index: 0 });
550        router.insert("/files/{path+}", "GET", RouteEntry { operation_index: 1 });
551
552        // Single segment: param wins (more specific)
553        match router.lookup("/files/readme.txt", "GET") {
554            RouteMatch::Found { entry, params } => {
555                assert_eq!(entry.operation_index, 0);
556                assert_eq!(params, vec![("name".to_string(), "readme.txt".to_string())]);
557            }
558            _ => panic!("expected Found for param"),
559        }
560
561        // Multi-segment: only wildcard can match
562        match router.lookup("/files/a/b.txt", "GET") {
563            RouteMatch::Found { entry, params } => {
564                assert_eq!(entry.operation_index, 1);
565                assert_eq!(params, vec![("path".to_string(), "a/b.txt".to_string())]);
566            }
567            _ => panic!("expected Found for wildcard"),
568        }
569    }
570}