use super::error::{MergeError, RouterError};
use super::from_request::FromRequest;
use super::handler::{
Handler, RouteHandler, from_request_handler, no_params_handler, result_handler,
with_params_handler,
};
#[cfg(wasm)]
use super::history::setup_popstate_listener;
use super::history::{HistoryState, NavigationType, current_path, push_state, replace_state};
use super::params::{FromPath, ParamContext, Path};
use super::pattern::ClientPathPattern;
use reinhardt_core::page::Page;
use reinhardt_core::reactive::Signal;
use std::collections::HashMap;
use std::sync::Arc;
pub(super) type RouteGuard = Arc<dyn Fn(&ClientRouteMatch) -> bool + Send + Sync>;
#[cfg(wasm)]
type NavigationObservers = std::rc::Rc<std::cell::RefCell<Vec<std::rc::Weak<NavigationListener>>>>;
#[cfg(wasm)]
type NavigationListener = dyn Fn(&str, &HashMap<String, String>) + 'static;
pub struct NavigationSubscription {
#[cfg(wasm)]
#[allow(dead_code)] listener: std::rc::Rc<NavigationListener>,
}
impl NavigationSubscription {
#[cfg(wasm)]
fn new<F>(router: &ClientRouter, listener: F) -> Self
where
F: Fn(&str, &HashMap<String, String>) + 'static,
{
let listener: std::rc::Rc<NavigationListener> = std::rc::Rc::new(listener);
router
.navigation_observers
.borrow_mut()
.push(std::rc::Rc::downgrade(&listener));
Self { listener }
}
#[cfg(native)]
fn new<F>(_router: &ClientRouter, _listener: F) -> Self
where
F: Fn(&str, &HashMap<String, String>) + 'static,
{
Self {}
}
}
#[derive(Debug, Clone)]
pub struct ClientRouteMatch {
pub route: ClientRoute,
pub params: HashMap<String, String>,
pub(crate) param_values: Vec<String>,
pub query: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteMetadata {
title: Option<String>,
breadcrumb: Option<String>,
requires_auth: bool,
}
impl RouteMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_breadcrumb(mut self, breadcrumb: impl Into<String>) -> Self {
self.breadcrumb = Some(breadcrumb.into());
self
}
pub fn with_requires_auth(mut self, requires_auth: bool) -> Self {
self.requires_auth = requires_auth;
self
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn breadcrumb(&self) -> Option<&str> {
self.breadcrumb.as_deref()
}
pub fn requires_auth(&self) -> bool {
self.requires_auth
}
}
pub struct ClientRoute {
pattern: ClientPathPattern,
name: Option<String>,
metadata: RouteMetadata,
handler: Arc<dyn RouteHandler>,
guard: Option<RouteGuard>,
}
impl Clone for ClientRoute {
fn clone(&self) -> Self {
Self {
pattern: self.pattern.clone(),
name: self.name.clone(),
metadata: self.metadata.clone(),
handler: Arc::clone(&self.handler),
guard: self.guard.clone(),
}
}
}
impl std::fmt::Debug for ClientRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientRoute")
.field("pattern", &self.pattern)
.field("name", &self.name)
.field("metadata", &self.metadata)
.field("has_guard", &self.guard.is_some())
.finish()
}
}
impl ClientRoute {
pub fn new<F>(pattern: &str, component: F) -> Self
where
F: Fn() -> Page + Send + Sync + 'static,
{
Self {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: None,
metadata: RouteMetadata::default(),
handler: no_params_handler(component),
guard: None,
}
}
pub fn named<F>(name: impl Into<String>, pattern: &str, component: F) -> Self
where
F: Fn() -> Page + Send + Sync + 'static,
{
Self {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: Some(name.into()),
metadata: RouteMetadata::default(),
handler: no_params_handler(component),
guard: None,
}
}
pub fn with_guard<G>(mut self, guard: G) -> Self
where
G: Fn(&ClientRouteMatch) -> bool + Send + Sync + 'static,
{
self.guard = Some(Arc::new(guard));
self
}
pub fn with_metadata(mut self, metadata: RouteMetadata) -> Self {
self.metadata = metadata;
self
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn pattern(&self) -> &ClientPathPattern {
&self.pattern
}
pub fn metadata(&self) -> &RouteMetadata {
&self.metadata
}
pub fn check_guard(&self, route_match: &ClientRouteMatch) -> bool {
self.guard.as_ref().map(|g| g(route_match)).unwrap_or(true)
}
}
#[derive(Clone)]
pub struct ClientRouter {
routes: Vec<ClientRoute>,
named_routes: HashMap<String, usize>,
current_path: Signal<String>,
current_params: Signal<HashMap<String, String>>,
current_route_name: Signal<Option<String>>,
not_found: Option<Arc<dyn Fn() -> Page + Send + Sync>>,
#[cfg(wasm)]
navigation_observers: NavigationObservers,
#[cfg(wasm)]
dispatch_count: std::rc::Rc<std::cell::Cell<u64>>,
#[cfg(native)]
diag_router_identity: Arc<()>,
}
impl std::fmt::Debug for ClientRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientRouter")
.field("routes_count", &self.routes.len())
.field(
"named_routes",
&self.named_routes.keys().collect::<Vec<_>>(),
)
.finish()
}
}
impl Default for ClientRouter {
fn default() -> Self {
Self::new()
}
}
impl ClientRouter {
pub fn new() -> Self {
let initial_path = current_path().unwrap_or_else(|_| "/".to_string());
Self {
routes: Vec::new(),
named_routes: HashMap::new(),
current_path: Signal::new(initial_path),
current_params: Signal::new(HashMap::new()),
current_route_name: Signal::new(None),
not_found: None,
#[cfg(wasm)]
navigation_observers: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
#[cfg(wasm)]
dispatch_count: std::rc::Rc::new(std::cell::Cell::new(0)),
#[cfg(native)]
diag_router_identity: Arc::new(()),
}
}
pub fn merge(mut self, other: ClientRouter) -> Self {
let offset = self.routes.len();
for (name, idx) in other.named_routes {
self.named_routes.insert(name, idx + offset);
}
self.routes.extend(other.routes);
self
}
pub fn try_merge(self, other: ClientRouter) -> Result<Self, MergeError> {
if let Some(name) = other
.named_routes
.keys()
.find(|name| self.named_routes.contains_key(*name))
{
return Err(MergeError::NameCollision { name: name.clone() });
}
Ok(self.merge(other))
}
pub fn with_namespace(mut self, namespace: &str) -> Self {
let old = std::mem::take(&mut self.named_routes);
for (name, idx) in old {
self.named_routes.insert(format!("{namespace}:{name}"), idx);
}
for route in &mut self.routes {
if let Some(ref old_name) = route.name {
route.name = Some(format!("{namespace}:{old_name}"));
}
}
self
}
fn insert_named_route(&mut self, name: &str, index: usize) {
if self.named_routes.insert(name.to_string(), index).is_some() {
panic!(
"Duplicate client route name '{}': a route with this name is already registered",
name,
);
}
}
pub fn with_route_metadata(mut self, name: &str, metadata: RouteMetadata) -> Self {
let index = *self.named_routes.get(name).unwrap_or_else(|| {
panic!(
"Unknown client route name '{}': cannot attach metadata",
name
)
});
self.routes[index] = self.routes[index].clone().with_metadata(metadata);
self
}
pub fn route<F>(mut self, name: &str, pattern: &str, component: F) -> Self
where
F: Fn() -> Page + Send + Sync + 'static,
{
let index = self.routes.len();
self.routes
.push(ClientRoute::named(name, pattern, component));
self.insert_named_route(name, index);
self
}
pub fn route_params<F, T>(mut self, name: &str, pattern: &str, handler: F) -> Self
where
F: Fn(Path<T>) -> Page + Send + Sync + 'static,
T: FromPath + Send + Sync + 'static,
{
let index = self.routes.len();
self.routes.push(ClientRoute {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: Some(name.to_string()),
metadata: RouteMetadata::default(),
handler: with_params_handler(handler),
guard: None,
});
self.insert_named_route(name, index);
self
}
pub fn route_result<F, T, E>(mut self, name: &str, pattern: &str, handler: F) -> Self
where
F: Fn(Path<T>) -> Result<Page, E> + Send + Sync + 'static,
T: FromPath + Send + Sync + 'static,
E: Into<RouterError> + Send + Sync + 'static,
{
let index = self.routes.len();
self.routes.push(ClientRoute {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: Some(name.to_string()),
metadata: RouteMetadata::default(),
handler: result_handler(handler),
guard: None,
});
self.insert_named_route(name, index);
self
}
pub fn page<F, P>(mut self, name: &str, pattern: &str, handler: F) -> Self
where
F: Fn(P) -> Page + Send + Sync + 'static,
P: FromRequest + Send + Sync + 'static,
{
let index = self.routes.len();
self.routes.push(ClientRoute {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: Some(name.to_string()),
metadata: RouteMetadata::default(),
handler: from_request_handler(handler, pattern.to_string()),
guard: None,
});
self.insert_named_route(name, index);
self
}
pub fn guarded_route<F, G>(mut self, pattern: &str, component: F, guard: G) -> Self
where
F: Fn() -> Page + Send + Sync + 'static,
G: Fn(&ClientRouteMatch) -> bool + Send + Sync + 'static,
{
self.routes
.push(ClientRoute::new(pattern, component).with_guard(guard));
self
}
pub fn route_path<H, Args>(mut self, name: &str, pattern: &str, handler: H) -> Self
where
H: Handler<Args>,
{
let index = self.routes.len();
self.routes.push(ClientRoute {
pattern: ClientPathPattern::new(pattern)
.unwrap_or_else(|e| panic!("Invalid route pattern '{}': {}", pattern, e)),
name: Some(name.to_string()),
metadata: RouteMetadata::default(),
handler: handler.into_route_handler(),
guard: None,
});
self.insert_named_route(name, index);
self
}
pub fn not_found<F>(mut self, component: F) -> Self
where
F: Fn() -> Page + Send + Sync + 'static,
{
self.not_found = Some(Arc::new(component));
self
}
pub fn current_path(&self) -> &Signal<String> {
&self.current_path
}
pub fn current_params(&self) -> &Signal<HashMap<String, String>> {
&self.current_params
}
pub fn current_route_name(&self) -> &Signal<Option<String>> {
&self.current_route_name
}
pub fn route_patterns(&self) -> impl Iterator<Item = (&str, Option<&str>)> {
self.routes
.iter()
.map(|r| (r.pattern.pattern(), r.name.as_deref()))
}
pub fn match_path(&self, path: &str) -> Option<ClientRouteMatch> {
let (path_only, query) = match path.split_once('?') {
Some((p, q)) => (p, Some(q.to_string())),
None => (path, None),
};
for route in &self.routes {
if let Some((params, param_values)) = route.pattern.matches(path_only) {
let route_match = ClientRouteMatch {
route: route.clone(),
params,
param_values,
query: query.clone(),
};
if route.check_guard(&route_match) {
return Some(route_match);
}
}
}
None
}
pub fn push(&self, path: &str) -> Result<(), RouterError> {
self.navigate(path, NavigationType::Push)
}
pub fn replace(&self, path: &str) -> Result<(), RouterError> {
self.navigate(path, NavigationType::Replace)
}
fn navigate(&self, path: &str, nav_type: NavigationType) -> Result<(), RouterError> {
let route_match = self.match_path(path);
let state = HistoryState::new(path)
.with_params(
route_match
.as_ref()
.map(|m| m.params.clone())
.unwrap_or_default(),
)
.with_route_name(
route_match
.as_ref()
.and_then(|m| m.route.name())
.unwrap_or(""),
);
let result = match nav_type {
NavigationType::Push => push_state(&state),
NavigationType::Replace => replace_state(&state),
_ => Ok(()),
};
result.map_err(RouterError::NavigationFailed)?;
self.current_path.set(path.to_string());
self.current_params.set(
route_match
.as_ref()
.map(|m| m.params.clone())
.unwrap_or_default(),
);
self.current_route_name.set(
route_match
.as_ref()
.and_then(|m| m.route.name().map(|s| s.to_string())),
);
let params_for_observers = route_match
.as_ref()
.map(|m| m.params.clone())
.unwrap_or_default();
self.notify_observers(path, ¶ms_for_observers);
Ok(())
}
pub fn on_navigate<F>(&self, listener: F) -> NavigationSubscription
where
F: Fn(&str, &HashMap<String, String>) + 'static,
{
NavigationSubscription::new(self, listener)
}
#[cfg(wasm)]
fn notify_observers(&self, path: &str, params: &HashMap<String, String>) {
dispatch_navigation_observers(
&self.navigation_observers,
&self.dispatch_count,
path,
params,
);
}
#[cfg(native)]
fn notify_observers(&self, _path: &str, _params: &HashMap<String, String>) {}
#[doc(hidden)]
pub fn __diag_observer_count(&self) -> usize {
self.diag_observer_count()
}
#[doc(hidden)]
pub fn __diag_dispatch_count(&self) -> u64 {
self.diag_dispatch_count()
}
#[doc(hidden)]
pub fn __diag_router_id(&self) -> usize {
self.diag_router_id()
}
#[cfg(wasm)]
fn diag_observer_count(&self) -> usize {
self.navigation_observers
.borrow()
.iter()
.filter(|w| w.strong_count() > 0)
.count()
}
#[cfg(native)]
fn diag_observer_count(&self) -> usize {
0
}
#[cfg(wasm)]
fn diag_dispatch_count(&self) -> u64 {
self.dispatch_count.get()
}
#[cfg(native)]
fn diag_dispatch_count(&self) -> u64 {
0
}
#[cfg(wasm)]
fn diag_router_id(&self) -> usize {
std::rc::Rc::as_ptr(&self.navigation_observers) as usize
}
#[cfg(native)]
fn diag_router_id(&self) -> usize {
Arc::as_ptr(&self.diag_router_identity) as usize
}
pub fn reverse(&self, name: &str, params: &[(&str, &str)]) -> Result<String, RouterError> {
let index = self
.named_routes
.get(name)
.ok_or_else(|| RouterError::InvalidRouteName(name.to_string()))?;
let route = &self.routes[*index];
let params_map: HashMap<String, String> = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
route
.pattern
.reverse(¶ms_map)
.ok_or_else(|| RouterError::MissingParameter("unknown".to_string()))
}
pub fn render_current(&self) -> Page {
let path = self.current_path.get();
if let Some(route_match) = self.match_path(&path) {
let ctx =
ParamContext::new(route_match.params.clone(), route_match.param_values.clone())
.with_query(route_match.query.clone());
match route_match.route.handler.handle(&ctx) {
Ok(view) => view,
Err(_err) => self.not_found.as_ref().map(|f| f()).unwrap_or(Page::Empty),
}
} else {
self.not_found.as_ref().map(|f| f()).unwrap_or(Page::Empty)
}
}
pub fn route_count(&self) -> usize {
self.routes.len()
}
pub fn has_route(&self, name: &str) -> bool {
self.named_routes.contains_key(name)
}
#[cfg(wasm)]
pub fn setup_history_listener(&self) {
let path_signal = self.current_path.clone();
let params_signal = self.current_params.clone();
let route_name_signal = self.current_route_name.clone();
let navigation_observers = self.navigation_observers.clone();
let dispatch_count = self.dispatch_count.clone();
let closure = setup_popstate_listener(move |path, state| {
path_signal.set(path.clone());
let params_for_observers = if let Some(hist_state) = state {
let params = hist_state.params.clone();
params_signal.set(hist_state.params);
route_name_signal.set(hist_state.route_name);
params
} else {
params_signal.set(HashMap::new());
route_name_signal.set(None);
HashMap::new()
};
dispatch_navigation_observers(
&navigation_observers,
&dispatch_count,
&path,
¶ms_for_observers,
);
});
if let Ok(c) = closure {
c.forget();
}
}
#[cfg(native)]
pub fn setup_history_listener(&self) {
}
}
#[cfg(wasm)]
fn dispatch_navigation_observers(
navigation_observers: &NavigationObservers,
dispatch_count: &std::rc::Rc<std::cell::Cell<u64>>,
path: &str,
params: &HashMap<String, String>,
) {
dispatch_count.set(dispatch_count.get() + 1);
let listeners_snapshot: Vec<std::rc::Rc<NavigationListener>> = {
let mut observers = navigation_observers.borrow_mut();
observers.retain(|w| w.strong_count() > 0);
observers.iter().filter_map(|w| w.upgrade()).collect()
};
for listener in listeners_snapshot {
listener(path, params);
}
}
#[cfg(all(test, native))]
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientRouter>();
};
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
fn test_page() -> Page {
Page::Empty
}
fn page_with_text(s: &str) -> Page {
Page::Text(s.to_string().into())
}
fn home_page() -> Page {
page_with_text("Home")
}
fn user_page() -> Page {
page_with_text("User")
}
fn not_found_page() -> Page {
page_with_text("NotFound")
}
#[test]
fn test_route_new() {
let route = ClientRoute::new("/", test_page);
assert!(route.name().is_none());
}
#[test]
fn test_route_named() {
let route = ClientRoute::named("home", "/", test_page);
assert_eq!(route.name(), Some("home"));
}
#[test]
fn test_router_new() {
let router = ClientRouter::new();
assert_eq!(router.route_count(), 0);
}
#[test]
fn test_router_add_route() {
let router = ClientRouter::new()
.route("home", "/", home_page)
.route("users", "/users/", user_page);
assert_eq!(router.route_count(), 2);
}
#[test]
fn test_router_route_with_name() {
let router = ClientRouter::new()
.route("home", "/", home_page)
.route("users", "/users/", user_page);
assert!(router.has_route("home"));
assert!(router.has_route("users"));
assert!(!router.has_route("nonexistent"));
}
#[test]
fn test_router_match_exact() {
let router = ClientRouter::new()
.route("home", "/", home_page)
.route("users", "/users/", user_page);
assert!(router.match_path("/").is_some());
assert!(router.match_path("/users/").is_some());
assert!(router.match_path("/nonexistent/").is_none());
}
#[test]
fn test_router_match_params() {
let router = ClientRouter::new().route("user_detail", "/users/{id}/", user_page);
let route_match = router.match_path("/users/42/");
assert!(route_match.is_some());
let route_match = route_match.unwrap();
assert_eq!(route_match.params.get("id"), Some(&"42".to_string()));
}
#[test]
fn test_router_reverse() {
let router = ClientRouter::new().route("home", "/", home_page).route(
"user_detail",
"/users/{id}/",
user_page,
);
assert_eq!(router.reverse("home", &[]).unwrap(), "/");
assert_eq!(
router.reverse("user_detail", &[("id", "42")]).unwrap(),
"/users/42/"
);
}
#[test]
fn test_router_reverse_invalid_name() {
let router = ClientRouter::new();
let result = router.reverse("nonexistent", &[]);
assert!(matches!(result, Err(RouterError::InvalidRouteName(_))));
}
#[test]
fn test_router_not_found() {
let router = ClientRouter::new().not_found(not_found_page);
let _view = router.render_current();
}
#[rstest]
fn test_render_current_returns_page_without_not_found() {
let router = ClientRouter::new().route("home", "/home/", home_page);
let page = router.render_current();
assert!(matches!(page, Page::Empty));
}
#[test]
fn test_router_with_guard() {
let router = ClientRouter::new()
.guarded_route("/admin/", test_page, |_| false)
.route("public", "/public/", test_page);
assert!(router.match_path("/admin/").is_none());
assert!(router.match_path("/public/").is_some());
}
#[test]
fn test_router_error_display() {
assert_eq!(
RouterError::NotFound("/test/".to_string()).to_string(),
"Route not found: /test/"
);
assert_eq!(
RouterError::InvalidRouteName("test".to_string()).to_string(),
"Invalid route name: test"
);
}
#[test]
fn test_router_push_non_wasm() {
let router = ClientRouter::new()
.route("home", "/", home_page)
.route("users", "/users/", user_page);
assert!(router.push("/users/").is_ok());
}
#[test]
fn test_router_replace_non_wasm() {
let router = ClientRouter::new().route("home", "/", home_page);
assert!(router.replace("/").is_ok());
}
#[test]
fn test_route_path_single() {
let router = ClientRouter::new().route_path(
"user_detail",
"/users/{id}/",
|Path(_id): Path<i64>| page_with_text("User"),
);
assert_eq!(router.route_count(), 1);
let route_match = router.match_path("/users/42/");
assert!(route_match.is_some());
let route_match = route_match.unwrap();
assert_eq!(route_match.params.get("id"), Some(&"42".to_string()));
}
#[test]
fn test_route_path_two_params() {
let router = ClientRouter::new().route_path(
"user_post",
"/users/{user_id}/posts/{post_id}/",
|Path(_user_id): Path<i64>, Path(_post_id): Path<i64>| page_with_text("UserPost"),
);
assert_eq!(router.route_count(), 1);
let route_match = router.match_path("/users/123/posts/456/");
assert!(route_match.is_some());
let route_match = route_match.unwrap();
assert_eq!(route_match.params.get("user_id"), Some(&"123".to_string()));
assert_eq!(route_match.params.get("post_id"), Some(&"456".to_string()));
}
#[test]
fn test_route_path_three_params() {
let router = ClientRouter::new().route_path(
"member",
"/orgs/{org_id}/teams/{team_id}/members/{member_id}/",
|Path(_org_id): Path<String>,
Path(_team_id): Path<i64>,
Path(_member_id): Path<i64>| page_with_text("Member"),
);
assert_eq!(router.route_count(), 1);
let route_match = router.match_path("/orgs/acme/teams/10/members/100/");
assert!(route_match.is_some());
let route_match = route_match.unwrap();
assert_eq!(route_match.params.get("org_id"), Some(&"acme".to_string()));
assert_eq!(route_match.params.get("team_id"), Some(&"10".to_string()));
assert_eq!(
route_match.params.get("member_id"),
Some(&"100".to_string())
);
}
#[test]
fn test_route_path_four_params() {
let router = ClientRouter::new().route_path(
"quad",
"/a/{a}/b/{b}/c/{c}/d/{d}/",
|Path(_a): Path<i64>, Path(_b): Path<i64>, Path(_c): Path<i64>, Path(_d): Path<i64>| {
page_with_text("Quad")
},
);
assert_eq!(router.route_count(), 1);
let route_match = router.match_path("/a/1/b/2/c/3/d/4/");
assert!(route_match.is_some());
let route_match = route_match.unwrap();
assert_eq!(route_match.params.get("a"), Some(&"1".to_string()));
assert_eq!(route_match.params.get("b"), Some(&"2".to_string()));
assert_eq!(route_match.params.get("c"), Some(&"3".to_string()));
assert_eq!(route_match.params.get("d"), Some(&"4".to_string()));
}
#[test]
fn test_route_path_single_with_reverse() {
let router = ClientRouter::new().route_path(
"user_detail",
"/users/{id}/",
|Path(_id): Path<i64>| page_with_text("User"),
);
assert!(router.has_route("user_detail"));
assert_eq!(
router.reverse("user_detail", &[("id", "42")]).unwrap(),
"/users/42/"
);
}
#[test]
fn test_route_path_two_params_with_reverse() {
let router = ClientRouter::new().route_path(
"user_post",
"/users/{user_id}/posts/{post_id}/",
|Path(_user_id): Path<i64>, Path(_post_id): Path<i64>| page_with_text("UserPost"),
);
assert!(router.has_route("user_post"));
assert_eq!(
router
.reverse("user_post", &[("user_id", "10"), ("post_id", "20")])
.unwrap(),
"/users/10/posts/20/"
);
}
#[test]
fn test_route_path_three_params_with_reverse() {
let router = ClientRouter::new().route_path(
"org_team_member",
"/orgs/{org}/teams/{team}/members/{member}/",
|Path(_org): Path<String>, Path(_team): Path<i64>, Path(_member): Path<i64>| {
page_with_text("Member")
},
);
assert!(router.has_route("org_team_member"));
assert_eq!(
router
.reverse(
"org_team_member",
&[("org", "acme"), ("team", "5"), ("member", "42")]
)
.unwrap(),
"/orgs/acme/teams/5/members/42/"
);
}
#[test]
fn test_route_path_with_string_param() {
let router = ClientRouter::new().route_path(
"post_detail",
"/posts/{slug}/",
|Path(_slug): Path<String>| page_with_text("Post"),
);
let route_match = router.match_path("/posts/hello-world/");
assert!(route_match.is_some());
assert_eq!(
route_match.unwrap().params.get("slug"),
Some(&"hello-world".to_string())
);
}
fn polls_router() -> ClientRouter {
ClientRouter::new()
.route("polls:index", "/polls/", home_page)
.route("polls:detail", "/polls/{id}/", user_page)
}
fn users_router() -> ClientRouter {
ClientRouter::new()
.route("users:login", "/users/login/", home_page)
.route("users:logout", "/users/logout/", home_page)
}
#[test]
fn merge_appends_routes_and_named_routes() {
let merged = polls_router().merge(users_router());
assert_eq!(merged.route_count(), 4);
assert_eq!(merged.reverse("polls:index", &[]).unwrap(), "/polls/");
assert_eq!(
merged.reverse("polls:detail", &[("id", "1")]).unwrap(),
"/polls/1/"
);
assert_eq!(merged.reverse("users:login", &[]).unwrap(), "/users/login/");
assert_eq!(
merged.reverse("users:logout", &[]).unwrap(),
"/users/logout/"
);
}
#[test]
fn merge_last_wins_on_name_collision() {
let first = ClientRouter::new().route("shared", "/a/", || page_with_text("first"));
let second = ClientRouter::new().route("shared", "/b/", || page_with_text("second"));
let merged = first.merge(second);
assert_eq!(merged.route_count(), 2);
assert_eq!(merged.reverse("shared", &[]).unwrap(), "/b/");
}
#[test]
fn merge_discards_other_not_found() {
let other_not_found_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = Arc::clone(&other_not_found_seen);
let other = ClientRouter::new().not_found(move || {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
page_with_text("other-not-found")
});
let merged = ClientRouter::new()
.route("home", "/home/", home_page)
.merge(other);
let page = merged.render_current();
assert!(matches!(page, Page::Empty));
assert!(!other_not_found_seen.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn try_merge_ok_when_no_collision() {
let merged = polls_router()
.try_merge(users_router())
.expect("disjoint named routes merge cleanly");
assert_eq!(merged.route_count(), 4);
assert!(merged.has_route("polls:index"));
assert!(merged.has_route("users:login"));
}
#[test]
fn try_merge_err_on_name_collision() {
let first = ClientRouter::new().route("polls:index", "/a/", home_page);
let second = ClientRouter::new().route("polls:index", "/b/", home_page);
let err = first
.try_merge(second)
.expect_err("collision must be reported");
assert_eq!(
err,
MergeError::NameCollision {
name: "polls:index".to_string(),
},
);
}
#[test]
fn try_merge_err_leaves_neither_router_partially_merged() {
let original = polls_router();
let baseline_count = original.route_count();
let baseline_named = original.has_route("polls:index");
let attempt = polls_router();
let collide = ClientRouter::new().route("polls:index", "/x/", home_page);
let err = attempt
.try_merge(collide)
.expect_err("collision must be reported");
assert!(matches!(err, MergeError::NameCollision { .. }));
assert_eq!(original.route_count(), baseline_count);
assert!(baseline_named);
}
#[test]
fn merge_error_display_includes_route_name() {
let err = MergeError::NameCollision {
name: "polls:detail".to_string(),
};
let text = err.to_string();
assert!(text.contains("polls:detail"));
}
#[rstest]
#[should_panic(expected = "Duplicate client route name 'home'")]
fn route_panics_on_duplicate_name() {
let router = ClientRouter::new().route("home", "/", home_page);
let _router = router.route("home", "/other/", user_page);
}
#[rstest]
#[should_panic(expected = "Duplicate client route name 'detail'")]
fn route_params_panics_on_duplicate_name() {
let router = ClientRouter::new().route("detail", "/items/{id}/", home_page);
let _router = router.route_params("detail", "/users/{id}/", |Path(_id): Path<i64>| {
page_with_text("User")
});
}
#[rstest]
#[should_panic(expected = "Duplicate client route name 'show'")]
fn route_path_panics_on_duplicate_name() {
let router = ClientRouter::new().route("show", "/a/", home_page);
let _router = router.route_path("show", "/b/{id}/", |Path(_id): Path<i64>| {
page_with_text("B")
});
}
#[rstest]
fn merge_does_not_panic_on_duplicate_name() {
let first = ClientRouter::new().route("shared", "/a/", || page_with_text("first"));
let second = ClientRouter::new().route("shared", "/b/", || page_with_text("second"));
let merged = first.merge(second);
assert_eq!(merged.reverse("shared", &[]).unwrap(), "/b/");
}
}