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}