bevy_asset/
path.rs

1use crate::io::AssetSourceId;
2use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize};
3use bevy_utils::CowArc;
4use serde::{de::Visitor, Deserialize, Serialize};
5use std::{
6    fmt::{Debug, Display},
7    hash::Hash,
8    ops::Deref,
9    path::{Path, PathBuf},
10};
11use thiserror::Error;
12
13/// Represents a path to an asset in a "virtual filesystem".
14///
15/// Asset paths consist of three main parts:
16/// * [`AssetPath::source`]: The name of the [`AssetSource`](crate::io::AssetSource) to load the asset from.
17///     This is optional. If one is not set the default source will be used (which is the `assets` folder by default).
18/// * [`AssetPath::path`]: The "virtual filesystem path" pointing to an asset source file.
19/// * [`AssetPath::label`]: An optional "named sub asset". When assets are loaded, they are
20/// allowed to load "sub assets" of any type, which are identified by a named "label".
21///
22/// Asset paths are generally constructed (and visualized) as strings:
23///
24/// ```no_run
25/// # use bevy_asset::{Asset, AssetServer, Handle};
26/// # use bevy_reflect::TypePath;
27/// #
28/// # #[derive(Asset, TypePath, Default)]
29/// # struct Mesh;
30/// #
31/// # #[derive(Asset, TypePath, Default)]
32/// # struct Scene;
33/// #
34/// # let asset_server: AssetServer = panic!();
35/// // This loads the `my_scene.scn` base asset from the default asset source.
36/// let scene: Handle<Scene> = asset_server.load("my_scene.scn");
37///
38/// // This loads the `PlayerMesh` labeled asset from the `my_scene.scn` base asset in the default asset source.
39/// let mesh: Handle<Mesh> = asset_server.load("my_scene.scn#PlayerMesh");
40///
41/// // This loads the `my_scene.scn` base asset from a custom 'remote' asset source.
42/// let scene: Handle<Scene> = asset_server.load("remote://my_scene.scn");
43/// ```
44///
45/// [`AssetPath`] implements [`From`] for `&'static str`, `&'static Path`, and `&'a String`,
46/// which allows us to optimize the static cases.
47/// This means that the common case of `asset_server.load("my_scene.scn")` when it creates and
48/// clones internal owned [`AssetPaths`](AssetPath).
49/// This also means that you should use [`AssetPath::parse`] in cases where `&str` is the explicit type.
50#[derive(Eq, PartialEq, Hash, Clone, Default, Reflect)]
51#[reflect_value(Debug, PartialEq, Hash, Serialize, Deserialize)]
52pub struct AssetPath<'a> {
53    source: AssetSourceId<'a>,
54    path: CowArc<'a, Path>,
55    label: Option<CowArc<'a, str>>,
56}
57
58impl<'a> Debug for AssetPath<'a> {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        Display::fmt(self, f)
61    }
62}
63
64impl<'a> Display for AssetPath<'a> {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        if let AssetSourceId::Name(name) = self.source() {
67            write!(f, "{name}://")?;
68        }
69        write!(f, "{}", self.path.display())?;
70        if let Some(label) = &self.label {
71            write!(f, "#{label}")?;
72        }
73        Ok(())
74    }
75}
76
77/// An error that occurs when parsing a string type to create an [`AssetPath`] fails, such as during [`AssetPath::parse`] or [`AssetPath::from<'static str>`].
78#[derive(Error, Debug, PartialEq, Eq)]
79pub enum ParseAssetPathError {
80    /// Error that occurs when the [`AssetPath::source`] section of a path string contains the [`AssetPath::label`] delimiter `#`. E.g. `bad#source://file.test`.
81    #[error("Asset source must not contain a `#` character")]
82    InvalidSourceSyntax,
83    /// Error that occurs when the [`AssetPath::label`] section of a path string contains the [`AssetPath::source`] delimiter `://`. E.g. `source://file.test#bad://label`.
84    #[error("Asset label must not contain a `://` substring")]
85    InvalidLabelSyntax,
86    /// Error that occurs when a path string has an [`AssetPath::source`] delimiter `://` with no characters preceding it. E.g. `://file.test`.
87    #[error("Asset source must be at least one character. Either specify the source before the '://' or remove the `://`")]
88    MissingSource,
89    /// Error that occurs when a path string has an [`AssetPath::label`] delimiter `#` with no characters succeeding it. E.g. `file.test#`
90    #[error("Asset label must be at least one character. Either specify the label after the '#' or remove the '#'")]
91    MissingLabel,
92}
93
94impl<'a> AssetPath<'a> {
95    /// Creates a new [`AssetPath`] from a string in the asset path format:
96    /// * An asset at the root: `"scene.gltf"`
97    /// * An asset nested in some folders: `"some/path/scene.gltf"`
98    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
99    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
100    ///
101    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
102    /// and reference counting for [`AssetPath::into_owned`].
103    ///
104    /// # Panics
105    /// Panics if the asset path is in an invalid format. Use [`AssetPath::try_parse`] for a fallible variant
106    pub fn parse(asset_path: &'a str) -> AssetPath<'a> {
107        Self::try_parse(asset_path).unwrap()
108    }
109
110    /// Creates a new [`AssetPath`] from a string in the asset path format:
111    /// * An asset at the root: `"scene.gltf"`
112    /// * An asset nested in some folders: `"some/path/scene.gltf"`
113    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
114    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
115    ///
116    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
117    /// and reference counting for [`AssetPath::into_owned`].
118    ///
119    /// This will return a [`ParseAssetPathError`] if `asset_path` is in an invalid format.
120    pub fn try_parse(asset_path: &'a str) -> Result<AssetPath<'a>, ParseAssetPathError> {
121        let (source, path, label) = Self::parse_internal(asset_path)?;
122        Ok(Self {
123            source: match source {
124                Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)),
125                None => AssetSourceId::Default,
126            },
127            path: CowArc::Borrowed(path),
128            label: label.map(CowArc::Borrowed),
129        })
130    }
131
132    // Attempts to Parse a &str into an `AssetPath`'s `AssetPath::source`, `AssetPath::path`, and `AssetPath::label` components.
133    fn parse_internal(
134        asset_path: &str,
135    ) -> Result<(Option<&str>, &Path, Option<&str>), ParseAssetPathError> {
136        let chars = asset_path.char_indices();
137        let mut source_range = None;
138        let mut path_range = 0..asset_path.len();
139        let mut label_range = None;
140
141        // Loop through the characters of the passed in &str to accomplish the following:
142        // 1. Search for the first instance of the `://` substring. If the `://` substring is found,
143        //  store the range of indices representing everything before the `://` substring as the `source_range`.
144        // 2. Search for the last instance of the `#` character. If the `#` character is found,
145        //  store the range of indices representing everything after the `#` character as the `label_range`
146        // 3. Set the `path_range` to be everything in between the `source_range` and `label_range`,
147        //  excluding the `://` substring and `#` character.
148        // 4. Verify that there are no `#` characters in the `AssetPath::source` and no `://` substrings in the `AssetPath::label`
149        let mut source_delimiter_chars_matched = 0;
150        let mut last_found_source_index = 0;
151        for (index, char) in chars {
152            match char {
153                ':' => {
154                    source_delimiter_chars_matched = 1;
155                }
156                '/' => {
157                    match source_delimiter_chars_matched {
158                        1 => {
159                            source_delimiter_chars_matched = 2;
160                        }
161                        2 => {
162                            // If we haven't found our first `AssetPath::source` yet, check to make sure it is valid and then store it.
163                            if source_range.is_none() {
164                                // If the `AssetPath::source` contains a `#` character, it is invalid.
165                                if label_range.is_some() {
166                                    return Err(ParseAssetPathError::InvalidSourceSyntax);
167                                }
168                                source_range = Some(0..index - 2);
169                                path_range.start = index + 1;
170                            }
171                            last_found_source_index = index - 2;
172                            source_delimiter_chars_matched = 0;
173                        }
174                        _ => {}
175                    }
176                }
177                '#' => {
178                    path_range.end = index;
179                    label_range = Some(index + 1..asset_path.len());
180                    source_delimiter_chars_matched = 0;
181                }
182                _ => {
183                    source_delimiter_chars_matched = 0;
184                }
185            }
186        }
187        // If we found an `AssetPath::label`
188        if let Some(range) = label_range.clone() {
189            // If the `AssetPath::label` contained a `://` substring, it is invalid.
190            if range.start <= last_found_source_index {
191                return Err(ParseAssetPathError::InvalidLabelSyntax);
192            }
193        }
194        // Try to parse the range of indices that represents the `AssetPath::source` portion of the `AssetPath` to make sure it is not empty.
195        // This would be the case if the input &str was something like `://some/file.test`
196        let source = match source_range {
197            Some(source_range) => {
198                if source_range.is_empty() {
199                    return Err(ParseAssetPathError::MissingSource);
200                }
201                Some(&asset_path[source_range])
202            }
203            None => None,
204        };
205        // Try to parse the range of indices that represents the `AssetPath::label` portion of the `AssetPath` to make sure it is not empty.
206        // This would be the case if the input &str was something like `some/file.test#`.
207        let label = match label_range {
208            Some(label_range) => {
209                if label_range.is_empty() {
210                    return Err(ParseAssetPathError::MissingLabel);
211                }
212                Some(&asset_path[label_range])
213            }
214            None => None,
215        };
216
217        let path = Path::new(&asset_path[path_range]);
218        Ok((source, path, label))
219    }
220
221    /// Creates a new [`AssetPath`] from a [`Path`].
222    #[inline]
223    pub fn from_path(path: &'a Path) -> AssetPath<'a> {
224        AssetPath {
225            path: CowArc::Borrowed(path),
226            source: AssetSourceId::Default,
227            label: None,
228        }
229    }
230
231    /// Gets the "asset source", if one was defined. If none was defined, the default source
232    /// will be used.
233    #[inline]
234    pub fn source(&self) -> &AssetSourceId {
235        &self.source
236    }
237
238    /// Gets the "sub-asset label".
239    #[inline]
240    pub fn label(&self) -> Option<&str> {
241        self.label.as_deref()
242    }
243
244    /// Gets the "sub-asset label".
245    #[inline]
246    pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
247        self.label.clone()
248    }
249
250    /// Gets the path to the asset in the "virtual filesystem".
251    #[inline]
252    pub fn path(&self) -> &Path {
253        self.path.deref()
254    }
255
256    /// Gets the path to the asset in the "virtual filesystem" without a label (if a label is currently set).
257    #[inline]
258    pub fn without_label(&self) -> AssetPath<'_> {
259        Self {
260            source: self.source.clone(),
261            path: self.path.clone(),
262            label: None,
263        }
264    }
265
266    /// Removes a "sub-asset label" from this [`AssetPath`], if one was set.
267    #[inline]
268    pub fn remove_label(&mut self) {
269        self.label = None;
270    }
271
272    /// Takes the "sub-asset label" from this [`AssetPath`], if one was set.
273    #[inline]
274    pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
275        self.label.take()
276    }
277
278    /// Returns this asset path with the given label. This will replace the previous
279    /// label if it exists.
280    #[inline]
281    pub fn with_label(self, label: impl Into<CowArc<'a, str>>) -> AssetPath<'a> {
282        AssetPath {
283            source: self.source,
284            path: self.path,
285            label: Some(label.into()),
286        }
287    }
288
289    /// Returns this asset path with the given asset source. This will replace the previous asset
290    /// source if it exists.
291    #[inline]
292    pub fn with_source(self, source: impl Into<AssetSourceId<'a>>) -> AssetPath<'a> {
293        AssetPath {
294            source: source.into(),
295            path: self.path,
296            label: self.label,
297        }
298    }
299
300    /// Returns an [`AssetPath`] for the parent folder of this path, if there is a parent folder in the path.
301    pub fn parent(&self) -> Option<AssetPath<'a>> {
302        let path = match &self.path {
303            CowArc::Borrowed(path) => CowArc::Borrowed(path.parent()?),
304            CowArc::Static(path) => CowArc::Static(path.parent()?),
305            CowArc::Owned(path) => path.parent()?.to_path_buf().into(),
306        };
307        Some(AssetPath {
308            source: self.source.clone(),
309            label: None,
310            path,
311        })
312    }
313
314    /// Converts this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
315    /// If internally a value is a static reference, the static reference will be used unchanged.
316    /// If internally a value is an "owned [`Arc`]", it will remain unchanged.
317    ///
318    /// [`Arc`]: std::sync::Arc
319    pub fn into_owned(self) -> AssetPath<'static> {
320        AssetPath {
321            source: self.source.into_owned(),
322            path: self.path.into_owned(),
323            label: self.label.map(|l| l.into_owned()),
324        }
325    }
326
327    /// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
328    /// If internally a value is a static reference, the static reference will be used unchanged.
329    /// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned.
330    ///
331    /// [`Arc`]: std::sync::Arc
332    #[inline]
333    pub fn clone_owned(&self) -> AssetPath<'static> {
334        self.clone().into_owned()
335    }
336
337    /// Resolves a relative asset path via concatenation. The result will be an `AssetPath` which
338    /// is resolved relative to this "base" path.
339    ///
340    /// ```
341    /// # use bevy_asset::AssetPath;
342    /// assert_eq!(AssetPath::parse("a/b").resolve("c"), Ok(AssetPath::parse("a/b/c")));
343    /// assert_eq!(AssetPath::parse("a/b").resolve("./c"), Ok(AssetPath::parse("a/b/c")));
344    /// assert_eq!(AssetPath::parse("a/b").resolve("../c"), Ok(AssetPath::parse("a/c")));
345    /// assert_eq!(AssetPath::parse("a/b").resolve("c.png"), Ok(AssetPath::parse("a/b/c.png")));
346    /// assert_eq!(AssetPath::parse("a/b").resolve("/c"), Ok(AssetPath::parse("c")));
347    /// assert_eq!(AssetPath::parse("a/b.png").resolve("#c"), Ok(AssetPath::parse("a/b.png#c")));
348    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve("#d"), Ok(AssetPath::parse("a/b.png#d")));
349    /// ```
350    ///
351    /// There are several cases:
352    ///
353    /// If the `path` argument begins with `#`, then it is considered an asset label, in which case
354    /// the result is the base path with the label portion replaced.
355    ///
356    /// If the path argument begins with '/', then it is considered a 'full' path, in which
357    /// case the result is a new `AssetPath` consisting of the base path asset source
358    /// (if there is one) with the path and label portions of the relative path. Note that a 'full'
359    /// asset path is still relative to the asset source root, and not necessarily an absolute
360    /// filesystem path.
361    ///
362    /// If the `path` argument begins with an asset source (ex: `http://`) then the entire base
363    /// path is replaced - the result is the source, path and label (if any) of the `path`
364    /// argument.
365    ///
366    /// Otherwise, the `path` argument is considered a relative path. The result is concatenated
367    /// using the following algorithm:
368    ///
369    /// * The base path and the `path` argument are concatenated.
370    /// * Path elements consisting of "/." or "&lt;name&gt;/.." are removed.
371    ///
372    /// If there are insufficient segments in the base path to match the ".." segments,
373    /// then any left-over ".." segments are left as-is.
374    pub fn resolve(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
375        self.resolve_internal(path, false)
376    }
377
378    /// Resolves an embedded asset path via concatenation. The result will be an `AssetPath` which
379    /// is resolved relative to this path. This is similar in operation to `resolve`, except that
380    /// the 'file' portion of the base path (that is, any characters after the last '/')
381    /// is removed before concatenation, in accordance with the behavior specified in
382    /// IETF RFC 1808 "Relative URIs".
383    ///
384    /// The reason for this behavior is that embedded URIs which start with "./" or "../" are
385    /// relative to the *directory* containing the asset, not the asset file. This is consistent
386    /// with the behavior of URIs in `JavaScript`, CSS, HTML and other web file formats. The
387    /// primary use case for this method is resolving relative paths embedded within asset files,
388    /// which are relative to the asset in which they are contained.
389    ///
390    /// ```
391    /// # use bevy_asset::AssetPath;
392    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c"), Ok(AssetPath::parse("a/c")));
393    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("./c"), Ok(AssetPath::parse("a/c")));
394    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("../c"), Ok(AssetPath::parse("c")));
395    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("c.png"), Ok(AssetPath::parse("a/c.png")));
396    /// assert_eq!(AssetPath::parse("a/b").resolve_embed("/c"), Ok(AssetPath::parse("c")));
397    /// assert_eq!(AssetPath::parse("a/b.png").resolve_embed("#c"), Ok(AssetPath::parse("a/b.png#c")));
398    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve_embed("#d"), Ok(AssetPath::parse("a/b.png#d")));
399    /// ```
400    pub fn resolve_embed(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
401        self.resolve_internal(path, true)
402    }
403
404    fn resolve_internal(
405        &self,
406        path: &str,
407        replace: bool,
408    ) -> Result<AssetPath<'static>, ParseAssetPathError> {
409        if let Some(label) = path.strip_prefix('#') {
410            // It's a label only
411            Ok(self.clone_owned().with_label(label.to_owned()))
412        } else {
413            let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
414            let mut base_path = PathBuf::from(self.path());
415            if replace && !self.path.to_str().unwrap().ends_with('/') {
416                // No error if base is empty (per RFC 1808).
417                base_path.pop();
418            }
419
420            // Strip off leading slash
421            let mut is_absolute = false;
422            let rpath = match rpath.strip_prefix("/") {
423                Ok(p) => {
424                    is_absolute = true;
425                    p
426                }
427                _ => rpath,
428            };
429
430            let mut result_path = if !is_absolute && source.is_none() {
431                base_path
432            } else {
433                PathBuf::new()
434            };
435            result_path.push(rpath);
436            result_path = normalize_path(result_path.as_path());
437
438            Ok(AssetPath {
439                source: match source {
440                    Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
441                    None => self.source.clone_owned(),
442                },
443                path: CowArc::Owned(result_path.into()),
444                label: rlabel.map(|l| CowArc::Owned(l.into())),
445            })
446        }
447    }
448
449    /// Returns the full extension (including multiple '.' values).
450    /// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
451    ///
452    /// Also strips out anything following a `?` to handle query parameters in URIs
453    pub fn get_full_extension(&self) -> Option<String> {
454        let file_name = self.path().file_name()?.to_str()?;
455        let index = file_name.find('.')?;
456        let mut extension = file_name[index + 1..].to_lowercase();
457
458        // Strip off any query parameters
459        let query = extension.find('?');
460        if let Some(offset) = query {
461            extension.truncate(offset);
462        }
463
464        Some(extension)
465    }
466
467    pub(crate) fn iter_secondary_extensions(full_extension: &str) -> impl Iterator<Item = &str> {
468        full_extension.chars().enumerate().filter_map(|(i, c)| {
469            if c == '.' {
470                Some(&full_extension[i + 1..])
471            } else {
472                None
473            }
474        })
475    }
476}
477
478impl From<&'static str> for AssetPath<'static> {
479    #[inline]
480    fn from(asset_path: &'static str) -> Self {
481        let (source, path, label) = Self::parse_internal(asset_path).unwrap();
482        AssetPath {
483            source: source.into(),
484            path: CowArc::Static(path),
485            label: label.map(CowArc::Static),
486        }
487    }
488}
489
490impl<'a> From<&'a String> for AssetPath<'a> {
491    #[inline]
492    fn from(asset_path: &'a String) -> Self {
493        AssetPath::parse(asset_path.as_str())
494    }
495}
496
497impl From<String> for AssetPath<'static> {
498    #[inline]
499    fn from(asset_path: String) -> Self {
500        AssetPath::parse(asset_path.as_str()).into_owned()
501    }
502}
503
504impl From<&'static Path> for AssetPath<'static> {
505    #[inline]
506    fn from(path: &'static Path) -> Self {
507        Self {
508            source: AssetSourceId::Default,
509            path: CowArc::Static(path),
510            label: None,
511        }
512    }
513}
514
515impl From<PathBuf> for AssetPath<'static> {
516    #[inline]
517    fn from(path: PathBuf) -> Self {
518        Self {
519            source: AssetSourceId::Default,
520            path: path.into(),
521            label: None,
522        }
523    }
524}
525
526impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
527    fn from(value: &'a AssetPath<'b>) -> Self {
528        value.clone()
529    }
530}
531
532impl<'a> From<AssetPath<'a>> for PathBuf {
533    fn from(value: AssetPath<'a>) -> Self {
534        value.path().to_path_buf()
535    }
536}
537
538impl<'a> Serialize for AssetPath<'a> {
539    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
540    where
541        S: serde::Serializer,
542    {
543        self.to_string().serialize(serializer)
544    }
545}
546
547impl<'de> Deserialize<'de> for AssetPath<'static> {
548    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549    where
550        D: serde::Deserializer<'de>,
551    {
552        deserializer.deserialize_string(AssetPathVisitor)
553    }
554}
555
556struct AssetPathVisitor;
557
558impl<'de> Visitor<'de> for AssetPathVisitor {
559    type Value = AssetPath<'static>;
560
561    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
562        formatter.write_str("string AssetPath")
563    }
564
565    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
566    where
567        E: serde::de::Error,
568    {
569        Ok(AssetPath::parse(v).into_owned())
570    }
571
572    fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
573    where
574        E: serde::de::Error,
575    {
576        Ok(AssetPath::from(v))
577    }
578}
579
580/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
581/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
582pub(crate) fn normalize_path(path: &Path) -> PathBuf {
583    let mut result_path = PathBuf::new();
584    for elt in path.iter() {
585        if elt == "." {
586            // Skip
587        } else if elt == ".." {
588            if !result_path.pop() {
589                // Preserve ".." if insufficient matches (per RFC 1808).
590                result_path.push(elt);
591            }
592        } else {
593            result_path.push(elt);
594        }
595    }
596    result_path
597}
598
599#[cfg(test)]
600mod tests {
601    use crate::AssetPath;
602    use std::path::Path;
603
604    #[test]
605    fn parse_asset_path() {
606        let result = AssetPath::parse_internal("a/b.test");
607        assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
608
609        let result = AssetPath::parse_internal("http://a/b.test");
610        assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
611
612        let result = AssetPath::parse_internal("http://a/b.test#Foo");
613        assert_eq!(
614            result,
615            Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
616        );
617
618        let result = AssetPath::parse_internal("localhost:80/b.test");
619        assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
620
621        let result = AssetPath::parse_internal("http://localhost:80/b.test");
622        assert_eq!(
623            result,
624            Ok((Some("http"), Path::new("localhost:80/b.test"), None))
625        );
626
627        let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
628        assert_eq!(
629            result,
630            Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
631        );
632
633        let result = AssetPath::parse_internal("#insource://a/b.test");
634        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
635
636        let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
637        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
638
639        let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
640        assert!(
641            result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
642                || result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
643        );
644
645        let result = AssetPath::parse_internal("http://");
646        assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
647
648        let result = AssetPath::parse_internal("://x");
649        assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
650
651        let result = AssetPath::parse_internal("a/b.test#");
652        assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
653    }
654
655    #[test]
656    fn test_parent() {
657        // Parent consumes path segments, returns None when insufficient
658        let result = AssetPath::from("a/b.test");
659        assert_eq!(result.parent(), Some(AssetPath::from("a")));
660        assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
661        assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
662
663        // Parent cannot consume asset source
664        let result = AssetPath::from("http://a");
665        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
666        assert_eq!(result.parent().unwrap().parent(), None);
667
668        // Parent consumes labels
669        let result = AssetPath::from("http://a#Foo");
670        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
671    }
672
673    #[test]
674    fn test_with_source() {
675        let result = AssetPath::from("http://a#Foo");
676        assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
677    }
678
679    #[test]
680    fn test_without_label() {
681        let result = AssetPath::from("http://a#Foo");
682        assert_eq!(result.without_label(), AssetPath::from("http://a"));
683    }
684
685    #[test]
686    fn test_resolve_full() {
687        // A "full" path should ignore the base path.
688        let base = AssetPath::from("alice/bob#carol");
689        assert_eq!(
690            base.resolve("/joe/next").unwrap(),
691            AssetPath::from("joe/next")
692        );
693        assert_eq!(
694            base.resolve_embed("/joe/next").unwrap(),
695            AssetPath::from("joe/next")
696        );
697        assert_eq!(
698            base.resolve("/joe/next#dave").unwrap(),
699            AssetPath::from("joe/next#dave")
700        );
701        assert_eq!(
702            base.resolve_embed("/joe/next#dave").unwrap(),
703            AssetPath::from("joe/next#dave")
704        );
705    }
706
707    #[test]
708    fn test_resolve_implicit_relative() {
709        // A path with no initial directory separator should be considered relative.
710        let base = AssetPath::from("alice/bob#carol");
711        assert_eq!(
712            base.resolve("joe/next").unwrap(),
713            AssetPath::from("alice/bob/joe/next")
714        );
715        assert_eq!(
716            base.resolve_embed("joe/next").unwrap(),
717            AssetPath::from("alice/joe/next")
718        );
719        assert_eq!(
720            base.resolve("joe/next#dave").unwrap(),
721            AssetPath::from("alice/bob/joe/next#dave")
722        );
723        assert_eq!(
724            base.resolve_embed("joe/next#dave").unwrap(),
725            AssetPath::from("alice/joe/next#dave")
726        );
727    }
728
729    #[test]
730    fn test_resolve_explicit_relative() {
731        // A path which begins with "./" or "../" is treated as relative
732        let base = AssetPath::from("alice/bob#carol");
733        assert_eq!(
734            base.resolve("./martin#dave").unwrap(),
735            AssetPath::from("alice/bob/martin#dave")
736        );
737        assert_eq!(
738            base.resolve_embed("./martin#dave").unwrap(),
739            AssetPath::from("alice/martin#dave")
740        );
741        assert_eq!(
742            base.resolve("../martin#dave").unwrap(),
743            AssetPath::from("alice/martin#dave")
744        );
745        assert_eq!(
746            base.resolve_embed("../martin#dave").unwrap(),
747            AssetPath::from("martin#dave")
748        );
749    }
750
751    #[test]
752    fn test_resolve_trailing_slash() {
753        // A path which begins with "./" or "../" is treated as relative
754        let base = AssetPath::from("alice/bob/");
755        assert_eq!(
756            base.resolve("./martin#dave").unwrap(),
757            AssetPath::from("alice/bob/martin#dave")
758        );
759        assert_eq!(
760            base.resolve_embed("./martin#dave").unwrap(),
761            AssetPath::from("alice/bob/martin#dave")
762        );
763        assert_eq!(
764            base.resolve("../martin#dave").unwrap(),
765            AssetPath::from("alice/martin#dave")
766        );
767        assert_eq!(
768            base.resolve_embed("../martin#dave").unwrap(),
769            AssetPath::from("alice/martin#dave")
770        );
771    }
772
773    #[test]
774    fn test_resolve_canonicalize() {
775        // Test that ".." and "." are removed after concatenation.
776        let base = AssetPath::from("alice/bob#carol");
777        assert_eq!(
778            base.resolve("./martin/stephan/..#dave").unwrap(),
779            AssetPath::from("alice/bob/martin#dave")
780        );
781        assert_eq!(
782            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
783            AssetPath::from("alice/martin#dave")
784        );
785        assert_eq!(
786            base.resolve("../martin/.#dave").unwrap(),
787            AssetPath::from("alice/martin#dave")
788        );
789        assert_eq!(
790            base.resolve_embed("../martin/.#dave").unwrap(),
791            AssetPath::from("martin#dave")
792        );
793        assert_eq!(
794            base.resolve("/martin/stephan/..#dave").unwrap(),
795            AssetPath::from("martin#dave")
796        );
797        assert_eq!(
798            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
799            AssetPath::from("martin#dave")
800        );
801    }
802
803    #[test]
804    fn test_resolve_canonicalize_base() {
805        // Test that ".." and "." are removed after concatenation even from the base path.
806        let base = AssetPath::from("alice/../bob#carol");
807        assert_eq!(
808            base.resolve("./martin/stephan/..#dave").unwrap(),
809            AssetPath::from("bob/martin#dave")
810        );
811        assert_eq!(
812            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
813            AssetPath::from("martin#dave")
814        );
815        assert_eq!(
816            base.resolve("../martin/.#dave").unwrap(),
817            AssetPath::from("martin#dave")
818        );
819        assert_eq!(
820            base.resolve_embed("../martin/.#dave").unwrap(),
821            AssetPath::from("../martin#dave")
822        );
823        assert_eq!(
824            base.resolve("/martin/stephan/..#dave").unwrap(),
825            AssetPath::from("martin#dave")
826        );
827        assert_eq!(
828            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
829            AssetPath::from("martin#dave")
830        );
831    }
832
833    #[test]
834    fn test_resolve_canonicalize_with_source() {
835        // Test that ".." and "." are removed after concatenation.
836        let base = AssetPath::from("source://alice/bob#carol");
837        assert_eq!(
838            base.resolve("./martin/stephan/..#dave").unwrap(),
839            AssetPath::from("source://alice/bob/martin#dave")
840        );
841        assert_eq!(
842            base.resolve_embed("./martin/stephan/..#dave").unwrap(),
843            AssetPath::from("source://alice/martin#dave")
844        );
845        assert_eq!(
846            base.resolve("../martin/.#dave").unwrap(),
847            AssetPath::from("source://alice/martin#dave")
848        );
849        assert_eq!(
850            base.resolve_embed("../martin/.#dave").unwrap(),
851            AssetPath::from("source://martin#dave")
852        );
853        assert_eq!(
854            base.resolve("/martin/stephan/..#dave").unwrap(),
855            AssetPath::from("source://martin#dave")
856        );
857        assert_eq!(
858            base.resolve_embed("/martin/stephan/..#dave").unwrap(),
859            AssetPath::from("source://martin#dave")
860        );
861    }
862
863    #[test]
864    fn test_resolve_absolute() {
865        // Paths beginning with '/' replace the base path
866        let base = AssetPath::from("alice/bob#carol");
867        assert_eq!(
868            base.resolve("/martin/stephan").unwrap(),
869            AssetPath::from("martin/stephan")
870        );
871        assert_eq!(
872            base.resolve_embed("/martin/stephan").unwrap(),
873            AssetPath::from("martin/stephan")
874        );
875        assert_eq!(
876            base.resolve("/martin/stephan#dave").unwrap(),
877            AssetPath::from("martin/stephan/#dave")
878        );
879        assert_eq!(
880            base.resolve_embed("/martin/stephan#dave").unwrap(),
881            AssetPath::from("martin/stephan/#dave")
882        );
883    }
884
885    #[test]
886    fn test_resolve_asset_source() {
887        // Paths beginning with 'source://' replace the base path
888        let base = AssetPath::from("alice/bob#carol");
889        assert_eq!(
890            base.resolve("source://martin/stephan").unwrap(),
891            AssetPath::from("source://martin/stephan")
892        );
893        assert_eq!(
894            base.resolve_embed("source://martin/stephan").unwrap(),
895            AssetPath::from("source://martin/stephan")
896        );
897        assert_eq!(
898            base.resolve("source://martin/stephan#dave").unwrap(),
899            AssetPath::from("source://martin/stephan/#dave")
900        );
901        assert_eq!(
902            base.resolve_embed("source://martin/stephan#dave").unwrap(),
903            AssetPath::from("source://martin/stephan/#dave")
904        );
905    }
906
907    #[test]
908    fn test_resolve_label() {
909        // A relative path with only a label should replace the label portion
910        let base = AssetPath::from("alice/bob#carol");
911        assert_eq!(
912            base.resolve("#dave").unwrap(),
913            AssetPath::from("alice/bob#dave")
914        );
915        assert_eq!(
916            base.resolve_embed("#dave").unwrap(),
917            AssetPath::from("alice/bob#dave")
918        );
919    }
920
921    #[test]
922    fn test_resolve_insufficient_elements() {
923        // Ensure that ".." segments are preserved if there are insufficient elements to remove them.
924        let base = AssetPath::from("alice/bob#carol");
925        assert_eq!(
926            base.resolve("../../joe/next").unwrap(),
927            AssetPath::from("joe/next")
928        );
929        assert_eq!(
930            base.resolve_embed("../../joe/next").unwrap(),
931            AssetPath::from("../joe/next")
932        );
933    }
934
935    #[test]
936    fn test_get_extension() {
937        let result = AssetPath::from("http://a.tar.gz#Foo");
938        assert_eq!(result.get_full_extension(), Some("tar.gz".to_string()));
939
940        let result = AssetPath::from("http://a#Foo");
941        assert_eq!(result.get_full_extension(), None);
942
943        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
944        assert_eq!(result.get_full_extension(), Some("tar.bz2".to_string()));
945    }
946}