use super::super::Route;
use super::super::pattern::PathPattern;
use super::runtime::ReverseResult;
use once_cell::sync::OnceCell;
use reinhardt_core::exception::Error;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::PoisonError;
use std::sync::RwLock as StdRwLock;
static GLOBAL_REVERSER: OnceCell<StdRwLock<Option<Arc<UrlReverser>>>> = OnceCell::new();
#[derive(Clone)]
pub struct UrlReverser {
routes: HashMap<String, Route>,
aliases: HashMap<String, String>,
}
impl UrlReverser {
pub fn new() -> Self {
Self {
routes: HashMap::new(),
aliases: HashMap::new(),
}
}
pub fn register(&mut self, mut route: Route) -> std::result::Result<(), String> {
if let Some(name) = route.name.take() {
route.name = Some(normalize_route_name(&name));
}
if let Some(full_name) = route.full_name() {
use std::collections::hash_map::Entry;
match self.routes.entry(full_name.clone()) {
Entry::Occupied(existing) => Err(format!(
"Duplicate route name '{}': path '{}' conflicts with existing path '{}'",
full_name,
route.path,
existing.get().path
)),
Entry::Vacant(entry) => {
entry.insert(route);
Ok(())
}
}
} else {
Ok(())
}
}
pub fn register_path(&mut self, name: &str, path: &str) -> std::result::Result<(), String> {
use reinhardt_http::Handler;
#[derive(Clone)]
struct DummyHandler;
#[async_trait::async_trait]
impl Handler for DummyHandler {
async fn handle(
&self,
_req: reinhardt_http::Request,
) -> reinhardt_core::exception::Result<reinhardt_http::Response> {
unreachable!("DummyHandler should never be called")
}
}
let (namespace, raw_segment) = match name.rsplit_once(':') {
Some((ns, seg)) => (Some(ns.to_string()), seg),
None => (None, name),
};
let route_name = normalize_route_name(raw_segment);
let qualified_name = match &namespace {
Some(ns) => format!("{ns}:{route_name}"),
None => route_name.clone(),
};
let mut route = Route::new(path, Arc::new(DummyHandler));
route.name = Some(route_name);
let route = if let Some(ns) = namespace {
route.with_namespace(&ns)
} else {
route
};
use std::collections::hash_map::Entry;
match self.routes.entry(qualified_name.clone()) {
Entry::Occupied(existing) => Err(format!(
"Duplicate route name '{}': path '{}' conflicts with existing path '{}'",
qualified_name,
path,
existing.get().path
)),
Entry::Vacant(entry) => {
entry.insert(route);
Ok(())
}
}
}
pub fn reverse(&self, name: &str, params: &HashMap<String, String>) -> ReverseResult<String> {
let route = if let Some(r) = self.routes.get(name) {
r
} else {
let resolved_name = self.aliases.get(name).map(|s| s.as_str()).unwrap_or(name);
self.routes
.get(resolved_name)
.ok_or_else(|| Error::NotFound(name.to_string()))?
};
let pattern = PathPattern::new(&route.path)
.map_err(|e| Error::Validation(format!("pattern: {}", e)))?;
pattern.reverse(params).map_err(Error::Validation)
}
pub fn reverse_with<S: AsRef<str>>(
&self,
name: &str,
params: &[(S, S)],
) -> ReverseResult<String> {
let params_map: HashMap<String, String> = params
.iter()
.map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
.collect();
self.reverse(name, ¶ms_map)
}
pub fn has_route(&self, name: &str) -> bool {
self.routes.contains_key(name)
}
pub fn route_names(&self) -> Vec<String> {
self.routes.keys().cloned().collect()
}
pub fn add_name_alias(&mut self, alias: &str, canonical: &str) {
self.aliases
.insert(alias.to_string(), canonical.to_string());
}
pub fn from_global() -> Arc<UrlReverser> {
Self::try_from_global().expect(
"global URL reverser is not registered. \
Call register_router() before using from_global().",
)
}
pub fn try_from_global() -> Option<Arc<UrlReverser>> {
GLOBAL_REVERSER
.get()
.and_then(|cell| cell.read().unwrap_or_else(PoisonError::into_inner).clone())
}
pub fn register_global(self) {
let cell = GLOBAL_REVERSER.get_or_init(|| StdRwLock::new(None));
let mut guard = cell.write().unwrap_or_else(PoisonError::into_inner);
if guard.is_some() {
panic!("global URL reverser already registered");
}
*guard = Some(Arc::new(self));
}
}
pub(crate) fn set_global_reverser(reverser: UrlReverser) {
let cell = GLOBAL_REVERSER.get_or_init(|| StdRwLock::new(None));
let mut guard = cell.write().unwrap_or_else(PoisonError::into_inner);
*guard = Some(Arc::new(reverser));
}
pub(crate) fn clear_global_reverser() {
if let Some(cell) = GLOBAL_REVERSER.get() {
let mut guard = cell.write().unwrap_or_else(PoisonError::into_inner);
*guard = None;
}
}
impl Default for UrlReverser {
fn default() -> Self {
Self::new()
}
}
pub fn reverse(
name: &str,
params: &HashMap<String, String>,
reverser: &UrlReverser,
) -> ReverseResult<String> {
reverser.reverse(name, params)
}
fn is_kebab_case(segment: &str) -> bool {
!segment.chars().any(|c| c == '_' || c.is_ascii_uppercase())
}
fn to_kebab_case(segment: &str) -> String {
let mut out = String::with_capacity(segment.len() + 4);
let mut prev_is_boundary = true;
for c in segment.chars() {
if c == '_' || c == '-' {
if !prev_is_boundary {
out.push('-');
prev_is_boundary = true;
}
} else if c.is_ascii_uppercase() {
if !prev_is_boundary {
out.push('-');
}
out.push(c.to_ascii_lowercase());
prev_is_boundary = false;
} else {
out.push(c);
prev_is_boundary = false;
}
}
out
}
const URL_NAME_WARNINGS_ENV: &str = "REINHARDT_URL_NAME_WARNINGS";
fn warnings_enabled_from_env(value: Option<&str>) -> bool {
match value {
Some(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
),
None => true,
}
}
fn url_name_warnings_enabled() -> bool {
warnings_enabled_from_env(std::env::var(URL_NAME_WARNINGS_ENV).ok().as_deref())
}
fn normalize_route_name(segment: &str) -> String {
if let Some(stripped) = segment.strip_prefix('!') {
return stripped.to_string();
}
if !is_kebab_case(segment) && url_name_warnings_enabled() {
let suggestion = to_kebab_case(segment);
tracing::warn!(
target: "reinhardt_urls::reverse",
"URL name '{segment}' is not kebab-case; suggestion: use '{suggestion}'. \
To suppress this warning, prefix the name with '!' (e.g. name = \"!{segment}\") \
or set {URL_NAME_WARNINGS_ENV}=0."
);
}
segment.to_string()
}
#[cfg(test)]
mod kebab_convention_tests {
use super::{is_kebab_case, normalize_route_name, to_kebab_case, warnings_enabled_from_env};
use rstest::rstest;
#[rstest]
#[case(None, true)]
#[case(Some("1"), true)]
#[case(Some("true"), true)]
#[case(Some("anything"), true)]
#[case(Some("0"), false)]
#[case(Some("false"), false)]
#[case(Some("OFF"), false)]
#[case(Some(" no "), false)]
fn warnings_enabled_from_env_honors_disable_values(
#[case] value: Option<&str>,
#[case] expected: bool,
) {
let result = warnings_enabled_from_env(value);
assert_eq!(result, expected);
}
#[rstest]
#[case("users-list", true)]
#[case("users-detail", true)]
#[case("detail", true)]
#[case("v2", true)]
#[case("user_detail", false)]
#[case("userDetail", false)]
#[case("UserDetail", false)]
fn is_kebab_case_classifies_segments(#[case] segment: &str, #[case] expected: bool) {
let result = is_kebab_case(segment);
assert_eq!(result, expected);
}
#[rstest]
#[case("user_detail", "user-detail")]
#[case("userDetail", "user-detail")]
#[case("UserDetail", "user-detail")]
#[case("users-list", "users-list")]
fn to_kebab_case_converts_segments(#[case] segment: &str, #[case] expected: &str) {
let result = to_kebab_case(segment);
assert_eq!(result, expected);
}
#[rstest]
#[case("!user_detail", "user_detail")]
#[case("!users-list", "users-list")]
#[case("user_detail", "user_detail")]
#[case("users-list", "users-list")]
fn normalize_route_name_strips_optout_sigil(#[case] segment: &str, #[case] expected: &str) {
let result = normalize_route_name(segment);
assert_eq!(result, expected);
}
}