Skip to main content

gpui_navigator/
matcher.rs

1//! Advanced route matching with priority system
2//!
3//! This module provides a more sophisticated route matching system
4//! compared to the simple pattern matching in route.rs.
5//!
6//! Features:
7//! - Priority-based route matching (specific routes before generic)
8//! - Optional segments support
9//! - Constraint validation
10//! - Better performance with early exit
11
12use std::collections::HashMap;
13
14/// Route path representation
15#[derive(Debug, Clone, PartialEq)]
16pub enum RoutePath {
17    /// Static path like "/users"
18    Static(&'static str),
19    /// Dynamic path with string pattern
20    Dynamic(String),
21    /// Complex pattern with segments
22    Pattern(RoutePattern),
23}
24
25/// A complete route pattern with segments and priority
26#[derive(Debug, Clone, PartialEq)]
27pub struct RoutePattern {
28    /// Pattern segments
29    pub segments: Vec<Segment>,
30    /// Matching priority (higher = matched first)
31    /// Calculated based on segment types
32    pub priority: u8,
33}
34
35impl RoutePattern {
36    /// Create a new route pattern from path string
37    ///
38    /// Examples:
39    /// - "/users" -> static segments, priority 100
40    /// - "/users/:id" -> mixed segments, priority 50
41    /// - "/files/*" -> wildcard, priority 10
42    pub fn from_path(path: &str) -> Self {
43        let segments: Vec<Segment> = path
44            .split('/')
45            .filter(|s| !s.is_empty())
46            .map(Segment::parse)
47            .collect();
48
49        let priority = Self::calculate_priority(&segments);
50
51        Self { segments, priority }
52    }
53
54    /// Calculate priority based on segment types
55    ///
56    /// Priority rules:
57    /// - All static segments: 100
58    /// - Each dynamic segment: -10
59    /// - Optional segment: -5
60    /// - Wildcard: 0
61    fn calculate_priority(segments: &[Segment]) -> u8 {
62        let mut priority: u8 = 100;
63
64        for segment in segments {
65            match segment {
66                Segment::Static(_) => {
67                    // Static segments don't reduce priority
68                }
69                Segment::Param { .. } => {
70                    priority = priority.saturating_sub(10);
71                }
72                Segment::Optional(_) => {
73                    priority = priority.saturating_sub(5);
74                }
75                Segment::Wildcard => {
76                    // Wildcards have lowest priority
77                    return 0;
78                }
79            }
80        }
81
82        priority
83    }
84
85    /// Match this pattern against a path
86    ///
87    /// Returns extracted parameters if matched
88    pub fn matches(&self, path: &str) -> Option<HashMap<String, String>> {
89        let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
90
91        self.match_segments(&path_segments)
92    }
93
94    /// Match segments against path segments
95    fn match_segments(&self, path_segments: &[&str]) -> Option<HashMap<String, String>> {
96        let mut params = HashMap::new();
97        let mut path_idx = 0;
98        let mut pattern_idx = 0;
99
100        while pattern_idx < self.segments.len() {
101            let segment = &self.segments[pattern_idx];
102
103            match segment {
104                Segment::Static(expected) => {
105                    // Must match exactly
106                    if path_idx >= path_segments.len() || path_segments[path_idx] != expected {
107                        return None;
108                    }
109                    path_idx += 1;
110                }
111                Segment::Param { name, constraint } => {
112                    // Extract parameter
113                    if path_idx >= path_segments.len() {
114                        return None;
115                    }
116
117                    let value = path_segments[path_idx];
118
119                    // Validate constraint if present
120                    if let Some(constraint) = constraint {
121                        if !constraint.validate(value) {
122                            return None;
123                        }
124                    }
125
126                    params.insert(name.clone(), value.to_string());
127                    path_idx += 1;
128                }
129                Segment::Optional(inner) => {
130                    // Try to match, but don't fail if it doesn't
131                    if path_idx < path_segments.len() {
132                        if let Segment::Param { name, constraint } = &**inner {
133                            let value = path_segments[path_idx];
134
135                            let is_valid = if let Some(constraint) = constraint {
136                                constraint.validate(value)
137                            } else {
138                                true
139                            };
140
141                            if is_valid {
142                                params.insert(name.clone(), value.to_string());
143                                path_idx += 1;
144                            }
145                        }
146                    }
147                }
148                Segment::Wildcard => {
149                    // Wildcard matches rest of path - always succeeds
150                    return Some(params);
151                }
152            }
153
154            pattern_idx += 1;
155        }
156
157        // All segments matched - check that we consumed all path segments
158        if path_idx == path_segments.len() {
159            Some(params)
160        } else {
161            None
162        }
163    }
164}
165
166/// A single segment in a route pattern
167#[derive(Debug, Clone, PartialEq)]
168pub enum Segment {
169    /// Static text that must match exactly
170    Static(String),
171    /// Parameter that captures a value
172    Param {
173        name: String,
174        constraint: Option<Constraint>,
175    },
176    /// Optional segment (can be missing)
177    Optional(Box<Segment>),
178    /// Wildcard that matches everything
179    Wildcard,
180}
181
182impl Segment {
183    /// Parse a segment from string
184    ///
185    /// Examples:
186    /// - "users" -> Static("users")
187    /// - ":id" -> Param { name: "id", constraint: None }
188    /// - ":id<\\d+>" -> Param { name: "id", constraint: Some(Regex) }
189    /// - "*" -> Wildcard
190    pub fn parse(s: &str) -> Self {
191        if s == "*" {
192            return Segment::Wildcard;
193        }
194
195        if let Some(rest) = s.strip_prefix(':') {
196            // Parameter segment
197
198            // Check for constraint: :id<\d+>
199            if let Some(pos) = rest.find('<') {
200                let name = rest[..pos].to_string();
201                let constraint_str = &rest[pos + 1..rest.len() - 1]; // Remove < and >
202
203                let constraint = Constraint::parse(constraint_str);
204
205                Segment::Param {
206                    name,
207                    constraint: Some(constraint),
208                }
209            } else {
210                Segment::Param {
211                    name: rest.to_string(),
212                    constraint: None,
213                }
214            }
215        } else {
216            // Static segment
217            Segment::Static(s.to_string())
218        }
219    }
220}
221
222/// Constraint for validating parameter values
223#[derive(Debug, Clone, PartialEq)]
224pub enum Constraint {
225    /// Regex pattern (simple implementation for now)
226    Pattern(String),
227    /// Numeric constraint
228    Numeric,
229    /// UUID constraint
230    Uuid,
231}
232
233impl Constraint {
234    /// Parse constraint from string
235    fn parse(s: &str) -> Self {
236        match s {
237            "\\d+" => Constraint::Numeric,
238            "uuid" => Constraint::Uuid,
239            _ => Constraint::Pattern(s.to_string()),
240        }
241    }
242
243    /// Validate a value against this constraint
244    pub fn validate(&self, value: &str) -> bool {
245        match self {
246            Constraint::Numeric => value.chars().all(|c| c.is_ascii_digit()),
247            Constraint::Uuid => {
248                // Simple UUID validation: 8-4-4-4-12 hex chars
249                let parts: Vec<&str> = value.split('-').collect();
250                if parts.len() != 5 {
251                    return false;
252                }
253
254                parts[0].len() == 8
255                    && parts[1].len() == 4
256                    && parts[2].len() == 4
257                    && parts[3].len() == 4
258                    && parts[4].len() == 12
259                    && parts
260                        .iter()
261                        .all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
262            }
263            Constraint::Pattern(_pattern) => {
264                // TODO: Implement regex matching
265                // For now, accept everything
266                true
267            }
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_segment_parsing() {
278        assert_eq!(
279            Segment::parse("users"),
280            Segment::Static("users".to_string())
281        );
282        assert_eq!(
283            Segment::parse(":id"),
284            Segment::Param {
285                name: "id".to_string(),
286                constraint: None
287            }
288        );
289        assert_eq!(Segment::parse("*"), Segment::Wildcard);
290    }
291
292    #[test]
293    fn test_segment_parsing_with_constraint() {
294        let segment = Segment::parse(":id<\\d+>");
295        match segment {
296            Segment::Param { name, constraint } => {
297                assert_eq!(name, "id");
298                assert_eq!(constraint, Some(Constraint::Numeric));
299            }
300            _ => panic!("Expected Param segment"),
301        }
302    }
303
304    #[test]
305    fn test_priority_calculation() {
306        let pattern1 = RoutePattern::from_path("/users");
307        assert_eq!(pattern1.priority, 100); // All static
308
309        let pattern2 = RoutePattern::from_path("/users/:id");
310        assert_eq!(pattern2.priority, 90); // One dynamic
311
312        let pattern3 = RoutePattern::from_path("/users/:id/posts/:postId");
313        assert_eq!(pattern3.priority, 80); // Two dynamic
314
315        let pattern4 = RoutePattern::from_path("/files/*");
316        assert_eq!(pattern4.priority, 0); // Wildcard
317    }
318
319    #[test]
320    fn test_static_route_matching() {
321        let pattern = RoutePattern::from_path("/users");
322
323        assert!(pattern.matches("/users").is_some());
324        assert!(pattern.matches("/posts").is_none());
325        assert!(pattern.matches("/users/123").is_none());
326    }
327
328    #[test]
329    fn test_dynamic_route_matching() {
330        let pattern = RoutePattern::from_path("/users/:id");
331
332        let params = pattern.matches("/users/123");
333        assert!(params.is_some());
334        assert_eq!(params.unwrap().get("id"), Some(&"123".to_string()));
335
336        assert!(pattern.matches("/users").is_none());
337        assert!(pattern.matches("/users/123/posts").is_none());
338    }
339
340    #[test]
341    fn test_wildcard_matching() {
342        let pattern = RoutePattern::from_path("/files/*");
343
344        assert!(pattern.matches("/files/docs").is_some());
345        assert!(pattern.matches("/files/docs/report.pdf").is_some());
346        assert!(pattern.matches("/other").is_none());
347    }
348
349    #[test]
350    fn test_numeric_constraint() {
351        let constraint = Constraint::Numeric;
352
353        assert!(constraint.validate("123"));
354        assert!(constraint.validate("0"));
355        assert!(!constraint.validate("abc"));
356        assert!(!constraint.validate("12a"));
357    }
358
359    #[test]
360    fn test_uuid_constraint() {
361        let constraint = Constraint::Uuid;
362
363        assert!(constraint.validate("550e8400-e29b-41d4-a716-446655440000"));
364        assert!(!constraint.validate("not-a-uuid"));
365        assert!(!constraint.validate("550e8400-e29b-41d4-a716"));
366    }
367
368    #[test]
369    fn test_constrained_param_matching() {
370        let pattern = RoutePattern::from_path("/users/:id<\\d+>");
371
372        assert!(pattern.matches("/users/123").is_some());
373        assert!(pattern.matches("/users/abc").is_none());
374    }
375
376    #[test]
377    fn test_complex_pattern() {
378        let pattern = RoutePattern::from_path("/api/users/:userId/posts/:postId");
379
380        let params = pattern.matches("/api/users/42/posts/7");
381        assert!(params.is_some());
382
383        let params = params.unwrap();
384        assert_eq!(params.get("userId"), Some(&"42".to_string()));
385        assert_eq!(params.get("postId"), Some(&"7".to_string()));
386    }
387}