Skip to main content

rama_net/uri/
path_mut.rs

1//! RAII guard for incremental path mutation.
2//!
3//! Created by [`Uri::path_mut`](super::Uri::path_mut). Amortises the
4//! Lazy → Owned promotion across multiple operations and supports
5//! the common `push_segment` / `pop_segment` pattern.
6
7use super::component_input::IntoUriComponent;
8use super::encode;
9use super::owned::OwnedUriRef;
10use super::path::{
11    PathMatchOptions, match_prefix_in_body, match_suffix_in_body, segment_range_bounds,
12    trim_ascii_slashes,
13};
14
15use rama_core::bytes::BytesMut;
16
17/// Mutable view of a [`Uri`](super::Uri)'s path component.
18///
19/// Holds the Owned representation of the URI for the guard's lifetime.
20/// Each method modifies the path in place. Drop releases the borrow —
21/// no special finalization required.
22pub struct PathMut<'a> {
23    owned: &'a mut OwnedUriRef,
24}
25
26impl<'a> PathMut<'a> {
27    #[inline]
28    pub(crate) fn new(owned: &'a mut OwnedUriRef) -> Self {
29        Self { owned }
30    }
31
32    /// Append a `/`-delimited segment, percent-encoding any bytes that
33    /// aren't legal in a URI path segment per RFC 3986.
34    ///
35    /// Encodes: ASCII controls, space, `"`, `#`, `%`, `/`, `<`, `>`,
36    /// `?`, `[`, `\`, `]`, `^`, `` ` ``, `{`, `|`, `}`, and every
37    /// non-ASCII byte. Passes through: ALPHA, DIGIT, `-._~`,
38    /// `!$&'()*+,;=`, `:`, `@`. The `%` itself is encoded to `%25` —
39    /// pass already-decoded values, not pre-encoded ones.
40    ///
41    /// If the current path doesn't already end with `/`, one is
42    /// inserted before the segment. Empty path + `push_segment("x")`
43    /// yields `/x`. `/foo` + `push_segment("bar")` yields `/foo/bar`.
44    /// `/foo/` + `push_segment("bar")` yields `/foo/bar` (no double
45    /// slash).
46    #[expect(
47        clippy::needless_pass_by_value,
48        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows because percent_encode can't consume its input"
49    )]
50    pub fn push_segment(&mut self, segment: impl IntoUriComponent) -> &mut Self {
51        if !self.owned.path.ends_with(b"/") {
52            self.owned.path.extend_from_slice(b"/");
53        }
54        encode::extend_encoded_segment(&mut self.owned.path, &segment);
55        self
56    }
57
58    /// Remove and return the last path segment.
59    ///
60    /// `/foo/bar` → `Some("bar")`, path becomes `/foo`. `/foo/` →
61    /// `Some("")`, path becomes `/foo`. `/foo` → `Some("foo")`, path
62    /// becomes empty. Empty path → `None`. Opaque paths (no `/`) → the
63    /// whole path is returned.
64    ///
65    /// The returned bytes are the raw on-wire form (still
66    /// percent-encoded). Use [`PathSegment::as_decoded_str`](super::PathSegment::as_decoded_str)
67    /// on the corresponding [`PathRef::segments`](super::PathRef::segments)
68    /// item before mutation if you need the decoded value.
69    pub fn pop_segment(&mut self) -> Option<BytesMut> {
70        if self.owned.path.is_empty() {
71            return None;
72        }
73        match memchr::memrchr(b'/', &self.owned.path) {
74            Some(i) => {
75                let mut removed = self.owned.path.split_off(i);
76                let _slash = removed.split_to(1);
77                Some(removed)
78            }
79            None => Some(core::mem::take(&mut self.owned.path)),
80        }
81    }
82
83    /// Clear the path entirely.
84    pub fn clear(&mut self) -> &mut Self {
85        self.owned.path.clear();
86        self
87    }
88
89    /// Ensure the path ends with exactly one trailing `/`: appended when
90    /// missing, left alone when already present. An empty path becomes `/`.
91    pub fn ensure_trailing_slash(&mut self) -> &mut Self {
92        if !self.owned.path.ends_with(b"/") {
93            self.owned.path.extend_from_slice(b"/");
94        }
95        self
96    }
97
98    /// Normalize the path by removing trailing `/` characters while keeping a
99    /// single leading `/`. Leading duplicate slashes are collapsed as part of
100    /// the same operation. Returns `true` when the path changed.
101    pub fn trim_trailing_slash(&mut self) -> bool {
102        let path = &self.owned.path;
103        if path.as_ref() == b"/" {
104            return false;
105        }
106        if !path.ends_with(b"/") && !path.starts_with(b"//") {
107            return false;
108        }
109
110        let body = trim_ascii_slashes(path);
111        let mut new = BytesMut::with_capacity(body.len() + 1);
112        new.extend_from_slice(b"/");
113        new.extend_from_slice(body);
114        self.owned.path = new;
115        true
116    }
117
118    /// Normalize the path by ensuring one trailing `/` and collapsing duplicate
119    /// trailing slashes. Returns `true` when the path changed.
120    pub fn append_trailing_slash(&mut self) -> bool {
121        let path = &self.owned.path;
122        if path.ends_with(b"/") && !path.ends_with(b"//") {
123            return false;
124        }
125
126        let body = trim_ascii_slashes(path);
127        let mut new = BytesMut::with_capacity(body.len() + 2);
128        new.extend_from_slice(b"/");
129        new.extend_from_slice(body);
130        if !body.is_empty() {
131            new.extend_from_slice(b"/");
132        }
133        self.owned.path = new;
134        true
135    }
136
137    /// Append multiple `/`-delimited segments at once.
138    ///
139    /// Splits the input on `/` and pushes each piece via
140    /// [`push_segment`](Self::push_segment), so every piece is
141    /// percent-encoded under the path-segment policy (a literal `/`
142    /// inside the input is the separator, not encoded). The normal
143    /// slash-insertion rule applies, so `"a/b"` and `"/a/b"` both append
144    /// `/a/b`, internal `//` collapses to a single separator, and a
145    /// trailing `/` yields a trailing empty segment.
146    ///
147    /// `"/api"` + `push_segments("v2/users")` → `/api/v2/users`.
148    #[expect(
149        clippy::needless_pass_by_value,
150        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows because percent_encode can't consume its input"
151    )]
152    pub fn push_segments(&mut self, segments: impl IntoUriComponent) -> &mut Self {
153        let bytes = segments.as_uri_component_bytes();
154        for piece in bytes.split(|&b| b == b'/') {
155            self.push_segment(piece);
156        }
157        self
158    }
159
160    /// Remove up to `n` trailing segments, returning the number actually
161    /// removed (fewer than `n` if the path runs out first).
162    ///
163    /// Equivalent to calling [`pop_segment`](Self::pop_segment) `n`
164    /// times, stopping early at an empty path.
165    pub fn pop_segments(&mut self, n: usize) -> usize {
166        let mut removed = 0;
167        while removed < n && self.pop_segment().is_some() {
168            removed += 1;
169        }
170        removed
171    }
172
173    /// Strip a leading `prefix` from the path, re-rooting the remainder with
174    /// a single leading `/`. Matching uses the default [`PathMatchOptions`]
175    /// (segment-boundary, percent-decoded, case-sensitive); see
176    /// [`strip_prefix_with_opts`](Self::strip_prefix_with_opts) to allow
177    /// partial / raw / case-insensitive matching.
178    ///
179    /// Returns `true` when the prefix matched and the path changed; `false`
180    /// when it didn't match, or when stripping was a no-op (e.g. an empty
181    /// prefix on an already-rooted path).
182    pub fn strip_prefix(&mut self, prefix: impl IntoUriComponent) -> bool {
183        self.strip_prefix_with_opts(prefix, PathMatchOptions::default())
184    }
185
186    /// Strip the first `count` path segments, re-rooting the remainder with a
187    /// single leading `/`.
188    ///
189    /// Returns `false` when the path has fewer than `count` segments, or when
190    /// stripping didn't change the path (e.g. the sole empty segment of `/`).
191    /// A `count` of `0` only re-roots the current path.
192    pub fn strip_prefix_segments(&mut self, count: usize) -> bool {
193        let new = {
194            let path: &[u8] = &self.owned.path;
195            let rest = if count == 0 {
196                path
197            } else {
198                let Some((_, end)) = segment_range_bounds(path, 0, count) else {
199                    return false;
200                };
201                &path[end..]
202            };
203            let rest = trim_ascii_slashes(rest);
204            let mut new = BytesMut::with_capacity(rest.len() + 1);
205            new.extend_from_slice(b"/");
206            new.extend_from_slice(rest);
207            new
208        };
209        if new == self.owned.path {
210            return false;
211        }
212        self.owned.path = new;
213        true
214    }
215
216    /// [`strip_prefix`](Self::strip_prefix) with explicit [`PathMatchOptions`].
217    #[expect(
218        clippy::needless_pass_by_value,
219        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows the input"
220    )]
221    pub fn strip_prefix_with_opts(
222        &mut self,
223        prefix: impl IntoUriComponent,
224        opts: PathMatchOptions,
225    ) -> bool {
226        let prefix = prefix.as_uri_component_bytes();
227        let new = {
228            let path: &[u8] = &self.owned.path;
229            let body = path.strip_prefix(b"/").unwrap_or(path);
230            let Some(offset) = match_prefix_in_body(body, &prefix, opts) else {
231                return false;
232            };
233            let mut rest = &body[offset..];
234            while let Some(stripped) = rest.strip_prefix(b"/") {
235                rest = stripped;
236            }
237            let mut new = BytesMut::with_capacity(rest.len() + 1);
238            new.extend_from_slice(b"/");
239            new.extend_from_slice(rest);
240            new
241        };
242        if new == self.owned.path {
243            return false;
244        }
245        self.owned.path = new;
246        true
247    }
248
249    /// Strip a trailing `suffix` from the path, keeping a single leading `/`.
250    /// Matching uses the default [`PathMatchOptions`]; see
251    /// [`strip_suffix_with_opts`](Self::strip_suffix_with_opts) for the rest.
252    ///
253    /// Returns `true` when the suffix matched and the path changed; `false`
254    /// when it didn't match, or when stripping was a no-op (e.g. an empty
255    /// suffix on an already-rooted path).
256    pub fn strip_suffix(&mut self, suffix: impl IntoUriComponent) -> bool {
257        self.strip_suffix_with_opts(suffix, PathMatchOptions::default())
258    }
259
260    /// [`strip_suffix`](Self::strip_suffix) with explicit [`PathMatchOptions`].
261    #[expect(
262        clippy::needless_pass_by_value,
263        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows the input"
264    )]
265    pub fn strip_suffix_with_opts(
266        &mut self,
267        suffix: impl IntoUriComponent,
268        opts: PathMatchOptions,
269    ) -> bool {
270        let suffix = suffix.as_uri_component_bytes();
271        let new = {
272            let path: &[u8] = &self.owned.path;
273            let body = path.strip_prefix(b"/").unwrap_or(path);
274            let Some(keep) = match_suffix_in_body(body, &suffix, opts) else {
275                return false;
276            };
277            let kept = &body[..keep];
278            let mut new = BytesMut::with_capacity(kept.len() + 1);
279            new.extend_from_slice(b"/");
280            new.extend_from_slice(kept);
281            new
282        };
283        if new == self.owned.path {
284            return false;
285        }
286        self.owned.path = new;
287        true
288    }
289}
290
291impl core::fmt::Debug for PathMut<'_> {
292    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
293        // Safety: parser invariant — path bytes are valid UTF-8.
294        let path = unsafe { core::str::from_utf8_unchecked(&self.owned.path) };
295        f.debug_struct("PathMut").field("path", &path).finish()
296    }
297}