elif_http/routing/
matcher.rs

1//! Route matching engine for elif.rs
2//!
3//! This module provides the core route matching functionality that efficiently
4//! resolves incoming requests to the appropriate route handlers.
5
6use super::pattern::{CompiledRoute, RouteId, RouteMatch, RoutePattern, RoutePatternError};
7use super::HttpMethod;
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// Errors that can occur during route matching
12#[derive(Error, Debug)]
13pub enum RouteMatchError {
14    #[error("No matching route found")]
15    NoMatch,
16    #[error("Route pattern error: {0}")]
17    PatternError(#[from] RoutePatternError),
18    #[error("Conflicting routes: {0} conflicts with {1}")]
19    RouteConflict(String, String),
20}
21
22/// Definition of a route to be compiled
23#[derive(Debug, Clone)]
24pub struct RouteDefinition {
25    pub id: RouteId,
26    pub method: HttpMethod,
27    pub path: String,
28}
29
30/// High-performance route matcher
31#[derive(Debug)]
32pub struct RouteMatcher {
33    /// Static routes for O(1) lookup - nested to avoid string allocation
34    /// Structure: method -> path -> route_id
35    static_routes: HashMap<HttpMethod, HashMap<String, RouteId>>,
36    /// Dynamic routes sorted by priority
37    dynamic_routes: Vec<CompiledRoute>,
38    /// All route definitions for introspection
39    route_definitions: HashMap<RouteId, RouteDefinition>,
40}
41
42impl RouteMatcher {
43    /// Create a new empty route matcher
44    pub fn new() -> Self {
45        Self {
46            static_routes: HashMap::new(),
47            dynamic_routes: Vec::new(),
48            route_definitions: HashMap::new(),
49        }
50    }
51
52    /// Add a route to the matcher
53    pub fn add_route(&mut self, definition: RouteDefinition) -> Result<(), RouteMatchError> {
54        let pattern = RoutePattern::parse(&definition.path)?;
55
56        // Check for route conflicts
57        self.check_conflicts(&definition, &pattern)?;
58
59        // Store the definition
60        self.route_definitions
61            .insert(definition.id.clone(), definition.clone());
62
63        if pattern.is_static() {
64            // Static route - add to nested lookup table
65            self.static_routes
66                .entry(definition.method.clone())
67                .or_default()
68                .insert(definition.path.clone(), definition.id);
69        } else {
70            // Dynamic route - compile and add to sorted list
71            let compiled_route = CompiledRoute::new(definition.id, definition.method, pattern);
72
73            // Insert in priority order (lower priority value = higher precedence)
74            let insert_pos = self
75                .dynamic_routes
76                .binary_search_by_key(&compiled_route.priority, |r| r.priority)
77                .unwrap_or_else(|pos| pos);
78
79            self.dynamic_routes.insert(insert_pos, compiled_route);
80        }
81
82        Ok(())
83    }
84
85    /// Resolve an incoming request to a matching route
86    pub fn resolve(&self, method: &HttpMethod, path: &str) -> Option<RouteMatch> {
87        // Fast path: check static routes first (no allocation!)
88        if let Some(method_routes) = self.static_routes.get(method) {
89            if let Some(route_id) = method_routes.get(path) {
90                return Some(RouteMatch {
91                    route_id: route_id.clone(),
92                    params: HashMap::new(),
93                });
94            }
95        }
96
97        // Dynamic route matching
98        for compiled_route in &self.dynamic_routes {
99            if compiled_route.matches(method, path) {
100                let params = compiled_route.extract_params(path);
101                return Some(RouteMatch {
102                    route_id: compiled_route.id.clone(),
103                    params,
104                });
105            }
106        }
107
108        None
109    }
110
111    /// Check for route conflicts before adding a new route
112    fn check_conflicts(
113        &self,
114        new_route: &RouteDefinition,
115        new_pattern: &RoutePattern,
116    ) -> Result<(), RouteMatchError> {
117        // Check against static routes
118        if new_pattern.is_static() {
119            if let Some(method_routes) = self.static_routes.get(&new_route.method) {
120                if let Some(existing_id) = method_routes.get(&new_route.path) {
121                    return Err(RouteMatchError::RouteConflict(
122                        new_route.id.clone(),
123                        existing_id.clone(),
124                    ));
125                }
126            }
127        }
128
129        // Check against dynamic routes
130        for existing_route in &self.dynamic_routes {
131            if existing_route.method == new_route.method
132                && self.patterns_conflict(new_pattern, &existing_route.pattern)
133            {
134                return Err(RouteMatchError::RouteConflict(
135                    new_route.id.clone(),
136                    existing_route.id.clone(),
137                ));
138            }
139        }
140
141        Ok(())
142    }
143
144    /// Check if two patterns would conflict (ambiguous matching)
145    fn patterns_conflict(&self, pattern1: &RoutePattern, pattern2: &RoutePattern) -> bool {
146        // Two patterns conflict if they are structurally identical.
147        // This means they have the same number of segments, and each corresponding
148        // segment is of the same type with the same static value or constraint.
149        if pattern1.segments.len() != pattern2.segments.len() {
150            return false;
151        }
152
153        for (seg1, seg2) in pattern1.segments.iter().zip(pattern2.segments.iter()) {
154            match (seg1, seg2) {
155                (
156                    super::pattern::PathSegment::Static(s1),
157                    super::pattern::PathSegment::Static(s2),
158                ) if s1 == s2 => continue,
159                (
160                    super::pattern::PathSegment::Parameter { constraint: c1, .. },
161                    super::pattern::PathSegment::Parameter { constraint: c2, .. },
162                ) if c1 == c2 => continue,
163                (
164                    super::pattern::PathSegment::CatchAll { .. },
165                    super::pattern::PathSegment::CatchAll { .. },
166                ) => continue,
167                _ => return false, // Segments are not structurally identical
168            }
169        }
170
171        true // All segments are structurally identical, so the patterns conflict.
172    }
173
174    /// Get all route definitions for introspection
175    pub fn all_routes(&self) -> &HashMap<RouteId, RouteDefinition> {
176        &self.route_definitions
177    }
178
179    /// Get a specific route definition
180    pub fn get_route(&self, route_id: &RouteId) -> Option<&RouteDefinition> {
181        self.route_definitions.get(route_id)
182    }
183
184    /// Get statistics about the matcher
185    pub fn stats(&self) -> MatcherStats {
186        let static_routes_count = self
187            .static_routes
188            .values()
189            .map(|method_routes| method_routes.len())
190            .sum();
191
192        MatcherStats {
193            static_routes: static_routes_count,
194            dynamic_routes: self.dynamic_routes.len(),
195            total_routes: self.route_definitions.len(),
196        }
197    }
198
199    /// Clear all routes
200    pub fn clear(&mut self) {
201        self.static_routes.clear();
202        self.dynamic_routes.clear();
203        self.route_definitions.clear();
204    }
205}
206
207impl Default for RouteMatcher {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213/// Statistics about the route matcher
214#[derive(Debug, Clone)]
215pub struct MatcherStats {
216    pub static_routes: usize,
217    pub dynamic_routes: usize,
218    pub total_routes: usize,
219}
220
221/// Builder for creating route matchers
222pub struct RouteMatcherBuilder {
223    routes: Vec<RouteDefinition>,
224}
225
226impl RouteMatcherBuilder {
227    /// Create a new builder
228    pub fn new() -> Self {
229        Self { routes: Vec::new() }
230    }
231
232    /// Add a route to the builder
233    pub fn route(mut self, id: String, method: HttpMethod, path: String) -> Self {
234        self.routes.push(RouteDefinition { id, method, path });
235        self
236    }
237
238    /// Add a GET route
239    pub fn get(self, id: String, path: String) -> Self {
240        self.route(id, HttpMethod::GET, path)
241    }
242
243    /// Add a POST route
244    pub fn post(self, id: String, path: String) -> Self {
245        self.route(id, HttpMethod::POST, path)
246    }
247
248    /// Add a PUT route
249    pub fn put(self, id: String, path: String) -> Self {
250        self.route(id, HttpMethod::PUT, path)
251    }
252
253    /// Add a DELETE route
254    pub fn delete(self, id: String, path: String) -> Self {
255        self.route(id, HttpMethod::DELETE, path)
256    }
257
258    /// Add a PATCH route
259    pub fn patch(self, id: String, path: String) -> Self {
260        self.route(id, HttpMethod::PATCH, path)
261    }
262
263    /// Build the route matcher
264    pub fn build(self) -> Result<RouteMatcher, RouteMatchError> {
265        let mut matcher = RouteMatcher::new();
266
267        for route_def in self.routes {
268            matcher.add_route(route_def)?;
269        }
270
271        Ok(matcher)
272    }
273}
274
275impl Default for RouteMatcherBuilder {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_static_route_matching() {
287        let mut matcher = RouteMatcher::new();
288
289        let route_def = RouteDefinition {
290            id: "home".to_string(),
291            method: HttpMethod::GET,
292            path: "/".to_string(),
293        };
294
295        matcher.add_route(route_def).unwrap();
296
297        let result = matcher.resolve(&HttpMethod::GET, "/");
298        assert!(result.is_some());
299
300        let route_match = result.unwrap();
301        assert_eq!(route_match.route_id, "home");
302        assert!(route_match.params.is_empty());
303
304        // Should not match different method
305        assert!(matcher.resolve(&HttpMethod::POST, "/").is_none());
306
307        // Should not match different path
308        assert!(matcher.resolve(&HttpMethod::GET, "/users").is_none());
309    }
310
311    #[test]
312    fn test_dynamic_route_matching() {
313        let mut matcher = RouteMatcher::new();
314
315        let route_def = RouteDefinition {
316            id: "user_show".to_string(),
317            method: HttpMethod::GET,
318            path: "/users/{id}".to_string(),
319        };
320
321        matcher.add_route(route_def).unwrap();
322
323        let result = matcher.resolve(&HttpMethod::GET, "/users/123");
324        assert!(result.is_some());
325
326        let route_match = result.unwrap();
327        assert_eq!(route_match.route_id, "user_show");
328        assert_eq!(route_match.params.get("id"), Some(&"123".to_string()));
329    }
330
331    #[test]
332    fn test_route_priority() {
333        let mut matcher = RouteMatcher::new();
334
335        // Add in reverse priority order to test sorting
336        matcher
337            .add_route(RouteDefinition {
338                id: "catch_all".to_string(),
339                method: HttpMethod::GET,
340                path: "/files/*path".to_string(),
341            })
342            .unwrap();
343
344        matcher
345            .add_route(RouteDefinition {
346                id: "specific".to_string(),
347                method: HttpMethod::GET,
348                path: "/files/config.json".to_string(),
349            })
350            .unwrap();
351
352        matcher
353            .add_route(RouteDefinition {
354                id: "param".to_string(),
355                method: HttpMethod::GET,
356                path: "/files/{name}".to_string(),
357            })
358            .unwrap();
359
360        // Static route should match first
361        let result = matcher.resolve(&HttpMethod::GET, "/files/config.json");
362        assert_eq!(result.unwrap().route_id, "specific");
363
364        // Parameter route should match before catch-all
365        let result = matcher.resolve(&HttpMethod::GET, "/files/other.txt");
366        assert_eq!(result.unwrap().route_id, "param");
367
368        // Catch-all should match multi-segment paths
369        let result = matcher.resolve(&HttpMethod::GET, "/files/docs/readme.md");
370        assert_eq!(result.unwrap().route_id, "catch_all");
371    }
372
373    #[test]
374    fn test_route_conflict_detection() {
375        let mut matcher = RouteMatcher::new();
376
377        // Add first route
378        matcher
379            .add_route(RouteDefinition {
380                id: "route1".to_string(),
381                method: HttpMethod::GET,
382                path: "/users".to_string(),
383            })
384            .unwrap();
385
386        // Try to add conflicting static route
387        let result = matcher.add_route(RouteDefinition {
388            id: "route2".to_string(),
389            method: HttpMethod::GET,
390            path: "/users".to_string(),
391        });
392
393        assert!(result.is_err());
394        assert!(matches!(result, Err(RouteMatchError::RouteConflict(_, _))));
395    }
396
397    #[test]
398    fn test_advanced_conflict_detection() {
399        let mut matcher = RouteMatcher::new();
400
401        // Test 1: Parameter routes with same structure should conflict
402        matcher
403            .add_route(RouteDefinition {
404                id: "users_by_id".to_string(),
405                method: HttpMethod::GET,
406                path: "/users/{id}".to_string(),
407            })
408            .unwrap();
409
410        let result = matcher.add_route(RouteDefinition {
411            id: "users_by_name".to_string(),
412            method: HttpMethod::GET,
413            path: "/users/{name}".to_string(),
414        });
415        assert!(
416            result.is_err(),
417            "Parameters with different names should conflict"
418        );
419
420        // Test 2: Different methods should not conflict
421        let result = matcher.add_route(RouteDefinition {
422            id: "users_post".to_string(),
423            method: HttpMethod::POST,
424            path: "/users/{id}".to_string(),
425        });
426        assert!(result.is_ok(), "Different methods should not conflict");
427
428        // Test 3: Different static segments should not conflict
429        let result = matcher.add_route(RouteDefinition {
430            id: "posts_by_id".to_string(),
431            method: HttpMethod::GET,
432            path: "/posts/{id}".to_string(),
433        });
434        assert!(
435            result.is_ok(),
436            "Different static segments should not conflict"
437        );
438
439        // Test 4: Catch-all routes with same structure should conflict
440        matcher
441            .add_route(RouteDefinition {
442                id: "files_serve".to_string(),
443                method: HttpMethod::GET,
444                path: "/files/*path".to_string(),
445            })
446            .unwrap();
447
448        let result = matcher.add_route(RouteDefinition {
449            id: "files_download".to_string(),
450            method: HttpMethod::GET,
451            path: "/files/*file_path".to_string(),
452        });
453        assert!(
454            result.is_err(),
455            "Catch-all routes with same structure should conflict"
456        );
457
458        // Test 5: Different segment types should not conflict
459        let result = matcher.add_route(RouteDefinition {
460            id: "admin_static".to_string(),
461            method: HttpMethod::GET,
462            path: "/admin/dashboard".to_string(),
463        });
464        assert!(
465            result.is_ok(),
466            "Static vs parameter segments should not conflict"
467        );
468    }
469
470    #[test]
471    fn test_constraint_based_conflicts() {
472        let mut matcher = RouteMatcher::new();
473
474        // Test 1: Same constraints should conflict
475        matcher
476            .add_route(RouteDefinition {
477                id: "user_by_int_id".to_string(),
478                method: HttpMethod::GET,
479                path: "/users/{id:int}".to_string(),
480            })
481            .unwrap();
482
483        let result = matcher.add_route(RouteDefinition {
484            id: "user_by_int_uid".to_string(),
485            method: HttpMethod::GET,
486            path: "/users/{uid:int}".to_string(),
487        });
488        assert!(result.is_err(), "Same constraints should conflict");
489
490        // Test 2: Different constraints should not conflict (they have different precedence)
491        let result = matcher.add_route(RouteDefinition {
492            id: "user_by_uuid".to_string(),
493            method: HttpMethod::GET,
494            path: "/users/{id:uuid}".to_string(),
495        });
496        assert!(result.is_ok(), "Different constraints should not conflict");
497
498        // Test 3: Constrained vs unconstrained should not conflict
499        let result = matcher.add_route(RouteDefinition {
500            id: "user_by_string".to_string(),
501            method: HttpMethod::GET,
502            path: "/users/{name}".to_string(),
503        });
504        assert!(
505            result.is_ok(),
506            "Constrained vs unconstrained should not conflict"
507        );
508    }
509
510    #[test]
511    fn test_complex_pattern_conflicts() {
512        let mut matcher = RouteMatcher::new();
513
514        // Test complex multi-segment patterns
515        matcher
516            .add_route(RouteDefinition {
517                id: "api_user_posts".to_string(),
518                method: HttpMethod::GET,
519                path: "/api/v1/users/{user_id}/posts/{post_id}".to_string(),
520            })
521            .unwrap();
522
523        // Same structure should conflict
524        let result = matcher.add_route(RouteDefinition {
525            id: "api_member_articles".to_string(),
526            method: HttpMethod::GET,
527            path: "/api/v1/users/{member_id}/posts/{article_id}".to_string(),
528        });
529        assert!(
530            result.is_err(),
531            "Structurally identical complex patterns should conflict"
532        );
533
534        // Different static segment should not conflict
535        let result = matcher.add_route(RouteDefinition {
536            id: "api_user_comments".to_string(),
537            method: HttpMethod::GET,
538            path: "/api/v1/users/{user_id}/comments/{comment_id}".to_string(),
539        });
540        assert!(
541            result.is_ok(),
542            "Different static segments should not conflict"
543        );
544
545        // Different segment count should not conflict
546        let result = matcher.add_route(RouteDefinition {
547            id: "api_user_profile".to_string(),
548            method: HttpMethod::GET,
549            path: "/api/v1/users/{user_id}/profile".to_string(),
550        });
551        assert!(
552            result.is_ok(),
553            "Different segment count should not conflict"
554        );
555    }
556
557    #[test]
558    fn test_matcher_builder() {
559        let matcher = RouteMatcherBuilder::new()
560            .get("home".to_string(), "/".to_string())
561            .post("users_create".to_string(), "/users".to_string())
562            .get("users_show".to_string(), "/users/{id}".to_string())
563            .build()
564            .unwrap();
565
566        let stats = matcher.stats();
567        assert_eq!(stats.total_routes, 3);
568        assert_eq!(stats.static_routes, 2); // "/" and "/users" for POST
569        assert_eq!(stats.dynamic_routes, 1); // "/users/{id}"
570
571        // Test that routes work
572        assert!(matcher.resolve(&HttpMethod::GET, "/").is_some());
573        assert!(matcher.resolve(&HttpMethod::POST, "/users").is_some());
574        assert!(matcher.resolve(&HttpMethod::GET, "/users/123").is_some());
575    }
576
577    #[test]
578    fn test_constraint_validation_in_matching() {
579        let mut matcher = RouteMatcher::new();
580
581        matcher
582            .add_route(RouteDefinition {
583                id: "user_by_id".to_string(),
584                method: HttpMethod::GET,
585                path: "/users/{id:int}".to_string(),
586            })
587            .unwrap();
588
589        // Should match valid integer
590        let result = matcher.resolve(&HttpMethod::GET, "/users/123");
591        assert!(result.is_some());
592        assert_eq!(result.unwrap().route_id, "user_by_id");
593
594        // Should not match invalid integer
595        let result = matcher.resolve(&HttpMethod::GET, "/users/abc");
596        assert!(result.is_none());
597    }
598
599    #[test]
600    fn test_mixed_static_and_dynamic_routes() {
601        let mut matcher = RouteMatcher::new();
602
603        // Mix of static and dynamic routes
604        matcher
605            .add_route(RouteDefinition {
606                id: "api_status".to_string(),
607                method: HttpMethod::GET,
608                path: "/api/status".to_string(),
609            })
610            .unwrap();
611
612        matcher
613            .add_route(RouteDefinition {
614                id: "api_user".to_string(),
615                method: HttpMethod::GET,
616                path: "/api/users/{id}".to_string(),
617            })
618            .unwrap();
619
620        matcher
621            .add_route(RouteDefinition {
622                id: "root".to_string(),
623                method: HttpMethod::GET,
624                path: "/".to_string(),
625            })
626            .unwrap();
627
628        // Test static route lookup
629        let result = matcher.resolve(&HttpMethod::GET, "/api/status");
630        assert_eq!(result.unwrap().route_id, "api_status");
631
632        // Test root route
633        let result = matcher.resolve(&HttpMethod::GET, "/");
634        assert_eq!(result.unwrap().route_id, "root");
635
636        // Test dynamic route
637        let result = matcher.resolve(&HttpMethod::GET, "/api/users/456");
638        let route_match = result.unwrap();
639        assert_eq!(route_match.route_id, "api_user");
640        assert_eq!(route_match.params.get("id"), Some(&"456".to_string()));
641    }
642
643    #[test]
644    fn test_no_match() {
645        let matcher = RouteMatcherBuilder::new()
646            .get("home".to_string(), "/".to_string())
647            .build()
648            .unwrap();
649
650        assert!(matcher.resolve(&HttpMethod::GET, "/nonexistent").is_none());
651        assert!(matcher.resolve(&HttpMethod::POST, "/").is_none());
652    }
653
654    #[test]
655    fn test_static_route_lookup_performance() {
656        // Create a matcher with many static routes across different methods
657        let mut builder = RouteMatcherBuilder::new();
658
659        for i in 0..100 {
660            builder = builder
661                .get(format!("get_{}", i), format!("/static/path/{}", i))
662                .post(format!("post_{}", i), format!("/api/v1/{}", i))
663                .put(format!("put_{}", i), format!("/resource/{}", i));
664        }
665
666        let matcher = builder.build().unwrap();
667        let stats = matcher.stats();
668
669        // Verify we have static routes
670        assert_eq!(stats.static_routes, 300); // 100 routes × 3 methods
671        assert_eq!(stats.dynamic_routes, 0);
672
673        // Test lookup performance - these should be O(1) with no allocations
674        let start = std::time::Instant::now();
675
676        // Perform many lookups
677        for i in 0..1000 {
678            let test_index = i % 100;
679
680            // These lookups should not allocate strings
681            let result = matcher.resolve(&HttpMethod::GET, &format!("/static/path/{}", test_index));
682            assert!(result.is_some());
683
684            let result = matcher.resolve(&HttpMethod::POST, &format!("/api/v1/{}", test_index));
685            assert!(result.is_some());
686
687            let result = matcher.resolve(&HttpMethod::PUT, &format!("/resource/{}", test_index));
688            assert!(result.is_some());
689
690            // Test non-existent path
691            let result = matcher.resolve(&HttpMethod::GET, "/nonexistent/path");
692            assert!(result.is_none());
693        }
694
695        let elapsed = start.elapsed();
696
697        // This test primarily verifies that the optimization doesn't break functionality
698        // The performance benefit (no string allocation) can't be directly tested in a unit test,
699        // but the nested HashMap structure ensures we only do &str lookups
700
701        // Should complete very quickly due to O(1) lookups
702        assert!(
703            elapsed.as_millis() < 100,
704            "Static route lookups took too long: {}ms",
705            elapsed.as_millis()
706        );
707
708        println!(
709            "3000 static route lookups completed in {}μs",
710            elapsed.as_micros()
711        );
712    }
713}