use std::collections::BTreeSet;
use crate::app::ScopedGroup;
use crate::config::Role;
use crate::route::{Route, RouteGroup};
use crate::route_listing::{RouteInfo, RouteSource};
use crate::state::AppState;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"role \"{role}\" mounts route group \"{group}\", but no route is in that group; \
route groups: {}",
known_groups_text(.known)
)]
pub struct UnknownMountGroup {
pub role: String,
pub group: String,
pub known: Vec<String>,
}
fn known_groups_text(known: &[String]) -> String {
if known.is_empty() {
"none".to_owned()
} else {
known.join(", ")
}
}
#[track_caller]
pub fn put_in_group(group: &RouteGroup, routes: Vec<Route>) -> Vec<Route> {
assert!(
!group.as_str().trim().is_empty(),
"the route group name must not be blank"
);
routes
.into_iter()
.map(|route| {
if route.group.is_some() {
route
} else {
route.in_group(group.clone())
}
})
.collect()
}
pub fn path_matches(pattern: &str, path: &str) -> bool {
let mut pattern = pattern.trim_matches('/').split('/');
let mut path = path.trim_matches('/').split('/');
loop {
match (pattern.next(), path.next()) {
(None, None) => return true,
(Some(p), _) if p.starts_with("{*") => return true,
(Some(p), Some(s)) if p.starts_with('{') && p.ends_with('}') && !s.is_empty() => {}
(Some(p), Some(s)) if p == s => {}
_ => return false,
}
}
}
pub struct MountTargets<'a> {
pub routes: &'a mut Vec<Route>,
pub route_sources: &'a mut Vec<RouteSource>,
pub scoped_groups: &'a mut Vec<ScopedGroup>,
pub merge_routers: &'a mut Vec<axum::Router<AppState>>,
pub merge_router_groups: &'a mut Vec<Option<RouteGroup>>,
pub nest_routers: &'a mut Vec<(String, axum::Router<AppState>)>,
pub nest_router_groups: &'a mut Vec<Option<RouteGroup>>,
pub declared_routes: &'a mut Vec<RouteInfo>,
}
impl MountTargets<'_> {
pub fn groups(&self) -> BTreeSet<String> {
let mut groups: BTreeSet<String> = self
.routes
.iter()
.chain(self.scoped_groups.iter().flat_map(|g| g.routes.iter()))
.map(|route| route.group_name().to_owned())
.collect();
groups.extend(
self.merge_router_groups
.iter()
.chain(self.nest_router_groups.iter())
.flatten()
.map(|group| group.as_str().to_owned()),
);
groups.extend(self.declared_routes.iter().filter_map(|r| r.group.clone()));
groups
}
pub fn retain(self, role: &Role) -> Result<(), UnknownMountGroup> {
let Some(mount) = role.mount().filter(|_| role.serves_http()) else {
return Ok(());
};
let known = self.groups();
if let Some(group) = mount.iter().find(|group| !known.contains(group.as_str())) {
return Err(UnknownMountGroup {
role: role.name().to_owned(),
group: group.clone(),
known: known.into_iter().collect(),
});
}
let keep = |group: &str| role.mounts_group(group);
let routes = std::mem::take(self.routes);
let sources = std::mem::take(self.route_sources);
let parallel = sources.len() == routes.len();
let mut sources = sources.into_iter();
for route in routes {
let source = if parallel { sources.next() } else { None };
if keep(route.group_name()) {
self.routes.push(route);
self.route_sources.extend(source);
}
}
for group in self.scoped_groups.iter_mut() {
group.routes.retain(|route| keep(route.group_name()));
}
self.scoped_groups.retain(|group| !group.routes.is_empty());
retain_tagged(self.merge_routers, self.merge_router_groups, &keep);
retain_tagged(self.nest_routers, self.nest_router_groups, &keep);
self.declared_routes
.retain(|route| route.group.as_deref().is_none_or(keep));
Ok(())
}
}
fn retain_tagged<T>(
items: &mut Vec<T>,
groups: &mut Vec<Option<RouteGroup>>,
keep: &impl Fn(&str) -> bool,
) {
let all = std::mem::take(items);
let tags = std::mem::take(groups);
let mut tags = tags.into_iter();
for item in all {
let tag = tags.next().flatten();
if tag.as_ref().is_none_or(|group| keep(group.as_str())) {
items.push(item);
groups.push(tag);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retain_tagged_keeps_shared_and_untagged_items() {
let mut items = vec!["a", "b", "shared", "untagged"];
let mut groups = vec![
Some(RouteGroup::from_static("a")),
Some(RouteGroup::from_static("b")),
None,
];
retain_tagged(&mut items, &mut groups, &|g| g == "a");
assert_eq!(items, vec!["a", "shared", "untagged"]);
assert_eq!(groups.len(), 3);
}
#[test]
fn unknown_mount_group_names_the_known_groups() {
let err = UnknownMountGroup {
role: "site".to_owned(),
group: "markting".to_owned(),
known: vec!["app".to_owned(), "marketing".to_owned()],
};
let text = err.to_string();
assert!(text.contains("\"markting\""), "{text}");
assert!(text.contains("no route is in that group"), "{text}");
assert!(text.ends_with("route groups: app, marketing"), "{text}");
}
#[test]
fn path_matches_literal_param_and_catch_all_segments() {
assert!(path_matches("/", "/"));
assert!(path_matches("/about", "/about"));
assert!(path_matches("/about", "/about/"));
assert!(!path_matches("/about", "/team"));
assert!(path_matches("/posts/{slug}", "/posts/hello"));
assert!(!path_matches("/posts/{slug}", "/posts"));
assert!(!path_matches("/posts/{slug}", "/posts/a/b"));
assert!(path_matches("/docs/{*rest}", "/docs/a/b/c"));
assert!(!path_matches("/", "/about"));
}
#[test]
#[should_panic(expected = "must not be blank")]
fn a_blank_group_is_refused() {
let _ = put_in_group(&RouteGroup::from_static(" "), Vec::new());
}
}