use std::borrow::Cow;
use heck::ToKebabCase;
use super::{
layer::ResolvedLayer,
page::{ResolvedLayout, ResolvedPage},
route::ResolvedRoute,
};
use crate::{
ModuleLayer, ModuleLayout, ModulePage, ModuleRoute, PathBuf, PathSegment, RouterBuilder,
Segment, SegmentKind, Segments,
};
#[doc(hidden)]
pub struct ModuleRouterBuilder {
inner: RouterBuilder,
root_module_path: &'static str,
segments: Segments,
}
impl ModuleRouterBuilder {
#[must_use]
pub fn new(root_module_path: &'static str) -> Self {
Self {
root_module_path,
inner: RouterBuilder::new(),
segments: Segments::new(),
}
}
#[track_caller]
fn relative_module_path(&self, module_path: &'static str) -> &'static str {
if module_path == self.root_module_path {
return "";
}
module_path
.strip_prefix(self.root_module_path)
.and_then(|s| s.strip_prefix("::"))
.expect("module path must be under module router's root")
}
#[must_use]
#[track_caller]
pub fn segment(mut self, segment: Segment) -> Self {
assert!(
self.inner.is_empty(),
"`segment` must be called before registering any resource"
);
self.segments
.register(self.relative_module_path(segment.module_path()), segment);
self
}
fn module_path_to_path(&self, module_path: &'static str) -> PathBuf {
let relative = self.relative_module_path(module_path);
let mut path_buf = PathBuf::new();
if relative.is_empty() {
return path_buf;
}
let mut prefix_end = 0;
for (i, component) in relative.split("::").enumerate() {
if i > 0 {
prefix_end += "::".len();
}
prefix_end += component.len();
let segment = self.segments.get(&relative[..prefix_end]);
let kind = match segment.and_then(|segment| segment.kind()) {
Some(kind) => *kind,
None => {
if component.starts_with('_') {
SegmentKind::Group
} else {
SegmentKind::Static
}
}
};
let name = match segment.and_then(|segment| segment.rename()) {
Some(rename) => Cow::Borrowed(rename),
None => match kind {
SegmentKind::Static => Cow::Owned(component.to_kebab_case()),
_ => Cow::Borrowed(component),
},
};
let path_segment = match kind {
SegmentKind::Static => PathSegment::Static(&name),
SegmentKind::Group => PathSegment::Group(&name),
SegmentKind::Param => PathSegment::Param(&name),
SegmentKind::CatchAll => PathSegment::CatchAll(&name),
};
path_buf += path_segment;
}
path_buf
}
#[must_use]
pub fn page(mut self, page: impl ModulePage) -> Self {
let path = self.module_path_to_path(page.module_path());
self.inner = self.inner.page(ResolvedPage::new(page, path));
self
}
#[must_use]
pub fn layout(mut self, layout: impl ModuleLayout) -> Self {
let path = self.module_path_to_path(layout.module_path());
self.inner = self.inner.layout(ResolvedLayout::new(layout, path));
self
}
#[must_use]
pub fn route(mut self, route: impl ModuleRoute) -> Self {
let path = self.module_path_to_path(route.module_path());
self.inner = self.inner.route(ResolvedRoute::new(route, path));
self
}
#[must_use]
pub fn layer(mut self, layer: impl ModuleLayer) -> Self {
let path = self.module_path_to_path(layer.module_path());
self.inner = self.inner.layer(ResolvedLayer::new(layer, path));
self
}
#[cfg(feature = "discover")]
#[must_use]
pub fn discover_segments(mut self) -> Self {
for segment in inventory::iter::<Segment>().cloned() {
self = self.segment(segment);
}
self
}
#[cfg(feature = "discover")]
#[must_use]
pub fn discover_pages(mut self) -> Self {
for &page in inventory::iter::<&'static dyn ModulePage>() {
self = self.page(page);
}
self
}
#[cfg(feature = "discover")]
#[must_use]
#[track_caller]
pub fn discover_layouts(mut self) -> Self {
let mut seen = std::collections::HashSet::new();
for &layout in inventory::iter::<&'static dyn ModuleLayout>() {
assert!(
seen.insert(self.module_path_to_path(layout.module_path())),
"multiple discovered layouts registered for the same path \"{}\"",
self.module_path_to_path(layout.module_path())
);
self = self.layout(layout);
}
self
}
#[cfg(feature = "discover")]
#[must_use]
pub fn discover_routes(mut self) -> Self {
for &route in inventory::iter::<&'static dyn ModuleRoute>() {
self = self.route(route);
}
self
}
#[cfg(feature = "discover")]
#[must_use]
#[track_caller]
pub fn discover_layers(mut self) -> Self {
let mut seen = std::collections::HashSet::new();
for &layer in inventory::iter::<&'static dyn ModuleLayer>() {
assert!(
seen.insert(self.module_path_to_path(layer.module_path())),
"multiple discovered layers registered for the same path \"{}\"",
self.module_path_to_path(layer.module_path())
);
self = self.layer(layer);
}
self
}
#[cfg(feature = "discover")]
#[must_use]
pub fn discover(self) -> Self {
self.discover_segments()
.discover_pages()
.discover_layouts()
.discover_routes()
.discover_layers()
}
}
impl From<ModuleRouterBuilder> for RouterBuilder {
fn from(value: ModuleRouterBuilder) -> Self {
value.inner
}
}
#[cfg(test)]
mod tests {
use topcoat_core::context::Cx;
use topcoat_view::BoxView;
use super::*;
use crate::{Body, Method, Methods, RouteId};
struct PageAt {
id: RouteId,
module_path: &'static str,
}
impl ModulePage for PageAt {
fn id(&self) -> RouteId {
self.id
}
fn methods(&self) -> Methods<'_> {
Methods::Only(&[Method::GET])
}
fn module_path(&self) -> &'static str {
self.module_path
}
fn render<'a>(&'a self, _cx: &'a Cx, _body: Body) -> BoxView<'a> {
unreachable!("test render function is never called")
}
}
fn page_at(module_path: &'static str) -> PageAt {
PageAt {
id: RouteId::new(),
module_path,
}
}
fn builder() -> ModuleRouterBuilder {
ModuleRouterBuilder::new("app")
}
#[test]
fn relative_path_of_root_is_empty() {
assert_eq!(builder().relative_module_path("app"), "");
}
#[test]
fn relative_path_strips_root_prefix() {
assert_eq!(builder().relative_module_path("app::users"), "users");
assert_eq!(
builder().relative_module_path("app::users::id"),
"users::id"
);
}
#[test]
#[should_panic(expected = "module path must be under module router's root")]
fn relative_path_outside_root_panics() {
builder().relative_module_path("other::thing");
}
#[test]
#[should_panic(expected = "module path must be under module router's root")]
fn relative_path_requires_module_boundary() {
builder().relative_module_path("application");
}
fn path_of(module_path: &'static str) -> String {
builder().module_path_to_path(module_path).to_string()
}
#[test]
fn root_maps_to_empty_path() {
assert_eq!(path_of("app"), "");
}
#[test]
fn static_segment_is_kebab_cased() {
assert_eq!(path_of("app::about"), "/about");
assert_eq!(path_of("app::blog_posts"), "/blog-posts");
}
#[test]
fn nested_static_segments() {
assert_eq!(path_of("app::settings::profile"), "/settings/profile");
assert_eq!(
path_of("app::user_settings::email_address"),
"/user-settings/email-address"
);
}
#[test]
fn underscore_module_is_a_group() {
let path = builder().module_path_to_path("app::_marketing::pricing");
assert_eq!(path.to_string(), "/(_marketing)/pricing");
assert_eq!(path.to_matchit_path(), "/pricing");
}
fn builder_with(segment: Segment) -> ModuleRouterBuilder {
builder().segment(segment)
}
#[test]
fn override_kind_param() {
let builder = builder_with(Segment::new(
"app::users::id",
Some(SegmentKind::Param),
None,
));
assert_eq!(
builder.module_path_to_path("app::users::id").to_string(),
"/users/{id}"
);
}
#[test]
fn override_kind_catch_all() {
let builder = builder_with(Segment::new(
"app::files::rest",
Some(SegmentKind::CatchAll),
None,
));
assert_eq!(
builder.module_path_to_path("app::files::rest").to_string(),
"/files/{*rest}"
);
}
#[test]
fn override_kind_group_strips_from_url() {
let builder = builder_with(Segment::new(
"app::marketing",
Some(SegmentKind::Group),
None,
));
let path = builder.module_path_to_path("app::marketing::pricing");
assert_eq!(path.to_string(), "/(marketing)/pricing");
assert_eq!(path.to_matchit_path(), "/pricing");
}
#[test]
fn override_kind_static_promotes_group_module() {
let builder = builder_with(Segment::new("app::_group", Some(SegmentKind::Static), None));
assert_eq!(
builder.module_path_to_path("app::_group").to_string(),
"/group"
);
}
#[test]
fn override_rename_is_used_verbatim() {
let builder = builder_with(Segment::new(
"app::blog_post",
None,
Some("articles".into()),
));
assert_eq!(
builder.module_path_to_path("app::blog_post").to_string(),
"/articles"
);
}
#[test]
fn override_applies_at_intermediate_segment() {
let builder = builder_with(Segment::new("app::users", Some(SegmentKind::Param), None));
assert_eq!(
builder.module_path_to_path("app::users::posts").to_string(),
"/{users}/posts"
);
}
#[test]
#[should_panic(expected = "must be called before registering any resource")]
fn segment_after_resource_panics() {
let _ = builder().page(page_at("app::home")).segment(Segment::new(
"app::users",
Some(SegmentKind::Param),
None,
));
}
#[test]
fn fresh_builder_converts_to_empty_router_builder() {
let inner = RouterBuilder::from(builder());
assert!(inner.is_empty());
}
#[test]
fn registering_a_page_is_observable_after_conversion() {
let inner = RouterBuilder::from(builder().page(page_at("app::about")));
assert!(!inner.is_empty());
}
}