1use itertools::Itertools;
21use percent_encoding::percent_decode;
22use std::fmt::Formatter;
23#[cfg(not(target_arch = "wasm32"))]
24use url::Url;
25
26pub const DELIMITER: &str = "/";
28
29pub const DELIMITER_BYTE: u8 = DELIMITER.as_bytes()[0];
31
32mod parts;
33
34pub use parts::{InvalidPart, PathPart};
35
36#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum Error {
40 #[error("Path \"{}\" contained empty path segment", path)]
42 EmptySegment {
43 path: String,
45 },
46
47 #[error("Error parsing Path \"{}\": {}", path, source)]
49 BadSegment {
50 path: String,
52 source: InvalidPart,
54 },
55
56 #[error("Failed to canonicalize path \"{}\": {}", path.display(), source)]
58 Canonicalize {
59 path: std::path::PathBuf,
61 source: std::io::Error,
63 },
64
65 #[error("Unable to convert path \"{}\" to URL", path.display())]
67 InvalidPath {
68 path: std::path::PathBuf,
70 },
71
72 #[error("Path \"{}\" contained non-unicode characters: {}", path, source)]
74 NonUnicode {
75 path: String,
77 source: std::str::Utf8Error,
79 },
80
81 #[error("Path {} does not start with prefix {}", path, prefix)]
83 PrefixMismatch {
84 path: String,
86 prefix: String,
88 },
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Ord, PartialOrd)]
154pub struct Path {
155 raw: String,
157}
158
159impl Path {
160 pub fn parse(path: impl AsRef<str>) -> Result<Self, Error> {
165 let path = path.as_ref();
166
167 let stripped = path.strip_prefix(DELIMITER).unwrap_or(path);
168 if stripped.is_empty() {
169 return Ok(Default::default());
170 }
171
172 let stripped = stripped.strip_suffix(DELIMITER).unwrap_or(stripped);
173
174 for segment in stripped.split(DELIMITER) {
175 if segment.is_empty() {
176 return Err(Error::EmptySegment { path: path.into() });
177 }
178
179 PathPart::parse(segment).map_err(|source| {
180 let path = path.into();
181 Error::BadSegment { source, path }
182 })?;
183 }
184
185 Ok(Self {
186 raw: stripped.to_string(),
187 })
188 }
189
190 #[cfg(not(target_arch = "wasm32"))]
191 pub fn from_filesystem_path(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
198 let absolute = std::fs::canonicalize(&path).map_err(|source| {
199 let path = path.as_ref().into();
200 Error::Canonicalize { source, path }
201 })?;
202
203 Self::from_absolute_path(absolute)
204 }
205
206 #[cfg(not(target_arch = "wasm32"))]
207 pub fn from_absolute_path(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
212 Self::from_absolute_path_with_base(path, None)
213 }
214
215 #[cfg(not(target_arch = "wasm32"))]
216 pub(crate) fn from_absolute_path_with_base(
222 path: impl AsRef<std::path::Path>,
223 base: Option<&Url>,
224 ) -> Result<Self, Error> {
225 let url = absolute_path_to_url(path)?;
226 let path = match base {
227 Some(prefix) => {
228 url.path()
229 .strip_prefix(prefix.path())
230 .ok_or_else(|| Error::PrefixMismatch {
231 path: url.path().to_string(),
232 prefix: prefix.to_string(),
233 })?
234 }
235 None => url.path(),
236 };
237
238 Self::from_url_path(path)
240 }
241
242 pub fn from_url_path(path: impl AsRef<str>) -> Result<Self, Error> {
247 let path = path.as_ref();
248 let decoded = percent_decode(path.as_bytes())
249 .decode_utf8()
250 .map_err(|source| {
251 let path = path.into();
252 Error::NonUnicode { source, path }
253 })?;
254
255 Self::parse(decoded)
256 }
257
258 pub fn parts(&self) -> impl Iterator<Item = PathPart<'_>> {
260 self.raw
261 .split_terminator(DELIMITER)
262 .map(|s| PathPart { raw: s.into() })
263 }
264
265 pub fn filename(&self) -> Option<&str> {
267 match self.raw.is_empty() {
268 true => None,
269 false => self.raw.rsplit(DELIMITER).next(),
270 }
271 }
272
273 pub fn extension(&self) -> Option<&str> {
275 self.filename()
276 .and_then(|f| f.rsplit_once('.'))
277 .and_then(|(_, extension)| {
278 if extension.is_empty() {
279 None
280 } else {
281 Some(extension)
282 }
283 })
284 }
285
286 pub fn prefix_match(&self, prefix: &Self) -> Option<impl Iterator<Item = PathPart<'_>> + '_> {
290 let mut stripped = self.raw.strip_prefix(&prefix.raw)?;
291 if !stripped.is_empty() && !prefix.raw.is_empty() {
292 stripped = stripped.strip_prefix(DELIMITER)?;
293 }
294 let iter = stripped
295 .split_terminator(DELIMITER)
296 .map(|x| PathPart { raw: x.into() });
297 Some(iter)
298 }
299
300 pub fn prefix_matches(&self, prefix: &Self) -> bool {
302 self.prefix_match(prefix).is_some()
303 }
304
305 pub fn child<'a>(&self, child: impl Into<PathPart<'a>>) -> Self {
307 let raw = match self.raw.is_empty() {
308 true => format!("{}", child.into().raw),
309 false => format!("{}{}{}", self.raw, DELIMITER, child.into().raw),
310 };
311
312 Self { raw }
313 }
314}
315
316impl AsRef<str> for Path {
317 fn as_ref(&self) -> &str {
318 &self.raw
319 }
320}
321
322impl From<&str> for Path {
323 fn from(path: &str) -> Self {
324 Self::from_iter(path.split(DELIMITER))
325 }
326}
327
328impl From<String> for Path {
329 fn from(path: String) -> Self {
330 Self::from_iter(path.split(DELIMITER))
331 }
332}
333
334impl From<Path> for String {
335 fn from(path: Path) -> Self {
336 path.raw
337 }
338}
339
340impl std::fmt::Display for Path {
341 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
342 self.raw.fmt(f)
343 }
344}
345
346impl<'a, I> FromIterator<I> for Path
347where
348 I: Into<PathPart<'a>>,
349{
350 fn from_iter<T: IntoIterator<Item = I>>(iter: T) -> Self {
351 let raw = T::into_iter(iter)
352 .map(|s| s.into())
353 .filter(|s| !s.raw.is_empty())
354 .map(|s| s.raw)
355 .join(DELIMITER);
356
357 Self { raw }
358 }
359}
360
361#[cfg(not(target_arch = "wasm32"))]
362pub(crate) fn absolute_path_to_url(path: impl AsRef<std::path::Path>) -> Result<Url, Error> {
364 Url::from_file_path(&path).map_err(|_| Error::InvalidPath {
365 path: path.as_ref().into(),
366 })
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn cloud_prefix_with_trailing_delimiter() {
375 let prefix = Path::from_iter(["test"]);
379 assert_eq!(prefix.as_ref(), "test");
380 }
381
382 #[test]
383 fn push_encodes() {
384 let location = Path::from_iter(["foo/bar", "baz%2Ftest"]);
385 assert_eq!(location.as_ref(), "foo%2Fbar/baz%252Ftest");
386 }
387
388 #[test]
389 fn test_parse() {
390 assert_eq!(Path::parse("/").unwrap().as_ref(), "");
391 assert_eq!(Path::parse("").unwrap().as_ref(), "");
392
393 let err = Path::parse("//").unwrap_err();
394 assert!(matches!(err, Error::EmptySegment { .. }));
395
396 assert_eq!(Path::parse("/foo/bar/").unwrap().as_ref(), "foo/bar");
397 assert_eq!(Path::parse("foo/bar/").unwrap().as_ref(), "foo/bar");
398 assert_eq!(Path::parse("foo/bar").unwrap().as_ref(), "foo/bar");
399
400 let err = Path::parse("foo///bar").unwrap_err();
401 assert!(matches!(err, Error::EmptySegment { .. }));
402 }
403
404 #[test]
405 fn convert_raw_before_partial_eq() {
406 let cloud = Path::from("test_dir/test_file.json");
408 let built = Path::from_iter(["test_dir", "test_file.json"]);
409
410 assert_eq!(built, cloud);
411
412 let cloud = Path::from("test_dir/test_file");
414 let built = Path::from_iter(["test_dir", "test_file"]);
415
416 assert_eq!(built, cloud);
417
418 let cloud = Path::from("test_dir/");
420 let built = Path::from_iter(["test_dir"]);
421 assert_eq!(built, cloud);
422
423 let cloud = Path::from("test_file.json");
425 let built = Path::from_iter(["test_file.json"]);
426 assert_eq!(built, cloud);
427
428 let cloud = Path::from("");
430 let built = Path::from_iter(["", ""]);
431
432 assert_eq!(built, cloud);
433 }
434
435 #[test]
436 fn parts_after_prefix_behavior() {
437 let existing_path = Path::from("apple/bear/cow/dog/egg.json");
438
439 let prefix = Path::from("apple");
441 let expected_parts: Vec<PathPart<'_>> = vec!["bear", "cow", "dog", "egg.json"]
442 .into_iter()
443 .map(Into::into)
444 .collect();
445 let parts: Vec<_> = existing_path.prefix_match(&prefix).unwrap().collect();
446 assert_eq!(parts, expected_parts);
447
448 let prefix = Path::from("apple/bear");
450 let expected_parts: Vec<PathPart<'_>> = vec!["cow", "dog", "egg.json"]
451 .into_iter()
452 .map(Into::into)
453 .collect();
454 let parts: Vec<_> = existing_path.prefix_match(&prefix).unwrap().collect();
455 assert_eq!(parts, expected_parts);
456
457 let prefix = Path::from("cow");
459 assert!(existing_path.prefix_match(&prefix).is_none());
460
461 let prefix = Path::from("ap");
463 assert!(existing_path.prefix_match(&prefix).is_none());
464
465 let existing = Path::from("apple/bear/cow/dog");
467
468 assert_eq!(existing.prefix_match(&existing).unwrap().count(), 0);
469 assert_eq!(Path::default().parts().count(), 0);
470 }
471
472 #[test]
473 fn prefix_matches() {
474 let haystack = Path::from_iter(["foo/bar", "baz%2Ftest", "something"]);
475 assert!(
477 haystack.prefix_matches(&haystack),
478 "{haystack:?} should have started with {haystack:?}"
479 );
480
481 let needle = haystack.child("longer now");
483 assert!(
484 !haystack.prefix_matches(&needle),
485 "{haystack:?} shouldn't have started with {needle:?}"
486 );
487
488 let needle = Path::from_iter(["foo/bar"]);
490 assert!(
491 haystack.prefix_matches(&needle),
492 "{haystack:?} should have started with {needle:?}"
493 );
494
495 let needle = needle.child("baz%2Ftest");
497 assert!(
498 haystack.prefix_matches(&needle),
499 "{haystack:?} should have started with {needle:?}"
500 );
501
502 let needle = Path::from_iter(["f"]);
504 assert!(
505 !haystack.prefix_matches(&needle),
506 "{haystack:?} should not have started with {needle:?}"
507 );
508
509 let needle = Path::from_iter(["foo/bar", "baz"]);
511 assert!(
512 !haystack.prefix_matches(&needle),
513 "{haystack:?} should not have started with {needle:?}"
514 );
515
516 let needle = Path::from("");
518 assert!(
519 haystack.prefix_matches(&needle),
520 "{haystack:?} should have started with {needle:?}"
521 );
522 }
523
524 #[test]
525 fn prefix_matches_with_file_name() {
526 let haystack = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "foo.segment"]);
527
528 let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "foo"]);
530
531 assert!(
532 !haystack.prefix_matches(&needle),
533 "{haystack:?} should not have started with {needle:?}"
534 );
535
536 let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "something", "e"]);
538
539 assert!(
540 !haystack.prefix_matches(&needle),
541 "{haystack:?} should not have started with {needle:?}"
542 );
543
544 let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "s"]);
547
548 assert!(
549 !haystack.prefix_matches(&needle),
550 "{haystack:?} should not have started with {needle:?}"
551 );
552
553 let needle = Path::from_iter(["foo/bar", "baz%2Ftest", "p"]);
556
557 assert!(
558 !haystack.prefix_matches(&needle),
559 "{haystack:?} should not have started with {needle:?}"
560 );
561 }
562
563 #[test]
564 fn path_containing_spaces() {
565 let a = Path::from_iter(["foo bar", "baz"]);
566 let b = Path::from("foo bar/baz");
567 let c = Path::parse("foo bar/baz").unwrap();
568
569 assert_eq!(a.raw, "foo bar/baz");
570 assert_eq!(a.raw, b.raw);
571 assert_eq!(b.raw, c.raw);
572 }
573
574 #[test]
575 fn from_url_path() {
576 let a = Path::from_url_path("foo%20bar").unwrap();
577 let b = Path::from_url_path("foo/%2E%2E/bar").unwrap_err();
578 let c = Path::from_url_path("foo%2F%252E%252E%2Fbar").unwrap();
579 let d = Path::from_url_path("foo/%252E%252E/bar").unwrap();
580 let e = Path::from_url_path("%48%45%4C%4C%4F").unwrap();
581 let f = Path::from_url_path("foo/%FF/as").unwrap_err();
582
583 assert_eq!(a.raw, "foo bar");
584 assert!(matches!(b, Error::BadSegment { .. }));
585 assert_eq!(c.raw, "foo/%2E%2E/bar");
586 assert_eq!(d.raw, "foo/%2E%2E/bar");
587 assert_eq!(e.raw, "HELLO");
588 assert!(matches!(f, Error::NonUnicode { .. }));
589 }
590
591 #[test]
592 fn filename_from_path() {
593 let a = Path::from("foo/bar");
594 let b = Path::from("foo/bar.baz");
595 let c = Path::from("foo.bar/baz");
596
597 assert_eq!(a.filename(), Some("bar"));
598 assert_eq!(b.filename(), Some("bar.baz"));
599 assert_eq!(c.filename(), Some("baz"));
600 }
601
602 #[test]
603 fn file_extension() {
604 let a = Path::from("foo/bar");
605 let b = Path::from("foo/bar.baz");
606 let c = Path::from("foo.bar/baz");
607 let d = Path::from("foo.bar/baz.qux");
608
609 assert_eq!(a.extension(), None);
610 assert_eq!(b.extension(), Some("baz"));
611 assert_eq!(c.extension(), None);
612 assert_eq!(d.extension(), Some("qux"));
613 }
614}