use std::collections::BTreeMap;
pub type RouteParams = BTreeMap<String, String>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
pub path: String,
pub segments: Vec<String>,
pub query: RouteParams,
}
impl Location {
pub fn parse(raw: &str) -> Location {
let (path_part, query_part) = match raw.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (raw, None),
};
let segments: Vec<String> = path_part
.split('/')
.filter(|s| !s.is_empty())
.map(percent_decode)
.collect();
let path = if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
};
let mut query = RouteParams::new();
if let Some(q) = query_part {
for pair in q.split('&').filter(|s| !s.is_empty()) {
let (k, v) = match pair.split_once('=') {
Some((k, v)) => (percent_decode(k), percent_decode(v)),
None => (percent_decode(pair), String::new()),
};
query.insert(k, v);
}
}
Location {
path,
segments,
query,
}
}
pub fn location_string(&self) -> String {
if self.query.is_empty() {
self.path.clone()
} else {
let query: Vec<String> = self
.query
.iter()
.map(|(k, v)| {
if v.is_empty() {
percent_encode(k)
} else {
format!("{}={}", percent_encode(k), percent_encode(v))
}
})
.collect();
format!("{}?{}", self.path, query.join("&"))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Segment {
Static(String),
Param(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathPattern {
segments: Vec<Segment>,
}
impl PathPattern {
pub fn parse(pattern: &str) -> PathPattern {
let segments = pattern
.split('/')
.filter(|s| !s.is_empty())
.map(|s| match s.strip_prefix(':') {
Some(name) => Segment::Param(name.to_string()),
None => Segment::Static(s.to_string()),
})
.collect();
PathPattern { segments }
}
pub fn len(&self) -> usize {
self.segments.len()
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
pub fn match_prefix(&self, segs: &[String]) -> Option<(usize, RouteParams)> {
if segs.len() < self.segments.len() {
return None;
}
let mut params = RouteParams::new();
for (pat, seg) in self.segments.iter().zip(segs.iter()) {
match pat {
Segment::Static(s) => {
if s != seg {
return None;
}
}
Segment::Param(name) => {
params.insert(name.clone(), seg.clone());
}
}
}
Some((self.segments.len(), params))
}
}
fn percent_decode(s: &str) -> String {
if !s.contains('%') {
return s.to_string();
}
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'%' | b'?' | b'&' | b'=' | b'#' | b'/' | b' ' => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
_ => out.push(b as char),
}
}
out
}
pub(super) fn encode_segment(s: &str) -> String {
percent_encode(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn segs(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_static_path() {
let loc = Location::parse("/users/list");
assert_eq!(loc.path, "/users/list");
assert_eq!(loc.segments, segs(&["users", "list"]));
assert!(loc.query.is_empty());
}
#[test]
fn root_path_normalizes() {
let loc = Location::parse("/");
assert_eq!(loc.path, "/");
assert!(loc.segments.is_empty());
assert_eq!(Location::parse("").path, "/");
}
#[test]
fn trailing_slash_is_tolerated() {
assert_eq!(Location::parse("/users/").segments, segs(&["users"]));
assert_eq!(Location::parse("/users").segments, segs(&["users"]));
assert_eq!(
Location::parse("/users/").location_string(),
Location::parse("/users").location_string()
);
}
#[test]
fn parses_query() {
let loc = Location::parse("/users/42?tab=posts&sort=asc");
assert_eq!(loc.segments, segs(&["users", "42"]));
assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
assert_eq!(loc.query.get("sort").map(String::as_str), Some("asc"));
assert_eq!(loc.location_string(), "/users/42?sort=asc&tab=posts");
}
#[test]
fn query_key_without_value() {
let loc = Location::parse("/search?debug");
assert_eq!(loc.query.get("debug").map(String::as_str), Some(""));
}
#[test]
fn percent_decodes_segments_and_query() {
let loc = Location::parse("/notes/hello%20world?q=a%26b");
assert_eq!(loc.segments, segs(&["notes", "hello world"]));
assert_eq!(loc.query.get("q").map(String::as_str), Some("a&b"));
}
#[test]
fn matches_static_pattern() {
let pat = PathPattern::parse("/users");
let (consumed, params) = pat.match_prefix(&segs(&["users"])).expect("matches");
assert_eq!(consumed, 1);
assert!(params.is_empty());
assert!(pat.match_prefix(&segs(&["posts"])).is_none());
}
#[test]
fn captures_param() {
let pat = PathPattern::parse("/users/:id");
let (consumed, params) = pat.match_prefix(&segs(&["users", "42"])).expect("matches");
assert_eq!(consumed, 2);
assert_eq!(params.get("id").map(String::as_str), Some("42"));
}
#[test]
fn match_prefix_leaves_remaining_segments() {
let parent = PathPattern::parse("/users");
let (consumed, _) = parent
.match_prefix(&segs(&["users", "42", "posts"]))
.expect("prefix matches");
assert_eq!(consumed, 1);
}
#[test]
fn too_few_segments_fails() {
let pat = PathPattern::parse("/users/:id");
assert!(pat.match_prefix(&segs(&["users"])).is_none());
}
#[test]
fn empty_pattern_consumes_nothing() {
let pat = PathPattern::parse("/");
assert!(pat.is_empty());
let (consumed, params) = pat.match_prefix(&segs(&["anything"])).expect("matches");
assert_eq!(consumed, 0);
assert!(params.is_empty());
}
}