use std::collections::HashMap;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use rainier_support::{Error, Result};
use crate::route::{compile, Segment};
const PATH_SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'%');
const QUERY_COMPONENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'%')
.add(b'&')
.add(b'=')
.add(b'+')
.add(b'?');
#[derive(Debug, Default, Clone)]
pub struct UrlGenerator {
routes: HashMap<String, String>,
base: Option<String>,
}
impl UrlGenerator {
pub fn new() -> Self {
Self::default()
}
pub fn from_routes(routes: impl IntoIterator<Item = (String, String)>) -> Self {
Self { routes: routes.into_iter().collect(), base: None }
}
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = Some(base.into().trim_end_matches('/').to_string());
self
}
pub fn insert(&mut self, name: impl Into<String>, uri: impl Into<String>) {
self.routes.insert(name.into(), uri.into());
}
pub fn has(&self, name: &str) -> bool {
self.routes.contains_key(name)
}
pub fn route(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
let pattern = self.routes.get(name).ok_or_else(|| {
Error::internal(format!(
"no route is named `{name}` — check the name, or that the route is registered \
before URLs are generated"
))
})?;
let mut supplied: HashMap<&str, &str> = params.iter().copied().collect();
let mut path = String::new();
for segment in compile(pattern) {
match segment {
Segment::Static(literal) => {
path.push('/');
path.push_str(&literal);
}
Segment::Param(param) => {
let value = supplied.remove(param.as_str()).ok_or_else(|| {
Error::internal(format!("the `{name}` route needs a `{param}` parameter"))
})?;
path.push('/');
path.push_str(&utf8_percent_encode(value, PATH_SEGMENT).to_string());
}
Segment::OptionalParam(param) => {
if let Some(value) = supplied.remove(param.as_str()) {
path.push('/');
path.push_str(&utf8_percent_encode(value, PATH_SEGMENT).to_string());
}
}
Segment::Wildcard(param) => {
if let Some(value) = supplied.remove(param.as_str()) {
let encoded: Vec<String> = value
.split('/')
.map(|piece| utf8_percent_encode(piece, PATH_SEGMENT).to_string())
.collect();
path.push('/');
path.push_str(&encoded.join("/"));
}
}
}
}
if path.is_empty() {
path.push('/');
}
if !supplied.is_empty() {
let mut extras: Vec<(&str, &str)> = supplied.into_iter().collect();
extras.sort();
let query = extras
.iter()
.map(|(key, value)| {
format!(
"{}={}",
utf8_percent_encode(key, QUERY_COMPONENT),
utf8_percent_encode(value, QUERY_COMPONENT)
)
})
.collect::<Vec<_>>()
.join("&");
path.push('?');
path.push_str(&query);
}
Ok(path)
}
pub fn absolute(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
let path = self.route(name, params)?;
Ok(self.to(&path))
}
pub fn to(&self, path: &str) -> String {
match &self.base {
Some(base) if path.starts_with('/') => format!("{base}{path}"),
Some(base) => format!("{base}/{path}"),
None => path.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn urls() -> UrlGenerator {
UrlGenerator::from_routes([
("home".to_string(), "/".to_string()),
("posts.index".to_string(), "/posts".to_string()),
("posts.show".to_string(), "/posts/{post}".to_string()),
("posts.comment".to_string(), "/posts/{post}/comments/{comment}".to_string()),
("archive".to_string(), "/archive/{year}/{month?}".to_string()),
("files".to_string(), "/files/{path*}".to_string()),
])
}
#[test]
fn builds_static_urls() {
assert_eq!(urls().route("posts.index", &[]).unwrap(), "/posts");
assert_eq!(urls().route("home", &[]).unwrap(), "/");
}
#[test]
fn substitutes_parameters() {
assert_eq!(urls().route("posts.show", &[("post", "7")]).unwrap(), "/posts/7");
assert_eq!(
urls().route("posts.comment", &[("post", "7"), ("comment", "3")]).unwrap(),
"/posts/7/comments/3"
);
}
#[test]
fn leftover_parameters_become_a_sorted_query_string() {
assert_eq!(
urls().route("posts.index", &[("page", "2"), ("sort", "new")]).unwrap(),
"/posts?page=2&sort=new"
);
assert_eq!(
urls().route("posts.index", &[("sort", "new"), ("page", "2")]).unwrap(),
"/posts?page=2&sort=new"
);
}
#[test]
fn a_missing_required_parameter_names_itself() {
let err = urls().route("posts.show", &[]).err().expect("should fail");
assert!(err.message().contains("`post`"), "{}", err.message());
assert!(err.message().contains("posts.show"), "{}", err.message());
}
#[test]
fn an_unknown_route_name_names_itself() {
let err = urls().route("nope", &[]).err().expect("should fail");
assert!(err.message().contains("`nope`"), "{}", err.message());
}
#[test]
fn optional_parameters_may_be_omitted() {
assert_eq!(urls().route("archive", &[("year", "2026")]).unwrap(), "/archive/2026");
assert_eq!(
urls().route("archive", &[("year", "2026"), ("month", "07")]).unwrap(),
"/archive/2026/07"
);
}
#[test]
fn a_slash_in_a_parameter_is_encoded_not_expanded() {
assert_eq!(urls().route("posts.show", &[("post", "a/b")]).unwrap(), "/posts/a%2Fb");
}
#[test]
fn a_wildcard_keeps_its_slashes() {
assert_eq!(
urls().route("files", &[("path", "docs/a b.txt")]).unwrap(),
"/files/docs/a%20b.txt"
);
}
#[test]
fn values_are_percent_encoded() {
assert_eq!(
urls().route("posts.show", &[("post", "hello world")]).unwrap(),
"/posts/hello%20world"
);
assert_eq!(urls().route("posts.index", &[("q", "a&b")]).unwrap(), "/posts?q=a%26b");
}
#[test]
fn a_query_value_cannot_forge_an_extra_parameter() {
assert_eq!(
urls().route("posts.index", &[("q", "x&admin=1")]).unwrap(),
"/posts?q=x%26admin%3D1"
);
}
#[test]
fn absolute_urls_use_the_base() {
let urls = urls().with_base("https://example.com/");
assert_eq!(
urls.absolute("posts.show", &[("post", "7")]).unwrap(),
"https://example.com/posts/7"
);
assert_eq!(urls.to("/x"), "https://example.com/x");
}
#[test]
fn a_relative_path_keeps_its_path_when_prefixed() {
let urls = urls().with_base("https://example.com");
assert_eq!(urls.to("assets/app.css"), "https://example.com/assets/app.css");
assert_eq!(urls.to("/assets/app.css"), "https://example.com/assets/app.css");
}
#[test]
fn without_a_base_absolute_falls_back_to_the_path() {
assert_eq!(urls().absolute("posts.index", &[]).unwrap(), "/posts");
}
}