1use super::HttpMethod;
7use regex::Regex;
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Error, Debug)]
13pub enum RoutePatternError {
14 #[error("Invalid pattern syntax: {0}")]
15 InvalidSyntax(String),
16 #[error("Multiple catch-all segments not allowed")]
17 MultipleCatchAll,
18 #[error("Catch-all must be the last segment")]
19 CatchAllNotLast,
20 #[error("Invalid constraint syntax: {0}")]
21 InvalidConstraint(String),
22 #[error("Duplicate parameter name: {0}")]
23 DuplicateParameter(String),
24}
25
26#[derive(Debug, Clone)]
28pub enum ParamConstraint {
29 None,
31 Int,
33 Uuid,
35 Alpha,
37 Slug,
39 Custom(Regex),
41}
42
43impl PartialEq for ParamConstraint {
44 fn eq(&self, other: &Self) -> bool {
45 match (self, other) {
46 (ParamConstraint::None, ParamConstraint::None) => true,
47 (ParamConstraint::Int, ParamConstraint::Int) => true,
48 (ParamConstraint::Uuid, ParamConstraint::Uuid) => true,
49 (ParamConstraint::Alpha, ParamConstraint::Alpha) => true,
50 (ParamConstraint::Slug, ParamConstraint::Slug) => true,
51 (ParamConstraint::Custom(regex1), ParamConstraint::Custom(regex2)) => {
52 regex1.as_str() == regex2.as_str()
53 }
54 _ => false,
55 }
56 }
57}
58
59impl ParamConstraint {
60 pub fn from_str(s: &str) -> Result<Self, RoutePatternError> {
62 match s {
63 "int" => Ok(ParamConstraint::Int),
64 "uuid" => Ok(ParamConstraint::Uuid),
65 "alpha" => Ok(ParamConstraint::Alpha),
66 "slug" => Ok(ParamConstraint::Slug),
67 _ => {
68 match Regex::new(s) {
70 Ok(regex) => Ok(ParamConstraint::Custom(regex)),
71 Err(e) => Err(RoutePatternError::InvalidConstraint(format!(
72 "Invalid regex pattern '{}': {}",
73 s, e
74 ))),
75 }
76 }
77 }
78 }
79
80 pub fn validate(&self, value: &str) -> bool {
82 if value.is_empty() {
83 return false;
84 }
85
86 match self {
87 ParamConstraint::None => true,
88 ParamConstraint::Int => value.parse::<i64>().is_ok(),
89 ParamConstraint::Uuid => uuid::Uuid::parse_str(value).is_ok(),
90 ParamConstraint::Alpha => value.chars().all(|c| c.is_alphabetic()),
91 ParamConstraint::Slug => value
92 .chars()
93 .all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
94 ParamConstraint::Custom(regex) => regex.is_match(value),
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub enum PathSegment {
102 Static(String),
104 Parameter {
106 name: String,
107 constraint: ParamConstraint,
108 },
109 CatchAll { name: String },
111}
112
113#[derive(Debug, Clone)]
115pub struct RoutePattern {
116 pub original_path: String,
118 pub segments: Vec<PathSegment>,
120 pub param_names: Vec<String>,
122 pub has_catch_all: bool,
124 pub static_segments: usize,
126}
127
128impl RoutePattern {
129 pub fn parse(path: &str) -> Result<Self, RoutePatternError> {
131 let mut segments = Vec::new();
132 let mut param_names = Vec::new();
133 let mut has_catch_all = false;
134 let mut static_segments = 0;
135 let mut seen_params = std::collections::HashSet::new();
136
137 let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
138
139 for (index, segment) in path_segments.iter().enumerate() {
140 let segment = segment.trim();
141
142 if segment.starts_with('{') && segment.ends_with('}') {
143 let param_def = &segment[1..segment.len() - 1];
145 let (name, constraint) = Self::parse_parameter_definition(param_def)?;
146
147 if seen_params.contains(&name) {
149 return Err(RoutePatternError::DuplicateParameter(name));
150 }
151 seen_params.insert(name.clone());
152
153 segments.push(PathSegment::Parameter {
154 name: name.clone(),
155 constraint,
156 });
157 param_names.push(name);
158 } else if segment.starts_with('*') {
159 if has_catch_all {
161 return Err(RoutePatternError::MultipleCatchAll);
162 }
163
164 if index != path_segments.len() - 1 {
166 return Err(RoutePatternError::CatchAllNotLast);
167 }
168
169 let name = segment[1..].to_string();
170 if name.is_empty() {
171 return Err(RoutePatternError::InvalidSyntax(
172 "Catch-all segment must have a name".to_string(),
173 ));
174 }
175
176 if seen_params.contains(&name) {
178 return Err(RoutePatternError::DuplicateParameter(name));
179 }
180 seen_params.insert(name.clone());
181
182 segments.push(PathSegment::CatchAll { name: name.clone() });
183 param_names.push(name);
184 has_catch_all = true;
185 } else {
186 if segment.is_empty() {
188 return Err(RoutePatternError::InvalidSyntax(
189 "Empty path segments not allowed".to_string(),
190 ));
191 }
192 segments.push(PathSegment::Static(segment.to_string()));
193 static_segments += 1;
194 }
195 }
196
197 Ok(RoutePattern {
198 original_path: path.to_string(),
199 segments,
200 param_names,
201 has_catch_all,
202 static_segments,
203 })
204 }
205
206 fn parse_parameter_definition(
208 param_def: &str,
209 ) -> Result<(String, ParamConstraint), RoutePatternError> {
210 if let Some(colon_pos) = param_def.find(':') {
211 let name = param_def[..colon_pos].trim().to_string();
212 let constraint_str = param_def[colon_pos + 1..].trim();
213
214 if name.is_empty() {
215 return Err(RoutePatternError::InvalidSyntax(
216 "Parameter name cannot be empty".to_string(),
217 ));
218 }
219
220 let constraint = ParamConstraint::from_str(constraint_str)?;
221 Ok((name, constraint))
222 } else {
223 let name = param_def.trim().to_string();
224 if name.is_empty() {
225 return Err(RoutePatternError::InvalidSyntax(
226 "Parameter name cannot be empty".to_string(),
227 ));
228 }
229 Ok((name, ParamConstraint::None))
230 }
231 }
232
233 pub fn matches(&self, path: &str) -> bool {
235 let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
236
237 let mut pattern_idx = 0;
238 let mut path_idx = 0;
239
240 while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
241 match &self.segments[pattern_idx] {
242 PathSegment::Static(expected) => {
243 if expected != path_segments[path_idx] {
244 return false;
245 }
246 pattern_idx += 1;
247 path_idx += 1;
248 }
249
250 PathSegment::Parameter { constraint, .. } => {
251 if !constraint.validate(path_segments[path_idx]) {
252 return false;
253 }
254 pattern_idx += 1;
255 path_idx += 1;
256 }
257
258 PathSegment::CatchAll { .. } => {
259 return true;
261 }
262 }
263 }
264
265 pattern_idx == self.segments.len()
268 && (path_idx == path_segments.len() || self.has_catch_all)
269 }
270
271 pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
273 let mut params = HashMap::new();
274
275 let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
276
277 let mut pattern_idx = 0;
278 let mut path_idx = 0;
279
280 while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
281 match &self.segments[pattern_idx] {
282 PathSegment::Static(_) => {
283 pattern_idx += 1;
284 path_idx += 1;
285 }
286
287 PathSegment::Parameter { name, .. } => {
288 params.insert(name.clone(), path_segments[path_idx].to_string());
289 pattern_idx += 1;
290 path_idx += 1;
291 }
292
293 PathSegment::CatchAll { name } => {
294 let remaining: Vec<&str> = path_segments[path_idx..].to_vec();
296 params.insert(name.clone(), remaining.join("/"));
297 break;
298 }
299 }
300 }
301
302 params
303 }
304
305 pub fn priority(&self) -> usize {
315 let mut priority = 0;
316
317 for segment in &self.segments {
318 match segment {
319 PathSegment::Static(_) => {
320 priority += 1; }
322 PathSegment::Parameter { constraint, .. } => {
323 priority += match constraint {
324 ParamConstraint::Int | ParamConstraint::Uuid => 5, ParamConstraint::Custom(_) => 6, ParamConstraint::Alpha | ParamConstraint::Slug => 8, ParamConstraint::None => 10, };
329 }
330 PathSegment::CatchAll { .. } => {
331 priority += 100; }
333 }
334 }
335
336 priority
337 }
338
339 pub fn is_static(&self) -> bool {
341 self.segments
342 .iter()
343 .all(|seg| matches!(seg, PathSegment::Static(_)))
344 }
345}
346
347pub type RouteId = String;
349
350#[derive(Debug, Clone)]
352pub struct RouteMatch {
353 pub route_id: RouteId,
354 pub params: HashMap<String, String>,
355}
356
357#[derive(Debug, Clone)]
359pub struct CompiledRoute {
360 pub id: RouteId,
361 pub method: HttpMethod,
362 pub pattern: RoutePattern,
363 pub priority: usize,
364}
365
366impl CompiledRoute {
367 pub fn new(id: RouteId, method: HttpMethod, pattern: RoutePattern) -> Self {
368 let priority = pattern.priority();
369 Self {
370 id,
371 method,
372 pattern,
373 priority,
374 }
375 }
376
377 pub fn matches(&self, method: &HttpMethod, path: &str) -> bool {
379 self.method == *method && self.pattern.matches(path)
380 }
381
382 pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
384 self.pattern.extract_params(path)
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_parse_static_route() {
394 let pattern = RoutePattern::parse("/users").unwrap();
395 assert_eq!(pattern.segments.len(), 1);
396 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
397 assert!(pattern.param_names.is_empty());
398 assert!(!pattern.has_catch_all);
399 assert_eq!(pattern.static_segments, 1);
400 }
401
402 #[test]
403 fn test_parse_parameter_route() {
404 let pattern = RoutePattern::parse("/users/{id}").unwrap();
405 assert_eq!(pattern.segments.len(), 2);
406 assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
407 assert!(
408 matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint }
409 if name == "id" && matches!(constraint, ParamConstraint::None))
410 );
411 assert_eq!(pattern.param_names, vec!["id"]);
412 assert!(!pattern.has_catch_all);
413 assert_eq!(pattern.static_segments, 1);
414 }
415
416 #[test]
417 fn test_parse_constrained_parameter() {
418 let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
419 assert!(
420 matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint }
421 if name == "id" && matches!(constraint, ParamConstraint::Int))
422 );
423 }
424
425 #[test]
426 fn test_parse_catch_all_route() {
427 let pattern = RoutePattern::parse("/files/*path").unwrap();
428 assert_eq!(pattern.segments.len(), 2);
429 assert!(matches!(&pattern.segments[1], PathSegment::CatchAll { name } if name == "path"));
430 assert!(pattern.has_catch_all);
431 assert_eq!(pattern.param_names, vec!["path"]);
432 }
433
434 #[test]
435 fn test_invalid_patterns() {
436 assert!(RoutePattern::parse("/users/{id}/files/*path/more").is_err()); assert!(RoutePattern::parse("/users/{id}/{id}").is_err()); assert!(RoutePattern::parse("/users/{}").is_err()); assert!(RoutePattern::parse("/files/*").is_err()); }
441
442 #[test]
443 fn test_pattern_matching() {
444 let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
445
446 assert!(pattern.matches("/users/123/posts/hello-world"));
447 assert!(!pattern.matches("/users/123/posts")); assert!(!pattern.matches("/users/123/posts/hello/world")); assert!(!pattern.matches("/posts/123/posts/hello")); }
451
452 #[test]
453 fn test_parameter_extraction() {
454 let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
455 let params = pattern.extract_params("/users/123/posts/hello-world");
456
457 assert_eq!(params.get("id"), Some(&"123".to_string()));
458 assert_eq!(params.get("slug"), Some(&"hello-world".to_string()));
459 assert_eq!(params.len(), 2);
460 }
461
462 #[test]
463 fn test_catch_all_extraction() {
464 let pattern = RoutePattern::parse("/files/*path").unwrap();
465 let params = pattern.extract_params("/files/docs/images/logo.png");
466
467 assert_eq!(
468 params.get("path"),
469 Some(&"docs/images/logo.png".to_string())
470 );
471 }
472
473 #[test]
474 fn test_constraint_validation() {
475 assert!(ParamConstraint::Int.validate("123"));
476 assert!(!ParamConstraint::Int.validate("abc"));
477
478 assert!(ParamConstraint::Alpha.validate("hello"));
479 assert!(!ParamConstraint::Alpha.validate("hello123"));
480
481 assert!(ParamConstraint::Slug.validate("hello-world_123"));
482 assert!(!ParamConstraint::Slug.validate("hello world!"));
483
484 let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
486 assert!(ParamConstraint::Uuid.validate(uuid_str));
487 assert!(!ParamConstraint::Uuid.validate("not-a-uuid"));
488 }
489
490 #[test]
491 fn test_pattern_priorities() {
492 let static_pattern = RoutePattern::parse("/users").unwrap();
493 let param_pattern = RoutePattern::parse("/users/{id}").unwrap();
494 let catch_all_pattern = RoutePattern::parse("/users/*path").unwrap();
495 let mixed_pattern = RoutePattern::parse("/api/v1/users/{id}/posts/{slug}").unwrap();
496
497 assert!(static_pattern.priority() < param_pattern.priority());
498 assert!(param_pattern.priority() < catch_all_pattern.priority());
499
500 assert_eq!(mixed_pattern.priority(), 24);
502 }
503
504 #[test]
505 fn test_constraint_based_priorities() {
506 let static_route = RoutePattern::parse("/users/123").unwrap();
508 let int_constraint = RoutePattern::parse("/users/{id:int}").unwrap();
509 let custom_constraint = RoutePattern::parse("/users/{id:[0-9]+}").unwrap();
510 let alpha_constraint = RoutePattern::parse("/users/{slug:alpha}").unwrap();
511 let no_constraint = RoutePattern::parse("/users/{name}").unwrap();
512 let catch_all = RoutePattern::parse("/users/*path").unwrap();
513
514 assert!(static_route.priority() < int_constraint.priority());
516 assert!(int_constraint.priority() < custom_constraint.priority());
517 assert!(custom_constraint.priority() < alpha_constraint.priority());
518 assert!(alpha_constraint.priority() < no_constraint.priority());
519 assert!(no_constraint.priority() < catch_all.priority());
520
521 assert_eq!(static_route.priority(), 2); assert_eq!(int_constraint.priority(), 6); assert_eq!(custom_constraint.priority(), 7); assert_eq!(alpha_constraint.priority(), 9); assert_eq!(no_constraint.priority(), 11); assert_eq!(catch_all.priority(), 101); }
529
530 #[test]
531 fn test_complex_priority_scenarios() {
532 let api_v1_int = RoutePattern::parse("/api/v1/users/{id:int}").unwrap();
536 let api_v1_uuid = RoutePattern::parse("/api/v1/users/{id:uuid}").unwrap();
537 let api_v1_slug = RoutePattern::parse("/api/v1/users/{slug:alpha}").unwrap();
538 let api_v1_any = RoutePattern::parse("/api/v1/users/{identifier}").unwrap();
539
540 assert!(api_v1_int.priority() == api_v1_uuid.priority()); assert!(api_v1_int.priority() < api_v1_slug.priority()); assert!(api_v1_slug.priority() < api_v1_any.priority()); let users_profile = RoutePattern::parse("/users/{id:int}/profile").unwrap();
547 let users_posts = RoutePattern::parse("/users/{id:int}/posts/{post_id:int}").unwrap();
548 let users_files = RoutePattern::parse("/users/{id:int}/files/*path").unwrap();
549
550 assert!(users_profile.priority() < users_posts.priority()); assert!(users_posts.priority() < users_files.priority()); assert_eq!(users_profile.priority(), 7);
557 assert_eq!(users_posts.priority(), 12);
559 assert_eq!(users_files.priority(), 107);
561 }
562
563 #[test]
564 fn test_compiled_route_matching() {
565 let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
566 let route = CompiledRoute::new("test".to_string(), HttpMethod::GET, pattern);
567
568 assert!(route.matches(&HttpMethod::GET, "/users/123"));
569 assert!(!route.matches(&HttpMethod::POST, "/users/123")); assert!(!route.matches(&HttpMethod::GET, "/users/abc")); }
572}