From c6666c2da4650803d02d183ee5103703ec96ba1e Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 2 Jun 2026 22:17:09 +0530 Subject: [PATCH 01/19] feat(router): add wildcard path matching support --- cot/src/router/path.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index ba1f6fd67..877ce347d 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -86,6 +86,22 @@ impl PathMatcher { (Some('/') | None, State::Param { start }) => { panic!("Unclosed parameter: `{}`", &path_pattern[start..index]); } + (Some('*'), State::Literal { start } ) => { + let literal_name = &path_pattern[start..index]; + let param_name = &path_pattern[index..].trim(); + let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); + if next_char.is_none() { + panic!("Wildcard must be named: `{}`", path_pattern); + } + if param_name.contains("/") { + panic!("Wildcard must be last: `{}`", param_name); + } + parts.push(PathPart::Literal(literal_name.to_string())); + parts.push(PathPart::Param { + name: (*param_name).to_string(), + }); + break; + } _ => {} } } @@ -126,6 +142,10 @@ impl PathMatcher { } current_path = ¤t_path[s.len()..]; } + PathPart::Param { name } if name.starts_with('*') => { + params.push(PathParam::new(name, current_path)); + current_path = ""; + } PathPart::Param { name } => { let next_slash = current_path.find('/'); let value = if let Some(next_slash) = next_slash { From 22d88ac48925216547bbfa9712a2344e3fe2bee7 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 2 Jun 2026 22:17:51 +0530 Subject: [PATCH 02/19] test(router): add tests for wildcard path matching --- cot/src/router/path.rs | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 877ce347d..0192c9d2c 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -546,4 +546,76 @@ mod tests { let params = ReverseParamMap::new(); assert_eq!(path_parser.reverse(¶ms).unwrap(), "/café/test"); } + + #[test] + fn path_parser_wildcard_root() { + let path_parser = PathMatcher::new("/*path"); + assert_eq!( + path_parser.capture("/foo/bar"), + Some(CaptureResult::new( + vec![PathParam::new("*path", "foo/bar")], + "" + )) + ); + } + + #[test] + fn path_parser_wildcard_single_segment() { + let path_parser = PathMatcher::new("/users/rand/*path"); + assert_eq!( + path_parser.capture("/users/rand/foo"), + Some(CaptureResult::new( + vec![PathParam::new("*path", "foo")], + "" + )) + ); + } + + #[test] + fn path_parser_wildcard_multi_segment() { + let path_parser = PathMatcher::new("/users/rand/*path"); + assert_eq!( + path_parser.capture("/users/rand/foo/bar"), + Some(CaptureResult::new( + vec![PathParam::new("*path", "foo/bar")], + "" + )) + ); + } + + #[test] + fn path_parser_wildcard_no_match() { + let path_parser = PathMatcher::new("/prefix/*path"); + assert_eq!(path_parser.capture("/other/foo"), None); + } + + #[test] + fn path_parser_wildcard_empty() { + let path_parser = PathMatcher::new("/users/rand/*path"); + assert_eq!( + path_parser.capture("/users/rand/"), + Some(CaptureResult::new(vec![PathParam::new("*path", "")], "")) + ); + } + + #[test] + fn path_parser_wildcard_with_param() { + let path_parser = PathMatcher::new("/users/{id}/*path"); + assert_eq!( + path_parser.capture("/users/42/foo"), + Some(CaptureResult::new(vec![PathParam::new("id", "42"), PathParam::new("*path", "foo")], "")) + ); + } + + #[test] + #[should_panic(expected = "Wildcard must be last: `*path/extra`")] + fn path_parser_wildcard_with_trailing_literal() { + let _ = PathMatcher::new("/*path/extra"); + } + + #[test] + #[should_panic(expected = "Wildcard must be named: `users/rand/*`")] + fn path_parser_with_no_wildcard_name() { + let _ = PathMatcher::new("users/rand/*"); + } } From 30250f122d0860bfaf0d27df78da5dc7b62da294 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 2 Jun 2026 22:27:37 +0530 Subject: [PATCH 03/19] fix(router): fixed formatting --- cot/src/router/path.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 0192c9d2c..dbdbd3c49 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -86,7 +86,7 @@ impl PathMatcher { (Some('/') | None, State::Param { start }) => { panic!("Unclosed parameter: `{}`", &path_pattern[start..index]); } - (Some('*'), State::Literal { start } ) => { + (Some('*'), State::Literal { start }) => { let literal_name = &path_pattern[start..index]; let param_name = &path_pattern[index..].trim(); let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); @@ -564,10 +564,7 @@ mod tests { let path_parser = PathMatcher::new("/users/rand/*path"); assert_eq!( path_parser.capture("/users/rand/foo"), - Some(CaptureResult::new( - vec![PathParam::new("*path", "foo")], - "" - )) + Some(CaptureResult::new(vec![PathParam::new("*path", "foo")], "")) ); } @@ -603,7 +600,10 @@ mod tests { let path_parser = PathMatcher::new("/users/{id}/*path"); assert_eq!( path_parser.capture("/users/42/foo"), - Some(CaptureResult::new(vec![PathParam::new("id", "42"), PathParam::new("*path", "foo")], "")) + Some(CaptureResult::new( + vec![PathParam::new("id", "42"), PathParam::new("*path", "foo")], + "" + )) ); } From d4d8c3982b9063ca72221710a8a0511ade94b3e3 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Wed, 3 Jun 2026 11:18:09 +0530 Subject: [PATCH 04/19] docs(introduction.md): document wildcard path matching --- docs/introduction.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/introduction.md b/docs/introduction.md index b2658a61c..d3ced8a3d 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -171,6 +171,25 @@ fn router(&self) -> Router { Now, when you visit [`localhost:8000/hello/John/Smith/`](http://localhost:8000/hello/John), you should see `Hello, John Smith!` displayed on the page! +cot also has wildcards to match all sub path in a segment: + +```rust +async fn wildcard_path(Path(path): Path<(String, String)>) -> cot::Result { + Ok(Html::new(format!("Passed path: {path}"))) +} + +// inside `impl App`: + +fn router(&self) -> Router { + Router::with_urls([ + // ... + Route::with_handler_and_name("/wildcard/*path", wildcard_path, "wildcard_path"), + ]) +} +``` + +In this case, it matches `/wildcard/`, `/wildcard/foo`, `/wildcard/foo/bar` and so on. + ## Project structure ### App From 8d0ab7331c69f106101d8c5a5109e275ad5ba99c Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Wed, 3 Jun 2026 11:37:57 +0530 Subject: [PATCH 05/19] fix(router): fixed clippy errors and warnings --- cot/src/router/path.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index dbdbd3c49..b73b32e20 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -90,12 +90,10 @@ impl PathMatcher { let literal_name = &path_pattern[start..index]; let param_name = &path_pattern[index..].trim(); let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); - if next_char.is_none() { - panic!("Wildcard must be named: `{}`", path_pattern); - } - if param_name.contains("/") { - panic!("Wildcard must be last: `{}`", param_name); - } + + assert!(next_char.is_some(), "Wildcard must be named: `{path_pattern}`"); + assert!(!param_name.contains('/'), "Wildcard must be last: `{param_name}`"); + parts.push(PathPart::Literal(literal_name.to_string())); parts.push(PathPart::Param { name: (*param_name).to_string(), From 7378518e98654d97b644713321d1ac0e26384fc8 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Wed, 3 Jun 2026 11:40:27 +0530 Subject: [PATCH 06/19] fix(router): fixed formatting --- cot/src/router/path.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index b73b32e20..f09567859 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -91,8 +91,14 @@ impl PathMatcher { let param_name = &path_pattern[index..].trim(); let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); - assert!(next_char.is_some(), "Wildcard must be named: `{path_pattern}`"); - assert!(!param_name.contains('/'), "Wildcard must be last: `{param_name}`"); + assert!( + next_char.is_some(), + "Wildcard must be named: `{path_pattern}`" + ); + assert!( + !param_name.contains('/'), + "Wildcard must be last: `{param_name}`" + ); parts.push(PathPart::Literal(literal_name.to_string())); parts.push(PathPart::Param { From 0dc4687204897e5e30b51fac993215e793e5002d Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Wed, 3 Jun 2026 13:47:04 +0530 Subject: [PATCH 07/19] test(router): add test for wildcard param --- cot/src/router/path.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index f09567859..7c58e5c57 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -99,6 +99,10 @@ impl PathMatcher { !param_name.contains('/'), "Wildcard must be last: `{param_name}`" ); + assert!( + Self::is_param_name_valid(¶m_name[1..]), + "Invalid parameter name: `{param_name}`" + ); parts.push(PathPart::Literal(literal_name.to_string())); parts.push(PathPart::Param { @@ -622,4 +626,20 @@ mod tests { fn path_parser_with_no_wildcard_name() { let _ = PathMatcher::new("users/rand/*"); } + + #[test] + fn path_parser_with_wildcard_name_contains_number_and_strings() { + let _ = PathMatcher::new("users/rand/*path123"); + } + + #[test] + fn path_parser_with_wildcard_name_starts_with_underscore() { + let _ = PathMatcher::new("users/rand/*_path"); + } + + #[test] + #[should_panic(expected = "Invalid parameter name: `*42`")] + fn path_parser_with_wildcard_name_starts_with_numbers() { + let _ = PathMatcher::new("users/rand/*42"); + } } From 4788c9d6c2170c2432f5cc0270520f0207322f07 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 9 Jun 2026 21:56:52 +0530 Subject: [PATCH 08/19] fix(router): change wildcard param syntax --- cot/src/router/path.rs | 157 +++++++++-------------------------------- 1 file changed, 34 insertions(+), 123 deletions(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 7c58e5c57..6157f4c8f 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -73,43 +73,37 @@ impl PathMatcher { } (Some('}'), State::Param { start }) => { let param_name = &path_pattern[start..index].trim(); - assert!( - Self::is_param_name_valid(param_name), - "Invalid parameter name: `{param_name}`" - ); - - parts.push(PathPart::Param { - name: (*param_name).to_string(), - }); + + if let Some(wildcard_name) = param_name.strip_prefix('*') { + assert!( + Self::is_param_name_valid(wildcard_name), + "Invalid parameter name: `{wildcard_name}`" + ); + + let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); + assert!( + next_char.is_none(), + "Wildcard must be the last part of the path: `{path_pattern}`" + ); + + parts.push(PathPart::Wildcard { + name: wildcard_name.to_string(), + }); + } else { + assert!( + Self::is_param_name_valid(param_name), + "Invalid parameter name: `{param_name}`" + ); + + parts.push(PathPart::Param { + name: param_name.to_string(), + }); + } state = State::Literal { start: index + 1 }; } (Some('/') | None, State::Param { start }) => { panic!("Unclosed parameter: `{}`", &path_pattern[start..index]); } - (Some('*'), State::Literal { start }) => { - let literal_name = &path_pattern[start..index]; - let param_name = &path_pattern[index..].trim(); - let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); - - assert!( - next_char.is_some(), - "Wildcard must be named: `{path_pattern}`" - ); - assert!( - !param_name.contains('/'), - "Wildcard must be last: `{param_name}`" - ); - assert!( - Self::is_param_name_valid(¶m_name[1..]), - "Invalid parameter name: `{param_name}`" - ); - - parts.push(PathPart::Literal(literal_name.to_string())); - parts.push(PathPart::Param { - name: (*param_name).to_string(), - }); - break; - } _ => {} } } @@ -150,7 +144,10 @@ impl PathMatcher { } current_path = ¤t_path[s.len()..]; } - PathPart::Param { name } if name.starts_with('*') => { + PathPart::Wildcard { name } => { + if current_path.is_empty() { + return None; + } params.push(PathParam::new(name, current_path)); current_path = ""; } @@ -179,7 +176,7 @@ impl PathMatcher { for part in &self.parts { match part { PathPart::Literal(s) => result.push_str(s), - PathPart::Param { name } => { + PathPart::Param { name } | PathPart::Wildcard { name } => { let value = params .get(name) .ok_or_else(|| ReverseError::MissingParam(name.clone()))?; @@ -199,7 +196,7 @@ impl PathMatcher { pub(super) fn param_names(&self) -> impl Iterator { self.parts.iter().filter_map(|part| match part { PathPart::Literal(..) => None, - PathPart::Param { name } => Some(name.as_str()), + PathPart::Param { name } | PathPart::Wildcard { name } => Some(name.as_str()), }) } } @@ -327,6 +324,7 @@ impl<'matcher, 'path> CaptureResult<'matcher, 'path> { enum PathPart { Literal(String), Param { name: String }, + Wildcard { name: String }, } impl Display for PathPart { @@ -337,6 +335,7 @@ impl Display for PathPart { write!(f, "{s}") } PathPart::Param { name } => write!(f, "{{{name}}}"), + PathPart::Wildcard { name } => write!(f, "{{*{name}}}"), } } } @@ -554,92 +553,4 @@ mod tests { let params = ReverseParamMap::new(); assert_eq!(path_parser.reverse(¶ms).unwrap(), "/café/test"); } - - #[test] - fn path_parser_wildcard_root() { - let path_parser = PathMatcher::new("/*path"); - assert_eq!( - path_parser.capture("/foo/bar"), - Some(CaptureResult::new( - vec![PathParam::new("*path", "foo/bar")], - "" - )) - ); - } - - #[test] - fn path_parser_wildcard_single_segment() { - let path_parser = PathMatcher::new("/users/rand/*path"); - assert_eq!( - path_parser.capture("/users/rand/foo"), - Some(CaptureResult::new(vec![PathParam::new("*path", "foo")], "")) - ); - } - - #[test] - fn path_parser_wildcard_multi_segment() { - let path_parser = PathMatcher::new("/users/rand/*path"); - assert_eq!( - path_parser.capture("/users/rand/foo/bar"), - Some(CaptureResult::new( - vec![PathParam::new("*path", "foo/bar")], - "" - )) - ); - } - - #[test] - fn path_parser_wildcard_no_match() { - let path_parser = PathMatcher::new("/prefix/*path"); - assert_eq!(path_parser.capture("/other/foo"), None); - } - - #[test] - fn path_parser_wildcard_empty() { - let path_parser = PathMatcher::new("/users/rand/*path"); - assert_eq!( - path_parser.capture("/users/rand/"), - Some(CaptureResult::new(vec![PathParam::new("*path", "")], "")) - ); - } - - #[test] - fn path_parser_wildcard_with_param() { - let path_parser = PathMatcher::new("/users/{id}/*path"); - assert_eq!( - path_parser.capture("/users/42/foo"), - Some(CaptureResult::new( - vec![PathParam::new("id", "42"), PathParam::new("*path", "foo")], - "" - )) - ); - } - - #[test] - #[should_panic(expected = "Wildcard must be last: `*path/extra`")] - fn path_parser_wildcard_with_trailing_literal() { - let _ = PathMatcher::new("/*path/extra"); - } - - #[test] - #[should_panic(expected = "Wildcard must be named: `users/rand/*`")] - fn path_parser_with_no_wildcard_name() { - let _ = PathMatcher::new("users/rand/*"); - } - - #[test] - fn path_parser_with_wildcard_name_contains_number_and_strings() { - let _ = PathMatcher::new("users/rand/*path123"); - } - - #[test] - fn path_parser_with_wildcard_name_starts_with_underscore() { - let _ = PathMatcher::new("users/rand/*_path"); - } - - #[test] - #[should_panic(expected = "Invalid parameter name: `*42`")] - fn path_parser_with_wildcard_name_starts_with_numbers() { - let _ = PathMatcher::new("users/rand/*42"); - } } From 22fbeed6564ce0bb829042bed150ca5a6a082550 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 9 Jun 2026 22:00:00 +0530 Subject: [PATCH 09/19] test(router): change test for new path syntax --- cot/src/router/path.rs | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 6157f4c8f..15bb38edd 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -553,4 +553,49 @@ mod tests { let params = ReverseParamMap::new(); assert_eq!(path_parser.reverse(¶ms).unwrap(), "/café/test"); } + + #[test] + fn path_parser_wildcard_root() { + let path_parser = PathMatcher::new("/{*path}"); + assert_eq!( + path_parser.capture("/foo/bar"), + Some(CaptureResult::new( + vec![PathParam::new("path", "foo/bar")], + "" + )) + ); + } + + #[test] + fn path_parser_wildcard_single_segment() { + let path_parser = PathMatcher::new("/users/rand/{*path}"); + assert_eq!( + path_parser.capture("/users/rand/foo"), + Some(CaptureResult::new(vec![PathParam::new("path", "foo")], "")) + ); + } + + #[test] + fn path_parser_wildcard_multi_segment() { + let path_parser = PathMatcher::new("/users/rand/{*path}"); + assert_eq!( + path_parser.capture("/users/rand/foo/bar"), + Some(CaptureResult::new( + vec![PathParam::new("path", "foo/bar")], + "" + )) + ); + } + + #[test] + fn path_parser_wildcard_no_match() { + let path_parser = PathMatcher::new("/prefix/{*path}"); + assert_eq!(path_parser.capture("/other/foo"), None); + } + + #[test] + fn path_parser_wildcard_empty_not_allowed() { + let path_parser = PathMatcher::new("/users/rand/{*path}"); + assert_eq!(path_parser.capture("/users/rand/"), None); + } } From 669fac55cf91285d8c41d34347747ce5e3196e30 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Wed, 10 Jun 2026 13:17:06 +0530 Subject: [PATCH 10/19] docs(introduction.md): update wildcard params to new syntax --- docs/introduction.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index e0cd56e17..f14a35de9 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -171,10 +171,10 @@ fn router(&self) -> Router { Now, when you visit [`localhost:8000/hello/John/Smith/`](http://localhost:8000/hello/John), you should see `Hello, John Smith!` displayed on the page! -cot also has wildcards to match all sub path in a segment: +Cot also supports wildcard parameters to match all sub-paths within a route segment. ```rust -async fn wildcard_path(Path(path): Path<(String, String)>) -> cot::Result { +async fn wildcard_path(Path(path): Path) -> cot::Result { Ok(Html::new(format!("Passed path: {path}"))) } @@ -183,12 +183,15 @@ async fn wildcard_path(Path(path): Path<(String, String)>) -> cot::Result fn router(&self) -> Router { Router::with_urls([ // ... - Route::with_handler_and_name("/wildcard/*path", wildcard_path, "wildcard_path"), + Route::with_handler_and_name("/wildcard/{*path}", wildcard_path, "wildcard_path"), ]) } ``` -In this case, it matches `/wildcard/`, `/wildcard/foo`, `/wildcard/foo/bar` and so on. +Prefixing the parameter name with an asterisk (*) designates it as a wildcard. + +In this case, it matches `/wildcard/foo`, `/wildcard/foo/bar` and so on, but it won't match `/wildcard/`. +Also note that, wildcard param can only be used on the end of path pattern. ## Project structure From 4559b716979cab295f86796a503ee373a057107c Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Thu, 11 Jun 2026 14:35:49 +0530 Subject: [PATCH 11/19] docs(introduction.md): fix wildcard routing code example --- docs/introduction.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index e67bbec56..d059ecca9 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -184,21 +184,23 @@ Now, when you visit [`localhost:8000/hello/John/Smith/`](http://localhost:8000/h Cot also supports wildcard parameters to match all sub-paths within a route segment. ```rust +# struct MyApp; async fn wildcard_path(Path(path): Path) -> cot::Result { Ok(Html::new(format!("Passed path: {path}"))) } // inside `impl App`: - +# impl App for MyApp { fn router(&self) -> Router { Router::with_urls([ // ... Route::with_handler_and_name("/wildcard/{*path}", wildcard_path, "wildcard_path"), ]) } +# } ``` -Prefixing the parameter name with an asterisk (*) designates it as a wildcard. +Prefixing the parameter name with an asterisk (`*`) designates it as a wildcard. In this case, it matches `/wildcard/foo`, `/wildcard/foo/bar` and so on, but it won't match `/wildcard/`. Also note that, wildcard param can only be used on the end of path pattern. From 0f455360285b0a658296c9ca2ac49051d96e7862 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Thu, 11 Jun 2026 16:47:35 +0530 Subject: [PATCH 12/19] docs(introduction.md): fix missed name fn in wildcard routing code example --- docs/introduction.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/introduction.md b/docs/introduction.md index d059ecca9..d6bea2caa 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -197,6 +197,7 @@ fn router(&self) -> Router { Route::with_handler_and_name("/wildcard/{*path}", wildcard_path, "wildcard_path"), ]) } +# fn name(&self) -> &str { todo!() } # } ``` From 83a3ca0397a7c893195a9b3d1da24a72f17effd1 Mon Sep 17 00:00:00 2001 From: Dharshan <110713600+dharshan-0@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:29:21 +0530 Subject: [PATCH 13/19] fix(router): improve error message for invalid wildcard parameter names Co-authored-by: EBADF --- cot/src/router/path.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 15bb38edd..48b9f73c8 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -77,7 +77,7 @@ impl PathMatcher { if let Some(wildcard_name) = param_name.strip_prefix('*') { assert!( Self::is_param_name_valid(wildcard_name), - "Invalid parameter name: `{wildcard_name}`" + "Invalid wildcard parameter name: `{wildcard_name}`" ); let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default(); From ccd7901cdc2fb9aba24e462caf3ece3af20353b6 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Sun, 28 Jun 2026 12:39:34 +0530 Subject: [PATCH 14/19] test(router): add test for illegal wildcard --- cot/src/router/path.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 48b9f73c8..775566114 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -598,4 +598,10 @@ mod tests { let path_parser = PathMatcher::new("/users/rand/{*path}"); assert_eq!(path_parser.capture("/users/rand/"), None); } + + #[test] + #[should_panic(expected = "Wildcard must be the last part of the path: `/users/{*rest}/`")] + fn path_parser_no_path_allowed_after_wildcard() { + let _ = PathMatcher::new("/users/{*rest}/"); + } } From 77ea6cae13ce63fa9022613ea084f2da46f0411a Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Sun, 28 Jun 2026 12:58:56 +0530 Subject: [PATCH 15/19] feat(router): add precedence based routing --- cot/src/router.rs | 6 +++-- cot/src/router/path.rs | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/cot/src/router.rs b/cot/src/router.rs index 4c733e249..b690028ef 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -36,7 +36,7 @@ use tracing::debug; use crate::error::NotFound; use crate::request::{PathParams, Request, RequestExt, RequestHead}; use crate::response::Response; -use crate::router::path::{CaptureResult, PathMatcher, ReverseParamMap}; +use crate::router::path::{CaptureResult, PathMatcher, ReverseParamMap, compare_weights}; use crate::{Error, ProjectContext, Result}; pub mod method; @@ -102,7 +102,9 @@ impl Router { /// ``` #[must_use] pub fn with_urls>>(urls: T) -> Self { - let urls = urls.into(); + let mut urls = urls.into(); + urls.sort_by(|a, b| compare_weights(&a.url.token_weights(), &b.url.token_weights())); + let mut names = HashMap::new(); for url in &urls { diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 775566114..c8e5f21be 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -199,6 +199,66 @@ impl PathMatcher { PathPart::Param { name } | PathPart::Wildcard { name } => Some(name.as_str()), }) } + + pub(crate) fn token_weights(&self) -> Vec { + let mut weights = Vec::with_capacity(self.parts.len()); + for part in &self.parts { + match part { + PathPart::Literal(s) => { + for _ in 0..s.split('/').filter(|s| !s.is_empty()).count() { + weights.push(0); + } + } + PathPart::Param { .. } => weights.push(1), + PathPart::Wildcard { .. } => weights.push(2), + } + } + weights + } +} + +pub(crate) fn compare_weights(a: &[u8], b: &[u8]) -> std::cmp::Ordering { + let max_len = std::cmp::max(a.len(), b.len()); + let mut score: isize = 0; + let mut lexical_order_a = String::with_capacity(a.len()); + let mut lexical_order_b = String::with_capacity(b.len()); + + for i in 0..max_len { + let (wa, wb) = match (a.get(i), b.get(i)) { + (None, _) => return std::cmp::Ordering::Less, + (_, None) => return std::cmp::Ordering::Greater, + (Some(&w), Some(&v)) => (w, v), + }; + lexical_order_a.push(char::from_digit(wa as u32, 10).unwrap()); + lexical_order_b.push(char::from_digit(wb as u32, 10).unwrap()); + + if wa == 2 && wb == 2 { + return lexical_order_a.cmp(&lexical_order_b); + } + if wa == 2 { + return std::cmp::Ordering::Greater; + } + if wb == 2 { + return std::cmp::Ordering::Less; + } + if wa != wb { + if wa < wb { + score += 1; + } else { + score -= 1; + } + } + } + + if score != 0 { + return if score > 0 { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + + lexical_order_a.cmp(&lexical_order_b) } impl Display for PathMatcher { From 66adf0ce8c1b49b00d3904a74602da8192d74534 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Sun, 28 Jun 2026 13:07:01 +0530 Subject: [PATCH 16/19] test(router.rs): add test for precedence based routing --- cot/src/router.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/cot/src/router.rs b/cot/src/router.rs index b690028ef..1e8761747 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -1066,6 +1066,14 @@ mod tests { } } + struct SelectedHandler; + + impl RequestHandler for SelectedHandler { + async fn handle(&self, _request: Request) -> Result { + Html::new("selected").into_response() + } + } + #[cfg(feature = "openapi")] impl crate::openapi::AsApiRoute for MockHandler { fn as_api_route( @@ -1289,4 +1297,129 @@ mod tests { fn test_request() -> Request { TestRequestBuilder::get("/test").build() } + + #[cot::test] + async fn prefer_literal_over_wildcard_placeholder() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{*path}", MockHandler), + Route::with_handler("/foo/{param}", MockHandler), + Route::with_handler("/foo/bar", SelectedHandler), + ]); + let response = router + .route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar") + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_literal_over_placeholder_wildcard() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{prarm}", MockHandler), + Route::with_handler("/foo/bar", SelectedHandler), + Route::with_handler("/foo/{*path}", MockHandler), + ]); + let response = router + .route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar") + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_wildcard_as_last() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/bar/baz", MockHandler), + Route::with_handler("/foo/{param}/baz", MockHandler), + Route::with_handler("/foo/{*path}", SelectedHandler), + ]); + let response = router + .route( + TestRequestBuilder::get("/foo/bar/boo").build(), + "/foo/bar/boo", + ) + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_param_over_wildcard() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{*path}", MockHandler), + Route::with_handler("/foo/{param}", SelectedHandler), + ]); + let response = router + .route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar") + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_literal_over_param() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{param}", MockHandler), + Route::with_handler("/foo/bar", SelectedHandler), + ]); + let response = router + .route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar") + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_more_literal_wildcard_over_less_literal_wildcard() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{*path}", MockHandler), + Route::with_handler("/foo/bar/{*path}", SelectedHandler), + ]); + let response = router + .route( + TestRequestBuilder::get("/foo/bar/baz").build(), + "/foo/bar/baz", + ) + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_sub_router_literal_over_wildcard() { + let sub_router = Router::with_urls(vec![ + Route::with_handler("/{*path}", MockHandler), + Route::with_handler("/bar", SelectedHandler), + ]); + let router = Router::with_urls(vec![Route::with_router("/foo", sub_router)]); + let response = router + .route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar") + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } + + #[cot::test] + async fn prefer_more_leading_literal_route_on_tie() { + let router = Router::with_urls(vec![ + Route::with_handler("/foo/{param}/baz", MockHandler), + Route::with_handler("/foo/bar/{param}", SelectedHandler), + ]); + let response = router + .route( + TestRequestBuilder::get("/foo/bar/baz").build(), + "/foo/bar/baz", + ) + .await + .unwrap(); + let body = response.into_body().into_bytes().await.unwrap(); + assert_eq!(body, "selected".as_bytes()); + } } From e854310d6bef5ae3bf0fc7a8533d92629b82cd12 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Sun, 28 Jun 2026 19:17:42 +0530 Subject: [PATCH 17/19] docs(introduction.md): add documentation for route precedence --- docs/introduction.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/introduction.md b/docs/introduction.md index d6bea2caa..70a6bde25 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -204,7 +204,37 @@ fn router(&self) -> Router { Prefixing the parameter name with an asterisk (`*`) designates it as a wildcard. In this case, it matches `/wildcard/foo`, `/wildcard/foo/bar` and so on, but it won't match `/wildcard/`. -Also note that, wildcard param can only be used on the end of path pattern. + +Also note that, wildcard param can only be used at the end of path pattern. + +### Routes precedence + +When a request path matches multiple routes, cot automatically uses the most specific route, so you do not need to worry about the order in which you define your routes. + +It looks something like this: + +**Literal segments** > **parameter segments** > **wildcard segments**. + +#### Example 1: + +If you have: + +- `/{*wildcard}` +- `/foo/{param}` +- `/foo/bar` + +For a request to `/foo/bar`, all three routes technically match. However, the 3rd route (`/foo/bar`) wins because explicit literal segments take absolute priority over dynamic parameters and wildcards. + +#### Example 2: + +Consider a scenario where two routes have an identical structural mix of literals and parameters: + +- Route A: `/foo/{param}/baz` *(Literal, Param, Literal)* +- Route B: `/foo/bar/{param}` *(Weights: Literal, Literal, Param)* + +For request `/foo/bar/baz`, it is tie, because two routes have same number of *literals* and *placeholders*, so to break the tie cot evaluates the patterns from left to right. + +At the second segment, **Route B** provides a concrete literal (`/bar`), whereas **Route A** opens up to a dynamic parameter (`{param}`). Because literals are more specific than placeholders, **Route B** wins. ## Project structure From 0e6c9ef81fc582032f6e8227975032d7fbc7ea86 Mon Sep 17 00:00:00 2001 From: "S.Dharshan" Date: Tue, 30 Jun 2026 09:14:08 +0530 Subject: [PATCH 18/19] fix(router): made clippy happy --- cot/src/router/path.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index 1e6826156..3d2af2852 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -203,6 +203,7 @@ impl PathMatcher { }) } + #[expect(clippy::same_item_push)] pub(crate) fn token_weights(&self) -> Vec { let mut weights = Vec::with_capacity(self.parts.len()); for part in &self.parts { @@ -232,8 +233,8 @@ pub(crate) fn compare_weights(a: &[u8], b: &[u8]) -> std::cmp::Ordering { (_, None) => return std::cmp::Ordering::Greater, (Some(&w), Some(&v)) => (w, v), }; - lexical_order_a.push(char::from_digit(wa as u32, 10).unwrap()); - lexical_order_b.push(char::from_digit(wb as u32, 10).unwrap()); + lexical_order_a.push(char::from_digit(u32::from(wa), 10).unwrap()); + lexical_order_b.push(char::from_digit(u32::from(wb), 10).unwrap()); if wa == 2 && wb == 2 { return lexical_order_a.cmp(&lexical_order_b); From dd863b8303f61e0ba7985130e2a8c7e2185ddf02 Mon Sep 17 00:00:00 2001 From: Dharshan <110713600+dharshan-0@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:59:54 +0530 Subject: [PATCH 19/19] fix(router.rs): fixed typo in the test Co-authored-by: Marek Grzelak --- cot/src/router.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/router.rs b/cot/src/router.rs index 7c7e27930..16caa603a 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -1335,7 +1335,7 @@ mod tests { #[cot::test] async fn prefer_literal_over_placeholder_wildcard() { let router = Router::with_urls(vec![ - Route::with_handler("/foo/{prarm}", MockHandler), + Route::with_handler("/foo/{param}", MockHandler), Route::with_handler("/foo/bar", SelectedHandler), Route::with_handler("/foo/{*path}", MockHandler), ]);