Skip to main content

topcoat_router/
path.rs

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/// A borrowed route pattern made of `/`-separated segments.
12///
13/// A `Path` consists of `/`-separated segments, where each segment is one of:
14/// - **`Static`**: a literal string (e.g. `users`)
15/// - **`Param`**: a dynamic parameter in braces (e.g. `{id}`)
16/// - **`CatchAll`**: a wildcard tail in braces with `*` (e.g. `{*rest}`)
17/// - **`Group`**: a logical grouping in parentheses (e.g. `(auth)`), stripped when converting to a
18///   `matchit` path
19///
20/// Create one from a string with [`Path::new`]. The root path `"/"` is stored
21/// as an empty string and has no segments.
22///
23/// A trailing slash is significant, so `/users/` and `/users` are different
24/// paths. It is represented by an empty final `Static` segment. No other
25/// segment may be empty.
26///
27/// # Examples
28///
29/// ```
30/// use topcoat_router::{Path, PathSegment};
31///
32/// let path = Path::new("/users/(group)/{id}");
33/// assert_eq!(path.segments().count(), 3);
34/// assert_eq!(path.to_matchit_path(), "/users/{id}");
35///
36/// let slashed = Path::new("/users/");
37/// assert_eq!(slashed.segments().last(), Some(PathSegment::Static("")));
38/// assert_eq!(slashed.to_matchit_path(), "/users/");
39/// ```
40#[derive(Debug, PartialEq, Eq, Hash, RefCastCustom)]
41#[repr(transparent)]
42pub struct Path {
43    inner: str,
44}
45
46impl Path {
47    // The root path "/".
48    pub const ROOT: &Path = Path::new("/");
49
50    /// Creates a `&Path` from a string slice.
51    ///
52    /// The root path `"/"` is stored as an empty string with no segments.
53    ///
54    /// Use [`from_str`](Path::from_str) to receive an error instead of a panic.
55    /// This function also works in const contexts.
56    ///
57    /// # Panics
58    ///
59    /// Panics if `s` is not a well-formed path; see [`PathError`] for the
60    /// conditions that are rejected.
61    #[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    /// Creates a `&Path` from a string slice, validating its segments.
71    ///
72    /// The root path `"/"` is stored as an empty string. Other paths contain
73    /// `/`-prefixed [`PathSegment`]s. Only the final segment may be empty,
74    /// representing a trailing slash.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`PathError`] if `s` is not a valid path: it must be empty, be
79    /// the root `"/"`, or be a sequence of `/`-prefixed valid segments.
80    #[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        // The root path is empty and has no segments to validate.
89        if len == 0 {
90            return Ok(Self::new_unchecked(s));
91        }
92        if bytes[0] != b'/' {
93            return Err(PathError::MissingLeadingSlash);
94        }
95        // Walk the `/`-separated segments, validating each `bytes[start..end)`.
96        // The last segment may be empty, as long as it is not also the first:
97        // that would be `//`, which is not the root with a trailing slash.
98        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    /// Creates a `&Path` from a string slice without validating or normalizing it.
114    ///
115    /// The caller must provide a valid, normalized path, such as the string
116    /// from another `Path`. In particular, use an empty string for the root.
117    /// Other input can produce incorrect segment results.
118    #[ref_cast_custom]
119    #[must_use]
120    pub const fn new_unchecked(s: &str) -> &Self;
121
122    /// Returns an iterator over the [`PathSegment`]s of this path.
123    ///
124    /// The root path yields zero segments.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use topcoat_router::{Path, PathSegment};
130    ///
131    /// let path = Path::new("/users/{id}/(auth)");
132    /// let segs: Vec<_> = path.segments().collect();
133    /// assert_eq!(
134    ///     segs,
135    ///     vec![
136    ///         PathSegment::Static("users"),
137    ///         PathSegment::Param("id"),
138    ///         PathSegment::Group("auth"),
139    ///     ]
140    /// );
141    /// ```
142    pub fn segments(&self) -> PathSegments<'_> {
143        PathSegments::new(self)
144    }
145
146    /// Converts this path to a `matchit`-compatible route string, stripping group
147    /// segments.
148    ///
149    /// Group segments (e.g. `(auth)`) are used for layout matching but are not
150    /// part of the URL that the router matches against. This method removes them
151    /// and returns the remaining path.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use topcoat_router::Path;
157    ///
158    /// let path = Path::new("/(auth)/dashboard/{id}");
159    /// assert_eq!(path.to_matchit_path(), "/dashboard/{id}");
160    ///
161    /// let root = Path::new("/");
162    /// assert_eq!(root.to_matchit_path(), "/");
163    ///
164    /// // A path made up entirely of group segments collapses to the root URL,
165    /// // e.g. a page in a `(marketing)` group that should serve `/`.
166    /// let group_root = Path::new("/(marketing)");
167    /// assert_eq!(group_root.to_matchit_path(), "/");
168    /// ```
169    #[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        // Stripping groups can leave nothing behind (e.g. `/(marketing)` or
180        // `/(a)/(b)`). Such a path addresses the root URL, so normalize the empty
181        // result back to "/": matchit rejects route paths that don't start with "/".
182        if stripped.is_empty() {
183            return Cow::Borrowed("/");
184        }
185        Cow::Owned(stripped)
186    }
187
188    /// Returns `true` if this path starts with the given prefix path.
189    ///
190    /// Compares segments using [`PathSegment`] equality, including groups.
191    /// Parameters must match by name. They do not match arbitrary values as
192    /// they do in [`matches`](Self::matches).
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use topcoat_router::Path;
198    ///
199    /// let path = Path::new("/users/{id}/posts");
200    /// assert!(path.starts_with(Path::new("/users/{id}")));
201    /// assert!(!path.starts_with(Path::new("/posts/{id}")));
202    /// ```
203    #[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    /// Returns a new path with the segments of `other` appended to this one.
212    ///
213    /// Joining the root path onto either side leaves the other path unchanged.
214    /// A trailing slash on this path is dropped when segments follow it.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use topcoat_router::Path;
220    ///
221    /// let base = Path::new("/settings");
222    /// assert_eq!(base.join(Path::new("/export")).as_str(), "/settings/export");
223    /// assert_eq!(base.join(Path::ROOT).as_str(), "/settings");
224    /// assert_eq!(Path::ROOT.join(base).as_str(), "/settings");
225    /// assert_eq!(
226    ///     Path::new("/settings/").join(Path::new("/export")).as_str(),
227    ///     "/settings/export"
228    /// );
229    /// ```
230    #[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    /// Returns `true` if `url`, a concrete URL path, matches this route path
238    /// exactly.
239    ///
240    /// Each segment is matched against the corresponding URL segment:
241    /// - **`Static`** segments must equal the URL segment.
242    /// - **`Param`** segments match any single non-empty URL segment.
243    /// - **`CatchAll`** segments match the remaining URL (including any `/` separators) and require
244    ///   at least one segment to be present.
245    /// - **`Group`** segments are ignored, as they are not part of the URL.
246    ///
247    /// A trailing `/` is significant: a path without one does not match a URL
248    /// with one, and the other way around.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use topcoat_router::Path;
254    ///
255    /// let path = Path::new("/users/{id}/posts");
256    /// assert!(path.matches("/users/42/posts"));
257    /// assert!(!path.matches("/users/42"));
258    ///
259    /// // Group segments are ignored.
260    /// assert!(Path::new("/(auth)/dashboard").matches("/dashboard"));
261    ///
262    /// // A catch-all matches the remainder of the URL.
263    /// assert!(Path::new("/files/{*rest}").matches("/files/a/b/c"));
264    ///
265    /// // A trailing slash has to match.
266    /// assert!(Path::new("/users/").matches("/users/"));
267    /// assert!(!Path::new("/users/").matches("/users"));
268    /// assert!(!Path::new("/users").matches("/users/"));
269    /// ```
270    #[must_use]
271    pub fn matches(&self, url: &str) -> bool {
272        // Splits the `/`-separated URL body into its first segment and the
273        // remainder after the separator, e.g. "users/42" into ("users",
274        // Some("42")) and "users" into ("users", None): the remainder is `None`
275        // once the body is used up without a separator left over.
276        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        // Drop a single leading `/`; what remains is the `/`-separated body,
284        // e.g. "users/42/posts". The root URL "/" has nothing left to consume,
285        // while a trailing separator leaves an empty body behind.
286        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                // Groups exist only for layout matching and never appear in a URL.
291                PathSegment::Group(_) => {}
292                // A trailing slash matches when the URL ended in a separator
293                // with nothing after it.
294                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                // A parameter matches any single non-empty segment. An empty
300                // one (as in `/users//`) never routes, so reject it here too.
301                PathSegment::Param(_) => match rest.map(first_segment) {
302                    Some((head, tail)) if !head.is_empty() => rest = tail,
303                    _ => return false,
304                },
305                // A catch-all swallows the whole remainder, so nothing can
306                // follow it and there is never leftover URL to reject.
307                PathSegment::CatchAll(_) => return rest.is_some_and(|rest| !rest.is_empty()),
308            }
309        }
310        // Every route segment matched; the URL must also be used up.
311        rest.is_none()
312    }
313
314    /// Returns the string backing this path.
315    ///
316    /// The root path is backed by the empty string rather than `"/"`, matching
317    /// the normalization [`new`](Path::new) applies.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use topcoat_router::Path;
323    ///
324    /// assert_eq!(Path::new("/users/{id}").as_str(), "/users/{id}");
325    /// assert_eq!(Path::new("/").as_str(), "");
326    /// ```
327    #[must_use]
328    pub fn as_str(&self) -> &str {
329        &self.inner
330    }
331
332    /// Returns `true` if this path ends in a `/`, which is to say its last
333    /// segment is empty. The root path does not.
334    ///
335    /// # Examples
336    ///
337    /// ```
338    /// use topcoat_router::Path;
339    ///
340    /// assert!(Path::new("/users/").has_trailing_slash());
341    /// assert!(!Path::new("/users").has_trailing_slash());
342    /// assert!(!Path::new("/").has_trailing_slash());
343    /// ```
344    #[must_use]
345    pub fn has_trailing_slash(&self) -> bool {
346        self.inner.ends_with('/')
347    }
348
349    /// Returns the length of the string backing this path.
350    ///
351    /// Measures bytes, not characters.
352    #[must_use]
353    pub fn len(&self) -> usize {
354        self.inner.len()
355    }
356
357    /// Returns `true` if `self` has no path segments, i.e. `self` is the root path `/`.
358    #[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/// An iterator over the [`PathSegment`]s of a [`Path`], created by
387/// [`Path::segments`].
388#[derive(Debug, Clone)]
389#[must_use = "iterators are lazy and do nothing unless consumed"]
390pub struct PathSegments<'path> {
391    /// The `/`-separated body left to walk, without a leading `/`.
392    rest: &'path str,
393    /// Whether the body is used up. It is tracked separately because the last
394    /// segment leaves `rest` empty, which is also how the root path starts out.
395    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            // The root path is backed by the empty string and has no segments.
403            None => Self {
404                rest: "",
405                done: true,
406            },
407        }
408    }
409
410    /// Marks the body as used up and returns what was left of it, the segment
411    /// at whichever end the caller was reading.
412    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        // The path was validated on construction, so its segments need no
433        // re-validation here.
434        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/// The reason a string could not be parsed into a [`Path`] by
457/// [`Path::from_str`].
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459#[non_exhaustive]
460pub enum PathError {
461    /// The path was non-empty but did not start with `/`.
462    MissingLeadingSlash,
463    /// A segment other than the last was empty, as produced by a doubled `/`.
464    EmptySegment,
465    /// A `{` parameter or catch-all segment was missing its closing `}`.
466    MissingClosingBrace,
467    /// A `(` group segment was missing its closing `)`.
468    MissingClosingParen,
469    /// A static segment contained a `{`, `}`, `(`, or `)`.
470    UnexpectedBracket,
471    /// A param, catch-all, or group name was empty.
472    EmptyName,
473    /// A name did not start with an ASCII letter or `_`.
474    InvalidNameStart,
475    /// A name contained a character other than an ASCII alphanumeric or `_`.
476    InvalidNameChar,
477}
478
479impl PathError {
480    /// A human-readable description of the error.
481    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/// An owned route path, similar to [`std::path::PathBuf`] but for URL paths.
506///
507/// `PathBuf` is the owned counterpart of [`Path`]. It can be built incrementally
508/// by adding [`PathSegment`]s or whole [`Path`]s with `+=`, or collected from an
509/// iterator of segments.
510///
511/// # Examples
512///
513/// ```
514/// use topcoat_router::{PathBuf, PathSegment};
515///
516/// let mut buf = PathBuf::new();
517/// buf += PathSegment::Static("users");
518/// buf += PathSegment::Param("id");
519/// assert_eq!(buf.to_string(), "/users/{id}");
520/// ```
521#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
522pub struct PathBuf {
523    inner: String,
524}
525
526impl PathBuf {
527    /// Creates a new empty `PathBuf`.
528    #[must_use]
529    pub fn new() -> Self {
530        PathBuf::default()
531    }
532}
533
534impl Borrow<Path> for PathBuf {
535    fn borrow(&self) -> &Path {
536        // A `PathBuf` only ever holds a valid path, so skip re-validation.
537        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        // A `PathBuf` only ever holds a valid path, so skip re-validation.
552        Path::new_unchecked(&self.inner)
553    }
554}
555
556impl PathBuf {
557    /// Drops a trailing slash so that appended segments do not produce an
558    /// empty segment in the middle of the path.
559    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        // Both sides hold a validated path whose root is the empty string, so
579        // appending the raw string yields a valid path again.
580        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
595/// Conversion into a route path, accepted by APIs that take a path prefix.
596///
597/// A `&'static str` is parsed with [`Path::new`] and panics when it is not a
598/// well-formed path; [`Path`], [`PathBuf`], and `Cow<'static, Path>` values
599/// convert as they are.
600pub trait IntoPath {
601    /// Converts the value into a route path.
602    ///
603    /// # Panics
604    ///
605    /// Panics if the value is a string that is not a well-formed path.
606    #[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/// A single segment of a route [`Path`].
642///
643/// Topcoat paths use four segment types:
644///
645/// | Syntax      | Variant    | Example     | Description                                             |
646/// |-------------|------------|-------------|---------------------------------------------------------|
647/// | `foo`       | `Static`   | `users`     | Literal URL segment                                     |
648/// | `{name}`    | `Param`    | `{id}`      | Dynamic parameter, extracted at request time            |
649/// | `{*name}`   | `CatchAll` | `{*path}`   | Wildcard tail, matches the rest of the URL              |
650/// | `(name)`    | `Group`    | `(auth)`    | Logical grouping for layout matching, stripped from URL |
651///
652/// Segment names (for `Param`, `CatchAll`, and `Group`) must be valid
653/// identifiers: starting with a letter or underscore, containing only
654/// ASCII alphanumerics and underscores.
655#[derive(Debug, Clone, PartialEq, Eq, Hash)]
656pub enum PathSegment<'a> {
657    /// A literal URL segment (e.g. `users`).
658    Static(&'a str),
659    /// A logical grouping segment (e.g. `(auth)`), stripped from the URL path.
660    Group(&'a str),
661    /// A dynamic parameter segment (e.g. `{id}`).
662    Param(&'a str),
663    /// A wildcard tail segment (e.g. `{*rest}`), matching the remainder of the URL.
664    CatchAll(&'a str),
665}
666
667impl<'a> PathSegment<'a> {
668    /// Parses a single path segment string into a [`PathSegment`].
669    ///
670    /// This is the panicking counterpart of [`from_str`](PathSegment::from_str).
671    ///
672    /// # Panics
673    ///
674    /// Panics if `s` is not a well-formed segment; see [`PathError`] for the
675    /// conditions that are rejected.
676    #[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    /// Parses a single path segment string into a [`PathSegment`], validating it.
686    ///
687    /// Returns [`PathError`] if `s` is not a well-formed segment: an empty string,
688    /// a `{...}`/`(...)` segment missing its closing bracket, a static segment that
689    /// contains a bracket, or a name that is not a valid identifier.
690    ///
691    /// # Errors
692    ///
693    /// Returns [`PathError`] if `s` is not a well-formed segment.
694    #[allow(clippy::should_implement_trait)]
695    pub fn from_str(s: &'a str) -> Result<Self, PathError> {
696        // Validate first, then extract the variant from the now-known-valid input.
697        validate_segment(s.as_bytes(), 0, s.len())?;
698        Ok(Self::new_unchecked(s))
699    }
700
701    /// Parses a single path segment string into a [`PathSegment`] without
702    /// validating it.
703    ///
704    /// The caller must provide a valid segment, such as one from
705    /// [`Path::segments`]. Malformed input can produce an incorrect segment
706    /// rather than an error.
707    #[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    /// Returns `true` if the segment is [`Static`].
723    ///
724    /// [`Static`]: PathSegment::Static
725    #[must_use]
726    pub fn is_static(&self) -> bool {
727        matches!(self, Self::Static(..))
728    }
729
730    /// Returns `true` if the segment is [`Group`].
731    ///
732    /// [`Group`]: PathSegment::Group
733    #[must_use]
734    pub fn is_group(&self) -> bool {
735        matches!(self, Self::Group(..))
736    }
737
738    /// Returns `true` if the segment is [`Param`].
739    ///
740    /// [`Param`]: PathSegment::Param
741    #[must_use]
742    pub fn is_param(&self) -> bool {
743        matches!(self, Self::Param(..))
744    }
745
746    /// Returns `true` if the segment is [`CatchAll`].
747    ///
748    /// [`CatchAll`]: PathSegment::CatchAll
749    #[must_use]
750    pub fn is_catch_all(&self) -> bool {
751        matches!(self, Self::CatchAll(..))
752    }
753
754    /// Returns the inner string if this is a [`Static`](PathSegment::Static) segment.
755    #[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    /// Returns the inner string if this is a [`Group`](PathSegment::Group) segment.
765    #[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    /// Returns the name this segment captures a value under, or `None` if it
775    /// captures nothing.
776    ///
777    /// # Examples
778    ///
779    /// ```
780    /// use topcoat_router::PathSegment;
781    ///
782    /// assert_eq!(PathSegment::Param("id").param_name(), Some("id"));
783    /// assert_eq!(PathSegment::CatchAll("rest").param_name(), Some("rest"));
784    /// assert_eq!(PathSegment::Static("users").param_name(), None);
785    /// ```
786    #[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    /// Returns the inner string if this is a [`Param`](PathSegment::Param) segment.
795    #[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    /// Returns the inner string if this is a [`CatchAll`](PathSegment::CatchAll) segment.
805    #[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
815/// Validates a single segment `bytes[start..end)` of a [`Path`]. Operates on
816/// bytes (rather than a `&str` subslice) so it can run in the `const` context of
817/// [`Path::from_str`], and is shared with [`PathSegment::from_str`].
818const 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            // The name sits between the braces; a leading `*` marks a catch-all.
828            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            // A static segment must not contain any of the reserved brackets.
843            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
856/// Validates that `bytes[start..end)` is a valid identifier: non-empty, starting
857/// with an ASCII letter or `_`, and otherwise only ASCII alphanumerics or `_`.
858const 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    // -- Path --
893
894    #[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        // Both ends are exhausted once they meet.
946        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        // A page inside a route group that should serve `/`.
989        assert_eq!(Path::new("/(marketing)").to_matchit_path(), "/");
990        // Nested groups collapse the same way.
991        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        // A trailing slash after nothing but groups still addresses the root.
1008        assert_eq!(Path::new("/(marketing)/").to_matchit_path(), "/");
1009    }
1010
1011    // -- join --
1012
1013    #[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        // `/admin` is a string prefix of `/administrator`, but not a whole
1081        // segment, so it must not count as a path prefix.
1082        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        // Groups are part of the logical path, so `/dashboard` is not a prefix
1090        // of `/(auth)/dashboard` even though both serve the URL `/dashboard`.
1091        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        // A slashed path lies under its slash-less prefix, but a slashed
1104        // prefix only covers itself.
1105        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    // -- Path matching --
1118
1119    #[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        // Doubled slashes produce empty URL segments, which never route.
1143        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        // Matching happens on the raw URL, where `%2F` is an ordinary part of
1151        // a segment, not a separator.
1152        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        // The remainder after `/files/` is `/`, a non-empty capture.
1223        assert!(Path::new("/files/{*rest}").matches("/files//"));
1224    }
1225
1226    #[test]
1227    fn matches_non_origin_form_urls() {
1228        // An asterisk-form request (`OPTIONS *`) matches no route path. An
1229        // empty authority-form path is equivalent to the root URL.
1230        assert!(!Path::new("/").matches("*"));
1231        assert!(!Path::new("/admin").matches("*"));
1232        assert!(Path::new("/").matches(""));
1233        assert!(!Path::new("/admin").matches(""));
1234    }
1235
1236    // -- Path validation --
1237
1238    #[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        // Compiles only because the path is valid; a malformed literal here would
1281        // be a compile-time error from the panic in `new`.
1282        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    // -- PathBuf --
1293
1294    #[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    // -- PathSegment --
1346
1347    #[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}