1use std::{
2 borrow::{Borrow, Cow},
3 fmt::{Display, Write},
4 iter::FusedIterator,
5 mem,
6 ops::{AddAssign, Deref},
7};
8
9use ref_cast::{RefCastCustom, ref_cast_custom};
10
11#[derive(Debug, PartialEq, Eq, Hash, RefCastCustom)]
41#[repr(transparent)]
42pub struct Path {
43 inner: str,
44}
45
46impl Path {
47 pub const ROOT: &Path = Path::new("/");
49
50 #[must_use]
62 #[track_caller]
63 pub const fn new(s: &str) -> &Self {
64 match Self::from_str(s) {
65 Ok(path) => path,
66 Err(err) => panic!("{}", err.message()),
67 }
68 }
69
70 #[allow(clippy::should_implement_trait)]
81 pub const fn from_str(s: &str) -> Result<&Self, PathError> {
82 let s = match s.as_bytes() {
83 [b'/'] => "",
84 _ => s,
85 };
86 let bytes = s.as_bytes();
87 let len = bytes.len();
88 if len == 0 {
90 return Ok(Self::new_unchecked(s));
91 }
92 if bytes[0] != b'/' {
93 return Err(PathError::MissingLeadingSlash);
94 }
95 let mut start = 1;
99 let mut i = 1;
100 while i <= len {
101 if i == len || bytes[i] == b'/' {
102 let trailing_slash = i == len && start == len && start > 1;
103 if !trailing_slash && let Err(err) = validate_segment(bytes, start, i) {
104 return Err(err);
105 }
106 start = i + 1;
107 }
108 i += 1;
109 }
110 Ok(Self::new_unchecked(s))
111 }
112
113 #[ref_cast_custom]
119 #[must_use]
120 pub const fn new_unchecked(s: &str) -> &Self;
121
122 pub fn segments(&self) -> PathSegments<'_> {
143 PathSegments::new(self)
144 }
145
146 #[must_use]
170 pub fn to_matchit_path(&self) -> Cow<'static, str> {
171 if self.inner.is_empty() {
172 return Cow::Borrowed("/");
173 }
174 let stripped = self
175 .segments()
176 .filter(|s| !s.is_group())
177 .collect::<PathBuf>()
178 .inner;
179 if stripped.is_empty() {
183 return Cow::Borrowed("/");
184 }
185 Cow::Owned(stripped)
186 }
187
188 #[must_use]
204 pub fn starts_with(&self, other: &Path) -> bool {
205 if self.inner.len() < other.inner.len() {
206 return false;
207 }
208 self.segments().zip(other.segments()).all(|(a, b)| a == b)
209 }
210
211 #[must_use]
231 pub fn join(&self, other: &Path) -> PathBuf {
232 let mut buf = self.to_owned();
233 buf += other;
234 buf
235 }
236
237 #[must_use]
271 pub fn matches(&self, url: &str) -> bool {
272 fn first_segment(rest: &str) -> (&str, Option<&str>) {
277 match rest.split_once('/') {
278 Some((head, tail)) => (head, Some(tail)),
279 None => (rest, None),
280 }
281 }
282
283 let body = url.strip_prefix('/').unwrap_or(url);
287 let mut rest = (!body.is_empty()).then_some(body);
288 for segment in self.segments() {
289 match segment {
290 PathSegment::Group(_) => {}
292 PathSegment::Static("") => return rest == Some(""),
295 PathSegment::Static(expected) => match rest.map(first_segment) {
296 Some((head, tail)) if head == expected => rest = tail,
297 _ => return false,
298 },
299 PathSegment::Param(_) => match rest.map(first_segment) {
302 Some((head, tail)) if !head.is_empty() => rest = tail,
303 _ => return false,
304 },
305 PathSegment::CatchAll(_) => return rest.is_some_and(|rest| !rest.is_empty()),
308 }
309 }
310 rest.is_none()
312 }
313
314 #[must_use]
328 pub fn as_str(&self) -> &str {
329 &self.inner
330 }
331
332 #[must_use]
345 pub fn has_trailing_slash(&self) -> bool {
346 self.inner.ends_with('/')
347 }
348
349 #[must_use]
353 pub fn len(&self) -> usize {
354 self.inner.len()
355 }
356
357 #[must_use]
359 pub fn is_empty(&self) -> bool {
360 self.len() == 0
361 }
362}
363
364impl Display for Path {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 self.inner.fmt(f)
367 }
368}
369
370impl ToOwned for Path {
371 type Owned = PathBuf;
372
373 fn to_owned(&self) -> Self::Owned {
374 PathBuf {
375 inner: self.inner.to_owned(),
376 }
377 }
378}
379
380impl<'a> From<&'a Path> for Cow<'a, Path> {
381 fn from(value: &'a Path) -> Self {
382 Self::Borrowed(value)
383 }
384}
385
386#[derive(Debug, Clone)]
389#[must_use = "iterators are lazy and do nothing unless consumed"]
390pub struct PathSegments<'path> {
391 rest: &'path str,
393 done: bool,
396}
397
398impl<'path> PathSegments<'path> {
399 fn new(path: &'path Path) -> Self {
400 match path.inner.strip_prefix('/') {
401 Some(rest) => Self { rest, done: false },
402 None => Self {
404 rest: "",
405 done: true,
406 },
407 }
408 }
409
410 fn last_segment(&mut self) -> &'path str {
413 self.done = true;
414 mem::take(&mut self.rest)
415 }
416}
417
418impl<'path> Iterator for PathSegments<'path> {
419 type Item = PathSegment<'path>;
420
421 fn next(&mut self) -> Option<Self::Item> {
422 if self.done {
423 return None;
424 }
425 let segment = match self.rest.split_once('/') {
426 Some((segment, rest)) => {
427 self.rest = rest;
428 segment
429 }
430 None => self.last_segment(),
431 };
432 Some(PathSegment::new_unchecked(segment))
435 }
436}
437
438impl DoubleEndedIterator for PathSegments<'_> {
439 fn next_back(&mut self) -> Option<Self::Item> {
440 if self.done {
441 return None;
442 }
443 let segment = match self.rest.rsplit_once('/') {
444 Some((rest, segment)) => {
445 self.rest = rest;
446 segment
447 }
448 None => self.last_segment(),
449 };
450 Some(PathSegment::new_unchecked(segment))
451 }
452}
453
454impl FusedIterator for PathSegments<'_> {}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459#[non_exhaustive]
460pub enum PathError {
461 MissingLeadingSlash,
463 EmptySegment,
465 MissingClosingBrace,
467 MissingClosingParen,
469 UnexpectedBracket,
471 EmptyName,
473 InvalidNameStart,
475 InvalidNameChar,
477}
478
479impl PathError {
480 const fn message(self) -> &'static str {
482 match self {
483 Self::MissingLeadingSlash => "invalid path: must be empty or start with `/`",
484 Self::EmptySegment => "invalid path: empty segment",
485 Self::MissingClosingBrace => "invalid path: missing closing `}`",
486 Self::MissingClosingParen => "invalid path: missing closing `)`",
487 Self::UnexpectedBracket => "invalid path: unexpected bracket in static segment",
488 Self::EmptyName => "invalid path: segment name must not be empty",
489 Self::InvalidNameStart => {
490 "invalid path: segment name must start with a letter or underscore"
491 }
492 Self::InvalidNameChar => "invalid path: segment name contains an invalid character",
493 }
494 }
495}
496
497impl Display for PathError {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 f.write_str(self.message())
500 }
501}
502
503impl std::error::Error for PathError {}
504
505#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
522pub struct PathBuf {
523 inner: String,
524}
525
526impl PathBuf {
527 #[must_use]
529 pub fn new() -> Self {
530 PathBuf::default()
531 }
532}
533
534impl Borrow<Path> for PathBuf {
535 fn borrow(&self) -> &Path {
536 Path::new_unchecked(&self.inner)
538 }
539}
540
541impl From<PathBuf> for Cow<'static, Path> {
542 fn from(value: PathBuf) -> Self {
543 Self::Owned(value)
544 }
545}
546
547impl Deref for PathBuf {
548 type Target = Path;
549
550 fn deref(&self) -> &Self::Target {
551 Path::new_unchecked(&self.inner)
553 }
554}
555
556impl PathBuf {
557 fn pop_trailing_slash(&mut self) {
560 if self.inner.ends_with('/') {
561 self.inner.pop();
562 }
563 }
564}
565
566impl AddAssign<PathSegment<'_>> for PathBuf {
567 fn add_assign(&mut self, rhs: PathSegment<'_>) {
568 self.pop_trailing_slash();
569 write!(self.inner, "/{rhs}").unwrap();
570 }
571}
572
573impl AddAssign<&Path> for PathBuf {
574 fn add_assign(&mut self, rhs: &Path) {
575 if rhs.is_empty() {
576 return;
577 }
578 self.pop_trailing_slash();
581 self.inner.push_str(&rhs.inner);
582 }
583}
584
585impl<'a> FromIterator<PathSegment<'a>> for PathBuf {
586 fn from_iter<T: IntoIterator<Item = PathSegment<'a>>>(iter: T) -> Self {
587 let mut buf = PathBuf::new();
588 for segment in iter {
589 buf += segment;
590 }
591 buf
592 }
593}
594
595pub trait IntoPath {
601 #[track_caller]
607 fn into_path(self) -> Cow<'static, Path>;
608}
609
610impl IntoPath for &'static str {
611 #[track_caller]
612 fn into_path(self) -> Cow<'static, Path> {
613 Cow::Borrowed(Path::new(self))
614 }
615}
616
617impl IntoPath for &'static Path {
618 fn into_path(self) -> Cow<'static, Path> {
619 Cow::Borrowed(self)
620 }
621}
622
623impl IntoPath for PathBuf {
624 fn into_path(self) -> Cow<'static, Path> {
625 Cow::Owned(self)
626 }
627}
628
629impl IntoPath for Cow<'static, Path> {
630 fn into_path(self) -> Cow<'static, Path> {
631 self
632 }
633}
634
635impl Display for PathBuf {
636 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
637 self.inner.fmt(f)
638 }
639}
640
641#[derive(Debug, Clone, PartialEq, Eq, Hash)]
656pub enum PathSegment<'a> {
657 Static(&'a str),
659 Group(&'a str),
661 Param(&'a str),
663 CatchAll(&'a str),
665}
666
667impl<'a> PathSegment<'a> {
668 #[must_use]
677 #[track_caller]
678 pub fn new(s: &'a str) -> Self {
679 match Self::from_str(s) {
680 Ok(segment) => segment,
681 Err(err) => panic!("{}", err.message()),
682 }
683 }
684
685 #[allow(clippy::should_implement_trait)]
695 pub fn from_str(s: &'a str) -> Result<Self, PathError> {
696 validate_segment(s.as_bytes(), 0, s.len())?;
698 Ok(Self::new_unchecked(s))
699 }
700
701 #[must_use]
708 pub fn new_unchecked(s: &'a str) -> Self {
709 if let Some(inner) = s.strip_prefix('{') {
710 let inner = inner.strip_suffix('}').unwrap_or(inner);
711 match inner.strip_prefix('*') {
712 Some(name) => PathSegment::CatchAll(name),
713 None => PathSegment::Param(inner),
714 }
715 } else if let Some(inner) = s.strip_prefix('(') {
716 PathSegment::Group(inner.strip_suffix(')').unwrap_or(inner))
717 } else {
718 PathSegment::Static(s)
719 }
720 }
721
722 #[must_use]
726 pub fn is_static(&self) -> bool {
727 matches!(self, Self::Static(..))
728 }
729
730 #[must_use]
734 pub fn is_group(&self) -> bool {
735 matches!(self, Self::Group(..))
736 }
737
738 #[must_use]
742 pub fn is_param(&self) -> bool {
743 matches!(self, Self::Param(..))
744 }
745
746 #[must_use]
750 pub fn is_catch_all(&self) -> bool {
751 matches!(self, Self::CatchAll(..))
752 }
753
754 #[must_use]
756 pub fn as_static(&self) -> Option<&&'a str> {
757 if let Self::Static(v) = self {
758 Some(v)
759 } else {
760 None
761 }
762 }
763
764 #[must_use]
766 pub fn as_group(&self) -> Option<&&'a str> {
767 if let Self::Group(v) = self {
768 Some(v)
769 } else {
770 None
771 }
772 }
773
774 #[must_use]
787 pub fn param_name(&self) -> Option<&'a str> {
788 match *self {
789 Self::Param(name) | Self::CatchAll(name) => Some(name),
790 Self::Static(_) | Self::Group(_) => None,
791 }
792 }
793
794 #[must_use]
796 pub fn as_param(&self) -> Option<&&'a str> {
797 if let Self::Param(v) = self {
798 Some(v)
799 } else {
800 None
801 }
802 }
803
804 #[must_use]
806 pub fn as_catch_all(&self) -> Option<&&'a str> {
807 if let Self::CatchAll(v) = self {
808 Some(v)
809 } else {
810 None
811 }
812 }
813}
814
815const fn validate_segment(bytes: &[u8], start: usize, end: usize) -> Result<(), PathError> {
819 if start >= end {
820 return Err(PathError::EmptySegment);
821 }
822 match bytes[start] {
823 b'{' => {
824 if bytes[end - 1] != b'}' {
825 return Err(PathError::MissingClosingBrace);
826 }
827 let mut name_start = start + 1;
829 let name_end = end - 1;
830 if name_start < name_end && bytes[name_start] == b'*' {
831 name_start += 1;
832 }
833 validate_ident(bytes, name_start, name_end)
834 }
835 b'(' => {
836 if bytes[end - 1] != b')' {
837 return Err(PathError::MissingClosingParen);
838 }
839 validate_ident(bytes, start + 1, end - 1)
840 }
841 _ => {
842 let mut i = start;
844 while i < end {
845 match bytes[i] {
846 b'{' | b'}' | b'(' | b')' => return Err(PathError::UnexpectedBracket),
847 _ => {}
848 }
849 i += 1;
850 }
851 Ok(())
852 }
853 }
854}
855
856const fn validate_ident(bytes: &[u8], start: usize, end: usize) -> Result<(), PathError> {
859 if start >= end {
860 return Err(PathError::EmptyName);
861 }
862 let first = bytes[start];
863 if !first.is_ascii_alphabetic() && first != b'_' {
864 return Err(PathError::InvalidNameStart);
865 }
866 let mut i = start + 1;
867 while i < end {
868 let ch = bytes[i];
869 if !ch.is_ascii_alphanumeric() && ch != b'_' {
870 return Err(PathError::InvalidNameChar);
871 }
872 i += 1;
873 }
874 Ok(())
875}
876
877impl Display for PathSegment<'_> {
878 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
879 match self {
880 Self::Static(inner) => f.write_str(inner),
881 Self::Param(inner) => write!(f, "{{{inner}}}"),
882 Self::Group(inner) => write!(f, "({inner})"),
883 Self::CatchAll(inner) => write!(f, "{{*{inner}}}"),
884 }
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 #[test]
895 fn path_root_slash_normalized() {
896 let path = Path::new("/");
897 assert_eq!(&path.inner, "");
898 assert_eq!(path.to_matchit_path(), "/");
899 assert_eq!(path.segments().count(), 0);
900 }
901
902 #[test]
903 fn path_segments() {
904 let path = Path::new("/dashboard/{id}/(auth)");
905 let segs: Vec<_> = path.segments().collect();
906 assert_eq!(
907 segs,
908 vec![
909 PathSegment::Static("dashboard"),
910 PathSegment::Param("id"),
911 PathSegment::Group("auth"),
912 ]
913 );
914 }
915
916 #[test]
917 fn path_single_segment() {
918 let path = Path::new("/home");
919 let segs: Vec<_> = path.segments().collect();
920 assert_eq!(segs, vec![PathSegment::Static("home")]);
921 }
922
923 #[test]
924 fn path_segments_from_the_back() {
925 let path = Path::new("/dashboard/{id}/(auth)");
926 let segs: Vec<_> = path.segments().rev().collect();
927 assert_eq!(
928 segs,
929 vec![
930 PathSegment::Group("auth"),
931 PathSegment::Param("id"),
932 PathSegment::Static("dashboard"),
933 ]
934 );
935 }
936
937 #[test]
938 fn path_segments_from_both_ends_meet_in_the_middle() {
939 let path = Path::new("/a/b/c");
940 let mut segments = path.segments();
941
942 assert_eq!(segments.next(), Some(PathSegment::Static("a")));
943 assert_eq!(segments.next_back(), Some(PathSegment::Static("c")));
944 assert_eq!(segments.next(), Some(PathSegment::Static("b")));
945 assert_eq!(segments.next(), None);
947 assert_eq!(segments.next_back(), None);
948 }
949
950 #[test]
951 fn root_path_yields_no_segments_from_either_end() {
952 let mut segments = Path::new("/").segments();
953
954 assert_eq!(segments.next(), None);
955 assert_eq!(segments.next_back(), None);
956 }
957
958 #[test]
959 fn trailing_slash_is_an_empty_last_segment() {
960 let path = Path::new("/users/{id}/");
961 let segs: Vec<_> = path.segments().collect();
962 assert_eq!(
963 segs,
964 vec![
965 PathSegment::Static("users"),
966 PathSegment::Param("id"),
967 PathSegment::Static(""),
968 ]
969 );
970 assert_eq!(path.segments().next_back(), Some(PathSegment::Static("")));
971 assert_eq!(path.as_str(), "/users/{id}/");
972 }
973
974 #[test]
975 fn path_to_matchit_strips_groups() {
976 let path = Path::new("/(auth)/dashboard/{id}");
977 assert_eq!(path.to_matchit_path(), "/dashboard/{id}");
978 }
979
980 #[test]
981 fn path_to_matchit_empty() {
982 let path = Path::new("");
983 assert_eq!(path.to_matchit_path(), "/");
984 }
985
986 #[test]
987 fn path_to_matchit_group_only_is_root() {
988 assert_eq!(Path::new("/(marketing)").to_matchit_path(), "/");
990 assert_eq!(Path::new("/(a)/(b)").to_matchit_path(), "/");
992 }
993
994 #[test]
995 fn path_to_matchit_no_groups() {
996 let path = Path::new("/users/{id}");
997 assert_eq!(path.to_matchit_path(), "/users/{id}");
998 }
999
1000 #[test]
1001 fn path_to_matchit_keeps_trailing_slash() {
1002 assert_eq!(Path::new("/users/").to_matchit_path(), "/users/");
1003 assert_eq!(
1004 Path::new("/(auth)/users/{id}/").to_matchit_path(),
1005 "/users/{id}/"
1006 );
1007 assert_eq!(Path::new("/(marketing)/").to_matchit_path(), "/");
1009 }
1010
1011 #[test]
1014 fn join_appends_segments() {
1015 let joined = Path::new("/settings").join(Path::new("/(admin)/{id}"));
1016 assert_eq!(joined.as_str(), "/settings/(admin)/{id}");
1017 assert_eq!(joined.segments().count(), 3);
1018 }
1019
1020 #[test]
1021 fn join_root_on_either_side_is_identity() {
1022 let path = Path::new("/settings");
1023 assert_eq!(&*path.join(Path::ROOT), path);
1024 assert_eq!(&*Path::ROOT.join(path), path);
1025 assert!(Path::ROOT.join(Path::ROOT).is_empty());
1026 }
1027
1028 #[test]
1029 fn path_buf_add_assign_path() {
1030 let mut buf = PathBuf::new();
1031 buf += Path::new("/users");
1032 buf += PathSegment::Param("id");
1033 buf += Path::new("/posts");
1034 assert_eq!(buf.as_str(), "/users/{id}/posts");
1035 }
1036
1037 #[test]
1038 fn join_drops_a_trailing_slash_when_segments_follow() {
1039 let base = Path::new("/settings/");
1040 assert_eq!(base.join(Path::new("/export")).as_str(), "/settings/export");
1041 assert_eq!(
1042 base.join(Path::new("/export/")).as_str(),
1043 "/settings/export/"
1044 );
1045 let mut buf = base.to_owned();
1046 buf += PathSegment::Param("id");
1047 assert_eq!(buf.as_str(), "/settings/{id}");
1048 }
1049
1050 #[test]
1051 fn join_keeps_a_trailing_slash_when_nothing_follows() {
1052 let path = Path::new("/settings/");
1053 assert_eq!(&*path.join(Path::ROOT), path);
1054 assert_eq!(&*Path::ROOT.join(path), path);
1055 }
1056
1057 #[test]
1058 fn path_starts_with_match() {
1059 let path = Path::new("/users/{id}/posts");
1060 let prefix = Path::new("/users/{id}");
1061 assert!(path.starts_with(prefix));
1062 }
1063
1064 #[test]
1065 fn path_starts_with_no_match() {
1066 let path = Path::new("/users/{id}");
1067 let prefix = Path::new("/posts/{id}");
1068 assert!(!path.starts_with(prefix));
1069 }
1070
1071 #[test]
1072 fn path_starts_with_longer_prefix() {
1073 let path = Path::new("/users");
1074 let prefix = Path::new("/users/{id}/posts");
1075 assert!(!path.starts_with(prefix));
1076 }
1077
1078 #[test]
1079 fn path_starts_with_rejects_partial_segment() {
1080 assert!(!Path::new("/administrator").starts_with(Path::new("/admin")));
1083 }
1084
1085 #[test]
1086 fn path_starts_with_includes_groups() {
1087 let path = Path::new("/(auth)/dashboard");
1088 assert!(path.starts_with(Path::new("/(auth)")));
1089 assert!(!path.starts_with(Path::new("/dashboard")));
1092 }
1093
1094 #[test]
1095 fn path_starts_with_distinguishes_param_names() {
1096 let path = Path::new("/users/{id}/posts");
1097 assert!(path.starts_with(Path::new("/users/{id}")));
1098 assert!(!path.starts_with(Path::new("/users/{user_id}")));
1099 }
1100
1101 #[test]
1102 fn path_starts_with_trailing_slash() {
1103 assert!(Path::new("/users/").starts_with(Path::new("/users")));
1106 assert!(Path::new("/users/").starts_with(Path::new("/users/")));
1107 assert!(!Path::new("/users").starts_with(Path::new("/users/")));
1108 assert!(!Path::new("/users/posts").starts_with(Path::new("/users/")));
1109 }
1110
1111 #[test]
1112 fn path_display() {
1113 let path = Path::new("/users/{id}");
1114 assert_eq!(path.to_string(), "/users/{id}");
1115 }
1116
1117 #[test]
1120 fn matches_static_exact() {
1121 assert!(Path::new("/users/list").matches("/users/list"));
1122 }
1123
1124 #[test]
1125 fn matches_static_mismatch() {
1126 assert!(!Path::new("/users/list").matches("/users/all"));
1127 }
1128
1129 #[test]
1130 fn matches_rejects_partial_segment() {
1131 assert!(!Path::new("/admin").matches("/administrator"));
1132 assert!(!Path::new("/administrator").matches("/admin"));
1133 }
1134
1135 #[test]
1136 fn matches_is_case_sensitive() {
1137 assert!(!Path::new("/admin").matches("/Admin"));
1138 }
1139
1140 #[test]
1141 fn matches_rejects_empty_segments() {
1142 assert!(!Path::new("/admin").matches("//admin"));
1144 assert!(!Path::new("/users/{id}").matches("/users//"));
1145 assert!(!Path::new("/users/{id}/posts").matches("/users//posts"));
1146 }
1147
1148 #[test]
1149 fn matches_treats_percent_encoding_as_opaque() {
1150 assert!(!Path::new("/admin/users").matches("/admin%2Fusers"));
1153 assert!(Path::new("/{page}").matches("/admin%2Fusers"));
1154 }
1155
1156 #[test]
1157 fn matches_param_captures_any_segment() {
1158 let path = Path::new("/users/{id}/posts");
1159 assert!(path.matches("/users/42/posts"));
1160 assert!(path.matches("/users/anything/posts"));
1161 }
1162
1163 #[test]
1164 fn matches_rejects_too_few_segments() {
1165 assert!(!Path::new("/users/{id}/posts").matches("/users/42"));
1166 }
1167
1168 #[test]
1169 fn matches_rejects_trailing_segments() {
1170 assert!(!Path::new("/users/{id}").matches("/users/42/posts"));
1171 }
1172
1173 #[test]
1174 fn matches_ignores_groups() {
1175 assert!(Path::new("/(auth)/dashboard").matches("/dashboard"));
1176 assert!(Path::new("/(a)/{id}/(b)").matches("/42"));
1177 }
1178
1179 #[test]
1180 fn matches_root() {
1181 assert!(Path::new("/").matches("/"));
1182 assert!(!Path::new("/").matches("/anything"));
1183 }
1184
1185 #[test]
1186 fn matches_group_only_path_is_root() {
1187 assert!(Path::new("/(marketing)").matches("/"));
1188 }
1189
1190 #[test]
1191 fn matches_trailing_slash_exactly() {
1192 assert!(!Path::new("/users").matches("/users/"));
1193 assert!(Path::new("/users/").matches("/users/"));
1194 assert!(!Path::new("/users/").matches("/users"));
1195 assert!(!Path::new("/users/").matches("/users//"));
1196 assert!(!Path::new("/users/").matches("/users/posts"));
1197 assert!(Path::new("/users/{id}/").matches("/users/42/"));
1198 assert!(!Path::new("/users/{id}/").matches("/users/42"));
1199 assert!(!Path::new("/users/{id}").matches("/users/"));
1200 }
1201
1202 #[test]
1203 fn matches_root_rejects_doubled_slash() {
1204 assert!(!Path::new("/").matches("//"));
1205 }
1206
1207 #[test]
1208 fn matches_catch_all() {
1209 let path = Path::new("/files/{*rest}");
1210 assert!(path.matches("/files/a"));
1211 assert!(path.matches("/files/a/b/c"));
1212 }
1213
1214 #[test]
1215 fn matches_catch_all_requires_a_segment() {
1216 assert!(!Path::new("/files/{*rest}").matches("/files"));
1217 assert!(!Path::new("/files/{*rest}").matches("/files/"));
1218 }
1219
1220 #[test]
1221 fn matches_catch_all_swallows_empty_segments() {
1222 assert!(Path::new("/files/{*rest}").matches("/files//"));
1224 }
1225
1226 #[test]
1227 fn matches_non_origin_form_urls() {
1228 assert!(!Path::new("/").matches("*"));
1231 assert!(!Path::new("/admin").matches("*"));
1232 assert!(Path::new("/").matches(""));
1233 assert!(!Path::new("/admin").matches(""));
1234 }
1235
1236 #[test]
1239 fn from_str_accepts_valid_paths() {
1240 for input in [
1241 "",
1242 "/",
1243 "/users",
1244 "/users/{id}",
1245 "/users/{id}/posts/{*rest}",
1246 "/(auth)/dashboard/{user_id}",
1247 "/{_private}",
1248 "/users/",
1249 "/users/{id}/",
1250 "/(marketing)/",
1251 ] {
1252 assert!(Path::from_str(input).is_ok(), "rejected `{input}`");
1253 }
1254 }
1255
1256 #[test]
1257 fn from_str_reports_errors() {
1258 use PathError::*;
1259 let cases = [
1260 ("users", MissingLeadingSlash),
1261 ("//", EmptySegment),
1262 ("/users//", EmptySegment),
1263 ("/users//posts", EmptySegment),
1264 ("/foo{bar}", UnexpectedBracket),
1265 ("/{id", MissingClosingBrace),
1266 ("/(auth", MissingClosingParen),
1267 ("/{}", EmptyName),
1268 ("/{*}", EmptyName),
1269 ("/{0id}", InvalidNameStart),
1270 ("/{id-name}", InvalidNameChar),
1271 ("/(my-group)", InvalidNameChar),
1272 ];
1273 for (input, expected) in cases {
1274 assert_eq!(Path::from_str(input), Err(expected), "for `{input}`");
1275 }
1276 }
1277
1278 #[test]
1279 fn new_validates_in_const_context() {
1280 const PATH: &Path = Path::new("/users/{id}/(auth)");
1283 assert_eq!(PATH.segments().count(), 3);
1284 }
1285
1286 #[test]
1287 #[should_panic(expected = "unexpected bracket")]
1288 fn new_panics_on_invalid() {
1289 let _ = Path::new("/foo{bar}");
1290 }
1291
1292 #[test]
1295 fn pathbuf_new_is_empty() {
1296 let buf = PathBuf::new();
1297 assert_eq!(buf.to_string(), "");
1298 }
1299
1300 #[test]
1301 fn pathbuf_add_assign() {
1302 let mut buf = PathBuf::new();
1303 buf += PathSegment::Static("users");
1304 buf += PathSegment::Param("id");
1305 assert_eq!(buf.to_string(), "/users/{id}");
1306 }
1307
1308 #[test]
1309 fn pathbuf_add_assign_trailing_slash() {
1310 let mut buf = PathBuf::new();
1311 buf += PathSegment::Static("users");
1312 buf += PathSegment::Static("");
1313 assert_eq!(buf.to_string(), "/users/");
1314 assert_eq!(&*buf, Path::new("/users/"));
1315 }
1316
1317 #[test]
1318 fn pathbuf_from_iterator() {
1319 let buf: PathBuf = vec![
1320 PathSegment::Static("api"),
1321 PathSegment::Static("v1"),
1322 PathSegment::Param("resource"),
1323 ]
1324 .into_iter()
1325 .collect();
1326 assert_eq!(buf.to_string(), "/api/v1/{resource}");
1327 }
1328
1329 #[test]
1330 fn pathbuf_deref_to_path() {
1331 let mut buf = PathBuf::new();
1332 buf += PathSegment::Static("users");
1333 let path: &Path = &buf;
1334 let segs: Vec<_> = path.segments().collect();
1335 assert_eq!(segs, vec![PathSegment::Static("users")]);
1336 }
1337
1338 #[test]
1339 fn pathbuf_to_owned_roundtrip() {
1340 let path = Path::new("/users/{id}");
1341 let buf = path.to_owned();
1342 assert_eq!(&*buf, path);
1343 }
1344
1345 #[test]
1348 fn static_segment() {
1349 let seg = PathSegment::new("dashboard");
1350 assert!(seg.is_static());
1351 assert_eq!(seg.as_static(), Some(&"dashboard"));
1352 }
1353
1354 #[test]
1355 fn param_segment() {
1356 let seg = PathSegment::new("{id}");
1357 assert!(seg.is_param());
1358 assert_eq!(seg.as_param(), Some(&"id"));
1359 }
1360
1361 #[test]
1362 fn param_with_underscore() {
1363 let seg = PathSegment::new("{user_id}");
1364 assert!(seg.is_param());
1365 assert_eq!(seg.as_param(), Some(&"user_id"));
1366 }
1367
1368 #[test]
1369 fn catch_all_segment() {
1370 let seg = PathSegment::new("{*rest}");
1371 assert!(matches!(seg, PathSegment::CatchAll("rest")));
1372 }
1373
1374 #[test]
1375 fn group_segment() {
1376 let seg = PathSegment::new("(auth)");
1377 assert!(seg.is_group());
1378 assert_eq!(seg.as_group(), Some(&"auth"));
1379 }
1380
1381 #[test]
1382 fn only_param_and_catch_all_segments_capture() {
1383 assert_eq!(PathSegment::new("{id}").param_name(), Some("id"));
1384 assert_eq!(PathSegment::new("{*rest}").param_name(), Some("rest"));
1385 assert_eq!(PathSegment::new("users").param_name(), None);
1386 assert_eq!(PathSegment::new("(auth)").param_name(), None);
1387 }
1388
1389 #[test]
1390 fn display_roundtrip() {
1391 for input in ["dashboard", "{id}", "{*rest}", "(auth)"] {
1392 assert_eq!(PathSegment::new(input).to_string(), input);
1393 }
1394 }
1395
1396 #[test]
1397 #[should_panic(expected = "missing closing `}`")]
1398 fn param_missing_close() {
1399 let _ = PathSegment::new("{id");
1400 }
1401
1402 #[test]
1403 #[should_panic(expected = "missing closing `)`")]
1404 fn group_missing_close() {
1405 let _ = PathSegment::new("(auth");
1406 }
1407
1408 #[test]
1409 #[should_panic(expected = "empty segment")]
1410 fn empty_segment() {
1411 let _ = PathSegment::new("");
1412 }
1413
1414 #[test]
1415 #[should_panic(expected = "unexpected bracket")]
1416 fn static_with_braces() {
1417 let _ = PathSegment::new("foo{bar}");
1418 }
1419
1420 #[test]
1421 #[should_panic(expected = "name must not be empty")]
1422 fn param_empty_name() {
1423 let _ = PathSegment::new("{}");
1424 }
1425
1426 #[test]
1427 #[should_panic(expected = "name must not be empty")]
1428 fn group_empty_name() {
1429 let _ = PathSegment::new("()");
1430 }
1431
1432 #[test]
1433 #[should_panic(expected = "name must not be empty")]
1434 fn catch_all_empty_name() {
1435 let _ = PathSegment::new("{*}");
1436 }
1437
1438 #[test]
1439 #[should_panic(expected = "must start with a letter or underscore")]
1440 fn param_invalid_start() {
1441 let _ = PathSegment::new("{0id}");
1442 }
1443
1444 #[test]
1445 #[should_panic(expected = "contains an invalid character")]
1446 fn param_invalid_char() {
1447 let _ = PathSegment::new("{id-name}");
1448 }
1449
1450 #[test]
1451 #[should_panic(expected = "must start with a letter or underscore")]
1452 fn group_invalid_start() {
1453 let _ = PathSegment::new("(0auth)");
1454 }
1455
1456 #[test]
1457 #[should_panic(expected = "contains an invalid character")]
1458 fn group_invalid_char() {
1459 let _ = PathSegment::new("(my-group)");
1460 }
1461
1462 #[test]
1463 fn underscore_leading_ident() {
1464 let seg = PathSegment::new("{_private}");
1465 assert!(seg.is_param());
1466 assert_eq!(seg.as_param(), Some(&"_private"));
1467 }
1468}