Skip to main content

bevy_asset/
path.rs

1use crate::io::AssetSourceId;
2use alloc::{
3    borrow::ToOwned,
4    string::{String, ToString},
5};
6use atomicow::CowArc;
7use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize};
8use core::{
9    fmt::{Debug, Display},
10    hash::Hash,
11    ops::Deref,
12};
13use serde::{de::Visitor, Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17/// Represents a path to an asset in a "virtual filesystem".
18///
19/// Asset paths consist of three main parts:
20/// * [`AssetPath::source`]: The name of the [`AssetSource`](crate::io::AssetSource) to load the asset from.
21///   This is optional. If one is not set the default source will be used (which is the `assets` folder by default).
22/// * [`AssetPath::path`]: The "virtual filesystem path" pointing to an asset source file.
23/// * [`AssetPath::label`]: An optional "named sub asset". When assets are loaded, they are
24///   allowed to load "sub assets" of any type, which are identified by a named "label".
25///
26/// Asset paths are generally constructed (and visualized) as strings:
27///
28/// ```no_run
29/// # use bevy_asset::{Asset, AssetServer, Handle};
30/// # use bevy_reflect::TypePath;
31/// #
32/// # #[derive(Asset, TypePath, Default)]
33/// # struct Mesh;
34/// #
35/// # #[derive(Asset, TypePath, Default)]
36/// # struct Scene;
37/// #
38/// # let asset_server: AssetServer = panic!();
39/// // This loads the `my_scene.scn` base asset from the default asset source.
40/// let scene: Handle<Scene> = asset_server.load("my_scene.scn");
41///
42/// // This loads the `PlayerMesh` labeled asset from the `my_scene.scn` base asset in the default asset source.
43/// let mesh: Handle<Mesh> = asset_server.load("my_scene.scn#PlayerMesh");
44///
45/// // This loads the `my_scene.scn` base asset from a custom 'remote' asset source.
46/// let scene: Handle<Scene> = asset_server.load("remote://my_scene.scn");
47/// ```
48///
49/// [`AssetPath`] implements [`From`] for `&'static str`, `&'static Path`, and `&'a String`,
50/// which allows us to optimize the static cases.
51/// This means that the common case of `asset_server.load("my_scene.scn")` when it creates and
52/// clones internal owned [`AssetPaths`](AssetPath).
53/// This also means that you should use [`AssetPath::parse`] in cases where `&str` is the explicit type.
54#[derive(Eq, PartialEq, Hash, Clone, Default, Reflect)]
55#[reflect(opaque)]
56#[reflect(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
57pub struct AssetPath<'a> {
58    source: AssetSourceId<'a>,
59    path: CowArc<'a, Path>,
60    label: Option<CowArc<'a, str>>,
61}
62
63impl<'a> Debug for AssetPath<'a> {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        Display::fmt(self, f)
66    }
67}
68
69impl<'a> Display for AssetPath<'a> {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        if let AssetSourceId::Name(name) = self.source() {
72            write!(f, "{name}://")?;
73        }
74        write!(f, "{}", self.path.display())?;
75        if let Some(label) = &self.label {
76            write!(f, "#{label}")?;
77        }
78        Ok(())
79    }
80}
81
82/// An error that occurs when parsing a string type to create an [`AssetPath`] fails, such as during [`AssetPath::parse`].
83#[derive(Error, Debug, PartialEq, Eq)]
84pub enum ParseAssetPathError {
85    /// Error that occurs when the [`AssetPath::source`] section of a path string contains the [`AssetPath::label`] delimiter `#`. E.g. `bad#source://file.test`.
86    #[error("Asset source must not contain a `#` character")]
87    InvalidSourceSyntax,
88    /// Error that occurs when the [`AssetPath::label`] section of a path string contains the [`AssetPath::source`] delimiter `://`. E.g. `source://file.test#bad://label`.
89    #[error("Asset label must not contain a `://` substring")]
90    InvalidLabelSyntax,
91    /// Error that occurs when a path string has an [`AssetPath::source`] delimiter `://` with no characters preceding it. E.g. `://file.test`.
92    #[error("Asset source must be at least one character. Either specify the source before the '://' or remove the `://`")]
93    MissingSource,
94    /// Error that occurs when a path string has an [`AssetPath::label`] delimiter `#` with no characters succeeding it. E.g. `file.test#`
95    #[error("Asset label must be at least one character. Either specify the label after the '#' or remove the '#'")]
96    MissingLabel,
97}
98
99impl<'a> AssetPath<'a> {
100    /// Creates a new [`AssetPath`] from a string in the asset path format:
101    /// * An asset at the root: `"scene.gltf"`
102    /// * An asset nested in some folders: `"some/path/scene.gltf"`
103    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
104    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
105    ///
106    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
107    /// and reference counting for [`AssetPath::into_owned`].
108    ///
109    /// # Panics
110    /// Panics if the asset path is in an invalid format. Use [`AssetPath::try_parse`] for a fallible variant
111    pub fn parse(asset_path: &'a str) -> AssetPath<'a> {
112        Self::try_parse(asset_path).unwrap()
113    }
114
115    /// Creates a new [`AssetPath`] from a string in the asset path format:
116    /// * An asset at the root: `"scene.gltf"`
117    /// * An asset nested in some folders: `"some/path/scene.gltf"`
118    /// * An asset with a "label": `"some/path/scene.gltf#Mesh0"`
119    /// * An asset with a custom "source": `"custom://some/path/scene.gltf#Mesh0"`
120    ///
121    /// Prefer [`From<'static str>`] for static strings, as this will prevent allocations
122    /// and reference counting for [`AssetPath::into_owned`].
123    ///
124    /// This will return a [`ParseAssetPathError`] if `asset_path` is in an invalid format.
125    pub fn try_parse(asset_path: &'a str) -> Result<AssetPath<'a>, ParseAssetPathError> {
126        let (source, path, label) = Self::parse_internal(asset_path)?;
127        Ok(Self {
128            source: match source {
129                Some(source) => AssetSourceId::Name(CowArc::Borrowed(source)),
130                None => AssetSourceId::Default,
131            },
132            path: CowArc::Borrowed(path),
133            label: label.map(CowArc::Borrowed),
134        })
135    }
136
137    // Attempts to Parse a &str into an `AssetPath`'s `AssetPath::source`, `AssetPath::path`, and `AssetPath::label` components.
138    fn parse_internal(
139        asset_path: &str,
140    ) -> Result<(Option<&str>, &Path, Option<&str>), ParseAssetPathError> {
141        let chars = asset_path.char_indices();
142        let mut source_range = None;
143        let mut path_range = 0..asset_path.len();
144        let mut label_range = None;
145
146        // Loop through the characters of the passed in &str to accomplish the following:
147        // 1. Search for the first instance of the `://` substring. If the `://` substring is found,
148        //  store the range of indices representing everything before the `://` substring as the `source_range`.
149        // 2. Search for the last instance of the `#` character. If the `#` character is found,
150        //  store the range of indices representing everything after the `#` character as the `label_range`
151        // 3. Set the `path_range` to be everything in between the `source_range` and `label_range`,
152        //  excluding the `://` substring and `#` character.
153        // 4. Verify that there are no `#` characters in the `AssetPath::source` and no `://` substrings in the `AssetPath::label`
154        let mut source_delimiter_chars_matched = 0;
155        let mut last_found_source_index = 0;
156        for (index, char) in chars {
157            match char {
158                ':' => {
159                    source_delimiter_chars_matched = 1;
160                }
161                '/' => {
162                    match source_delimiter_chars_matched {
163                        1 => {
164                            source_delimiter_chars_matched = 2;
165                        }
166                        2 => {
167                            // If we haven't found our first `AssetPath::source` yet, check to make sure it is valid and then store it.
168                            if source_range.is_none() {
169                                // If the `AssetPath::source` contains a `#` character, it is invalid.
170                                if label_range.is_some() {
171                                    return Err(ParseAssetPathError::InvalidSourceSyntax);
172                                }
173                                source_range = Some(0..index - 2);
174                                path_range.start = index + 1;
175                            }
176                            last_found_source_index = index - 2;
177                            source_delimiter_chars_matched = 0;
178                        }
179                        _ => {}
180                    }
181                }
182                '#' => {
183                    path_range.end = index;
184                    label_range = Some(index + 1..asset_path.len());
185                    source_delimiter_chars_matched = 0;
186                }
187                _ => {
188                    source_delimiter_chars_matched = 0;
189                }
190            }
191        }
192        // If we found an `AssetPath::label`
193        if let Some(range) = label_range.clone() {
194            // If the `AssetPath::label` contained a `://` substring, it is invalid.
195            if range.start <= last_found_source_index {
196                return Err(ParseAssetPathError::InvalidLabelSyntax);
197            }
198        }
199        // Try to parse the range of indices that represents the `AssetPath::source` portion of the `AssetPath` to make sure it is not empty.
200        // This would be the case if the input &str was something like `://some/file.test`
201        let source = match source_range {
202            Some(source_range) => {
203                if source_range.is_empty() {
204                    return Err(ParseAssetPathError::MissingSource);
205                }
206                Some(&asset_path[source_range])
207            }
208            None => None,
209        };
210        // Try to parse the range of indices that represents the `AssetPath::label` portion of the `AssetPath` to make sure it is not empty.
211        // This would be the case if the input &str was something like `some/file.test#`.
212        let label = match label_range {
213            Some(label_range) => {
214                if label_range.is_empty() {
215                    return Err(ParseAssetPathError::MissingLabel);
216                }
217                Some(&asset_path[label_range])
218            }
219            None => None,
220        };
221
222        let path = Path::new(&asset_path[path_range]);
223        Ok((source, path, label))
224    }
225
226    /// Creates a new [`AssetPath`] from a [`PathBuf`].
227    #[inline]
228    pub fn from_path_buf(path_buf: PathBuf) -> AssetPath<'a> {
229        AssetPath {
230            path: CowArc::Owned(path_buf.into()),
231            source: AssetSourceId::Default,
232            label: None,
233        }
234    }
235
236    /// Creates a new [`AssetPath`] from a [`Path`].
237    #[inline]
238    pub fn from_path(path: &'a Path) -> AssetPath<'a> {
239        AssetPath {
240            path: CowArc::Borrowed(path),
241            source: AssetSourceId::Default,
242            label: None,
243        }
244    }
245
246    /// Gets the "asset source", if one was defined. If none was defined, the default source
247    /// will be used.
248    #[inline]
249    pub fn source(&self) -> &AssetSourceId<'_> {
250        &self.source
251    }
252
253    /// Gets the "sub-asset label".
254    #[inline]
255    pub fn label(&self) -> Option<&str> {
256        self.label.as_deref()
257    }
258
259    /// Gets the "sub-asset label".
260    #[inline]
261    pub fn label_cow(&self) -> Option<CowArc<'a, str>> {
262        self.label.clone()
263    }
264
265    /// Gets the path to the asset in the "virtual filesystem".
266    #[inline]
267    pub fn path(&self) -> &Path {
268        self.path.deref()
269    }
270
271    /// Gets the path to the asset in the "virtual filesystem" without a label (if a label is currently set).
272    #[inline]
273    pub fn without_label(&self) -> AssetPath<'_> {
274        Self {
275            source: self.source.clone(),
276            path: self.path.clone(),
277            label: None,
278        }
279    }
280
281    /// Removes a "sub-asset label" from this [`AssetPath`], if one was set.
282    #[inline]
283    pub fn remove_label(&mut self) {
284        self.label = None;
285    }
286
287    /// Takes the "sub-asset label" from this [`AssetPath`], if one was set.
288    #[inline]
289    pub fn take_label(&mut self) -> Option<CowArc<'a, str>> {
290        self.label.take()
291    }
292
293    /// Returns this asset path with the given label. This will replace the previous
294    /// label if it exists.
295    #[inline]
296    pub fn with_label(self, label: impl Into<CowArc<'a, str>>) -> AssetPath<'a> {
297        AssetPath {
298            source: self.source,
299            path: self.path,
300            label: Some(label.into()),
301        }
302    }
303
304    /// Returns this asset path with the given asset source. This will replace the previous asset
305    /// source if it exists.
306    #[inline]
307    pub fn with_source(self, source: impl Into<AssetSourceId<'a>>) -> AssetPath<'a> {
308        AssetPath {
309            source: source.into(),
310            path: self.path,
311            label: self.label,
312        }
313    }
314
315    /// Returns an [`AssetPath`] for the parent folder of this path, if there is a parent folder in the path.
316    pub fn parent(&self) -> Option<AssetPath<'a>> {
317        let path = match &self.path {
318            CowArc::Borrowed(path) => CowArc::Borrowed(path.parent()?),
319            CowArc::Static(path) => CowArc::Static(path.parent()?),
320            CowArc::Owned(path) => path.parent()?.to_path_buf().into(),
321        };
322        Some(AssetPath {
323            source: self.source.clone(),
324            label: None,
325            path,
326        })
327    }
328
329    /// Converts this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
330    /// If internally a value is a static reference, the static reference will be used unchanged.
331    /// If internally a value is an "owned [`Arc`]", it will remain unchanged.
332    ///
333    /// [`Arc`]: alloc::sync::Arc
334    pub fn into_owned(self) -> AssetPath<'static> {
335        AssetPath {
336            source: self.source.into_owned(),
337            path: self.path.into_owned(),
338            label: self.label.map(CowArc::into_owned),
339        }
340    }
341
342    /// Clones this into an "owned" value. If internally a value is borrowed, it will be cloned into an "owned [`Arc`]".
343    /// If internally a value is a static reference, the static reference will be used unchanged.
344    /// If internally a value is an "owned [`Arc`]", the [`Arc`] will be cloned.
345    ///
346    /// [`Arc`]: alloc::sync::Arc
347    #[inline]
348    pub fn clone_owned(&self) -> AssetPath<'static> {
349        self.clone().into_owned()
350    }
351
352    /// Resolves an [`AssetPath`] relative to `self`.
353    ///
354    /// Semantics:
355    /// - If `path` is label-only (default source, empty path, label set), replace `self`'s label.
356    /// - If `path` begins with `/`, treat it as rooted at the asset-source root (not the filesystem).
357    /// - If `path` has an explicit source (`name://...`), it replaces the base source.
358    /// - Relative segments are concatenated and normalized (`.`/`..` removal), preserving extra `..` if the base underflows.
359    ///
360    /// ```
361    /// # use bevy_asset::AssetPath;
362    /// let base = AssetPath::parse("a/b");
363    /// assert_eq!(base.resolve(&AssetPath::parse("c")), AssetPath::parse("a/b/c"));
364    /// assert_eq!(base.resolve(&AssetPath::parse("./c")), AssetPath::parse("a/b/c"));
365    /// assert_eq!(base.resolve(&AssetPath::parse("../c")), AssetPath::parse("a/c"));
366    /// assert_eq!(base.resolve(&AssetPath::parse("c.png")), AssetPath::parse("a/b/c.png"));
367    /// assert_eq!(base.resolve(&AssetPath::parse("/c")), AssetPath::parse("c"));
368    /// assert_eq!(AssetPath::parse("a/b.png").resolve(&AssetPath::parse("#c")), AssetPath::parse("a/b.png#c"));
369    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve(&AssetPath::parse("#d")), AssetPath::parse("a/b.png#d"));
370    /// ```
371    ///
372    /// See also [`AssetPath::resolve_str`].
373    pub fn resolve(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
374        let is_label_only = matches!(path.source(), AssetSourceId::Default)
375            && path.path().as_os_str().is_empty()
376            && path.label().is_some();
377
378        if is_label_only {
379            self.clone_owned()
380                .with_label(path.label().unwrap().to_owned())
381        } else {
382            let explicit_source = match path.source() {
383                AssetSourceId::Default => None,
384                AssetSourceId::Name(name) => Some(name.as_ref()),
385            };
386
387            self.resolve_from_parts(false, explicit_source, path.path(), path.label())
388        }
389    }
390
391    /// Resolves an [`AssetPath`] relative to `self` using embedded (RFC 1808) semantics.
392    ///
393    /// Semantics:
394    /// - Remove the "file portion" of the base before concatenation (unless the base ends with `/`).
395    /// - Otherwise identical to [`AssetPath::resolve`].
396    ///
397    /// ```
398    /// # use bevy_asset::AssetPath;
399    /// let base = AssetPath::parse("a/b");
400    /// assert_eq!(base.resolve_embed(&AssetPath::parse("c")), AssetPath::parse("a/c"));
401    /// assert_eq!(base.resolve_embed(&AssetPath::parse("./c")), AssetPath::parse("a/c"));
402    /// assert_eq!(base.resolve_embed(&AssetPath::parse("../c")), AssetPath::parse("c"));
403    /// assert_eq!(base.resolve_embed(&AssetPath::parse("c.png")), AssetPath::parse("a/c.png"));
404    /// assert_eq!(base.resolve_embed(&AssetPath::parse("/c")), AssetPath::parse("c"));
405    /// assert_eq!(AssetPath::parse("a/b.png").resolve_embed(&AssetPath::parse("#c")), AssetPath::parse("a/b.png#c"));
406    /// assert_eq!(AssetPath::parse("a/b.png#c").resolve_embed(&AssetPath::parse("#d")), AssetPath::parse("a/b.png#d"));
407    /// ```
408    ///
409    /// See also [`AssetPath::resolve_embed_str`].
410    pub fn resolve_embed(&self, path: &AssetPath<'_>) -> AssetPath<'static> {
411        let is_label_only = matches!(path.source(), AssetSourceId::Default)
412            && path.path().as_os_str().is_empty()
413            && path.label().is_some();
414
415        if is_label_only {
416            self.clone_owned()
417                .with_label(path.label().unwrap().to_owned())
418        } else {
419            let explicit_source = match path.source() {
420                AssetSourceId::Default => None,
421                AssetSourceId::Name(name) => Some(name.as_ref()),
422            };
423
424            self.resolve_from_parts(true, explicit_source, path.path(), path.label())
425        }
426    }
427
428    /// Parses `path` as an [`AssetPath`], then resolves it relative to `self`.
429    ///
430    /// Returns an error if parsing fails.
431    ///
432    /// For more details, see [`AssetPath::resolve`].
433    pub fn resolve_str(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
434        self.resolve_internal(path, false)
435    }
436
437    /// Parses `path` as an [`AssetPath`], then resolves it relative to `self` using embedded
438    /// (RFC 1808) semantics.
439    ///
440    /// Returns an error if parsing fails.
441    ///
442    /// For more details, see [`AssetPath::resolve_embed`].
443    pub fn resolve_embed_str(&self, path: &str) -> Result<AssetPath<'static>, ParseAssetPathError> {
444        self.resolve_internal(path, true)
445    }
446
447    fn resolve_from_parts(
448        &self,
449        replace: bool,
450        source: Option<&str>,
451        rpath: &Path,
452        rlabel: Option<&str>,
453    ) -> AssetPath<'static> {
454        let mut base_path = PathBuf::from(self.path());
455        if replace && !self.path.to_str().unwrap().ends_with('/') {
456            // No error if base is empty (per RFC 1808).
457            base_path.pop();
458        }
459
460        // Strip off leading slash
461        let mut is_absolute = false;
462        let rpath = match rpath.strip_prefix("/") {
463            Ok(p) => {
464                is_absolute = true;
465                p
466            }
467            _ => rpath,
468        };
469
470        let mut result_path = if !is_absolute && source.is_none() {
471            base_path
472        } else {
473            PathBuf::new()
474        };
475        result_path.push(rpath);
476        result_path = normalize_path(result_path.as_path());
477
478        AssetPath {
479            source: match source {
480                Some(source) => AssetSourceId::Name(CowArc::Owned(source.into())),
481                None => self.source.clone_owned(),
482            },
483            path: CowArc::Owned(result_path.into()),
484            label: rlabel.map(|l| CowArc::Owned(l.into())),
485        }
486    }
487
488    fn resolve_internal(
489        &self,
490        path: &str,
491        replace: bool,
492    ) -> Result<AssetPath<'static>, ParseAssetPathError> {
493        if let Some(label) = path.strip_prefix('#') {
494            // It's a label only
495            Ok(self.clone_owned().with_label(label.to_owned()))
496        } else {
497            let (source, rpath, rlabel) = AssetPath::parse_internal(path)?;
498            Ok(self.resolve_from_parts(replace, source, rpath, rlabel))
499        }
500    }
501
502    /// Returns the full extension (including multiple '.' values).
503    /// Ex: Returns `"config.ron"` for `"my_asset.config.ron"`
504    ///
505    /// Also strips out anything following a `?` to handle query parameters in URIs
506    pub fn get_full_extension(&self) -> Option<&str> {
507        let file_name = self.path().file_name()?.to_str()?;
508        let index = file_name.find('.')?;
509        let mut extension = &file_name[index + 1..];
510
511        // Strip off any query parameters
512        let query = extension.find('?');
513        if let Some(offset) = query {
514            extension = &extension[..offset];
515        }
516
517        Some(extension)
518    }
519
520    /// Returns the extension, excluding multiple `.` values.
521    ///
522    /// Ex: Returns `"ron"` for `"my_asset.config.ron"`
523    ///
524    /// Also strips out anything follow a `?` to handle query parameters in URIs.
525    pub fn get_extension(&self) -> Option<&str> {
526        let full_extension = self.get_full_extension()?;
527        Some(match full_extension.rfind(".") {
528            None => full_extension,
529            Some(index) => &full_extension[(index + 1)..],
530        })
531    }
532
533    pub(crate) fn iter_secondary_extensions(full_extension: &str) -> impl Iterator<Item = &str> {
534        full_extension.char_indices().filter_map(|(i, c)| {
535            if c == '.' {
536                Some(&full_extension[i + 1..])
537            } else {
538                None
539            }
540        })
541    }
542
543    /// Returns `true` if this [`AssetPath`] points to a file that is
544    /// outside of its [`AssetSource`](crate::io::AssetSource) folder.
545    ///
546    /// ## Example
547    /// ```
548    /// # use bevy_asset::AssetPath;
549    /// // Inside the default AssetSource.
550    /// let path = AssetPath::parse("thingy.png");
551    /// assert!( ! path.is_unapproved());
552    /// let path = AssetPath::parse("gui/thingy.png");
553    /// assert!( ! path.is_unapproved());
554    ///
555    /// // Inside a different AssetSource.
556    /// let path = AssetPath::parse("embedded://thingy.png");
557    /// assert!( ! path.is_unapproved());
558    ///
559    /// // Exits the `AssetSource`s directory.
560    /// let path = AssetPath::parse("../thingy.png");
561    /// assert!(path.is_unapproved());
562    /// let path = AssetPath::parse("folder/../../thingy.png");
563    /// assert!(path.is_unapproved());
564    ///
565    /// // This references the linux root directory.
566    /// let path = AssetPath::parse("/home/thingy.png");
567    /// assert!(path.is_unapproved());
568    /// ```
569    pub fn is_unapproved(&self) -> bool {
570        use std::path::Component;
571        let mut simplified = PathBuf::new();
572        for component in self.path.components() {
573            match component {
574                Component::Prefix(_) | Component::RootDir => return true,
575                Component::CurDir => {}
576                Component::ParentDir => {
577                    if !simplified.pop() {
578                        return true;
579                    }
580                }
581                Component::Normal(os_str) => simplified.push(os_str),
582            }
583        }
584
585        false
586    }
587}
588
589// This is only implemented for static lifetimes to ensure `Path::clone` does not allocate
590// by ensuring that this is stored as a `CowArc::Static`.
591// Please read https://github.com/bevyengine/bevy/issues/19844 before changing this!
592impl From<&'static str> for AssetPath<'static> {
593    #[inline]
594    fn from(asset_path: &'static str) -> Self {
595        let (source, path, label) = Self::parse_internal(asset_path).unwrap();
596        AssetPath {
597            source: source.into(),
598            path: CowArc::Static(path),
599            label: label.map(CowArc::Static),
600        }
601    }
602}
603
604impl<'a> From<&'a String> for AssetPath<'a> {
605    #[inline]
606    fn from(asset_path: &'a String) -> Self {
607        AssetPath::parse(asset_path.as_str())
608    }
609}
610
611impl From<String> for AssetPath<'static> {
612    #[inline]
613    fn from(asset_path: String) -> Self {
614        AssetPath::parse(asset_path.as_str()).into_owned()
615    }
616}
617
618impl From<&'static Path> for AssetPath<'static> {
619    #[inline]
620    fn from(path: &'static Path) -> Self {
621        Self {
622            source: AssetSourceId::Default,
623            path: CowArc::Static(path),
624            label: None,
625        }
626    }
627}
628
629impl From<PathBuf> for AssetPath<'static> {
630    #[inline]
631    fn from(path: PathBuf) -> Self {
632        Self {
633            source: AssetSourceId::Default,
634            path: path.into(),
635            label: None,
636        }
637    }
638}
639
640impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
641    fn from(value: &'a AssetPath<'b>) -> Self {
642        value.clone()
643    }
644}
645
646impl<'a> From<AssetPath<'a>> for PathBuf {
647    fn from(value: AssetPath<'a>) -> Self {
648        value.path().to_path_buf()
649    }
650}
651
652impl<'a> Serialize for AssetPath<'a> {
653    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
654    where
655        S: serde::Serializer,
656    {
657        self.to_string().serialize(serializer)
658    }
659}
660
661impl<'de> Deserialize<'de> for AssetPath<'static> {
662    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
663    where
664        D: serde::Deserializer<'de>,
665    {
666        deserializer.deserialize_string(AssetPathVisitor)
667    }
668}
669
670struct AssetPathVisitor;
671
672impl<'de> Visitor<'de> for AssetPathVisitor {
673    type Value = AssetPath<'static>;
674
675    fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
676        formatter.write_str("string AssetPath")
677    }
678
679    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
680    where
681        E: serde::de::Error,
682    {
683        match AssetPath::try_parse(v) {
684            Ok(path) => Ok(path.into_owned()),
685            Err(err) => Err(E::custom(err)),
686        }
687    }
688}
689
690/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
691/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
692pub(crate) fn normalize_path(path: &Path) -> PathBuf {
693    let mut result_path = PathBuf::new();
694    for elt in path.iter() {
695        if elt == "." {
696            // Skip
697        } else if elt == ".." {
698            // Note: If the result_path ends in `..`, Path::file_name returns None, so we'll end up
699            // preserving it.
700            if result_path.file_name().is_some() {
701                // This assert is just a sanity check - we already know the path has a file_name, so
702                // we know there is something to pop.
703                assert!(result_path.pop());
704            } else {
705                // Preserve ".." if insufficient matches (per RFC 1808).
706                result_path.push(elt);
707            }
708        } else {
709            result_path.push(elt);
710        }
711    }
712    result_path
713}
714
715#[cfg(test)]
716mod tests {
717    use crate::AssetPath;
718    use std::path::Path;
719
720    #[test]
721    fn parse_asset_path() {
722        let result = AssetPath::parse_internal("a/b.test");
723        assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
724
725        let result = AssetPath::parse_internal("http://a/b.test");
726        assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
727
728        let result = AssetPath::parse_internal("http://a/b.test#Foo");
729        assert_eq!(
730            result,
731            Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
732        );
733
734        let result = AssetPath::parse_internal("localhost:80/b.test");
735        assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
736
737        let result = AssetPath::parse_internal("http://localhost:80/b.test");
738        assert_eq!(
739            result,
740            Ok((Some("http"), Path::new("localhost:80/b.test"), None))
741        );
742
743        let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
744        assert_eq!(
745            result,
746            Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
747        );
748
749        let result = AssetPath::parse_internal("#insource://a/b.test");
750        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
751
752        let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
753        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
754
755        let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
756        assert!(
757            result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
758                || result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
759        );
760
761        let result = AssetPath::parse_internal("http://");
762        assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
763
764        let result = AssetPath::parse_internal("://x");
765        assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
766
767        let result = AssetPath::parse_internal("a/b.test#");
768        assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
769    }
770
771    #[test]
772    fn test_serialize() {
773        assert!(ron::de::from_str::<AssetPath>("\"a/b.test\"").is_ok());
774        assert!(ron::de::from_str::<AssetPath>("\"a/b.test#\"").is_err());
775    }
776
777    #[test]
778    fn test_parent() {
779        // Parent consumes path segments, returns None when insufficient
780        let result = AssetPath::from("a/b.test");
781        assert_eq!(result.parent(), Some(AssetPath::from("a")));
782        assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
783        assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
784
785        // Parent cannot consume asset source
786        let result = AssetPath::from("http://a");
787        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
788        assert_eq!(result.parent().unwrap().parent(), None);
789
790        // Parent consumes labels
791        let result = AssetPath::from("http://a#Foo");
792        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
793    }
794
795    #[test]
796    fn test_with_source() {
797        let result = AssetPath::from("http://a#Foo");
798        assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
799    }
800
801    #[test]
802    fn test_without_label() {
803        let result = AssetPath::from("http://a#Foo");
804        assert_eq!(result.without_label(), AssetPath::from("http://a"));
805    }
806
807    #[test]
808    fn test_resolve_full() {
809        // A "full" path should ignore the base path.
810        let base = AssetPath::from("alice/bob#carol");
811        assert_eq!(
812            base.resolve_str("/joe/next").unwrap(),
813            AssetPath::from("joe/next")
814        );
815        assert_eq!(
816            base.resolve(&AssetPath::parse("/joe/next")),
817            AssetPath::from("joe/next")
818        );
819        assert_eq!(
820            base.resolve_embed_str("/joe/next").unwrap(),
821            AssetPath::from("joe/next")
822        );
823        assert_eq!(
824            base.resolve_embed(&AssetPath::parse("/joe/next")),
825            AssetPath::from("joe/next")
826        );
827        assert_eq!(
828            base.resolve_str("/joe/next#dave").unwrap(),
829            AssetPath::from("joe/next#dave")
830        );
831        assert_eq!(
832            base.resolve(&AssetPath::parse("/joe/next#dave")),
833            AssetPath::from("joe/next#dave")
834        );
835        assert_eq!(
836            base.resolve_embed_str("/joe/next#dave").unwrap(),
837            AssetPath::from("joe/next#dave")
838        );
839        assert_eq!(
840            base.resolve_embed(&AssetPath::parse("/joe/next#dave")),
841            AssetPath::from("joe/next#dave")
842        );
843    }
844
845    #[test]
846    fn test_resolve_implicit_relative() {
847        // A path with no initial directory separator should be considered relative.
848        let base = AssetPath::from("alice/bob#carol");
849        assert_eq!(
850            base.resolve_str("joe/next").unwrap(),
851            AssetPath::from("alice/bob/joe/next")
852        );
853        assert_eq!(
854            base.resolve(&AssetPath::parse("joe/next")),
855            AssetPath::from("alice/bob/joe/next")
856        );
857        assert_eq!(
858            base.resolve_embed_str("joe/next").unwrap(),
859            AssetPath::from("alice/joe/next")
860        );
861        assert_eq!(
862            base.resolve_embed(&AssetPath::parse("joe/next")),
863            AssetPath::from("alice/joe/next")
864        );
865        assert_eq!(
866            base.resolve_str("joe/next#dave").unwrap(),
867            AssetPath::from("alice/bob/joe/next#dave")
868        );
869        assert_eq!(
870            base.resolve(&AssetPath::parse("joe/next#dave")),
871            AssetPath::from("alice/bob/joe/next#dave")
872        );
873        assert_eq!(
874            base.resolve_embed_str("joe/next#dave").unwrap(),
875            AssetPath::from("alice/joe/next#dave")
876        );
877        assert_eq!(
878            base.resolve_embed(&AssetPath::parse("joe/next#dave")),
879            AssetPath::from("alice/joe/next#dave")
880        );
881    }
882
883    #[test]
884    fn test_resolve_explicit_relative() {
885        // A path which begins with "./" or "../" is treated as relative
886        let base = AssetPath::from("alice/bob#carol");
887        assert_eq!(
888            base.resolve_str("./martin#dave").unwrap(),
889            AssetPath::from("alice/bob/martin#dave")
890        );
891        assert_eq!(
892            base.resolve(&AssetPath::parse("./martin#dave")),
893            AssetPath::from("alice/bob/martin#dave")
894        );
895        assert_eq!(
896            base.resolve_embed_str("./martin#dave").unwrap(),
897            AssetPath::from("alice/martin#dave")
898        );
899        assert_eq!(
900            base.resolve_embed(&AssetPath::parse("./martin#dave")),
901            AssetPath::from("alice/martin#dave")
902        );
903        assert_eq!(
904            base.resolve_str("../martin#dave").unwrap(),
905            AssetPath::from("alice/martin#dave")
906        );
907        assert_eq!(
908            base.resolve(&AssetPath::parse("../martin#dave")),
909            AssetPath::from("alice/martin#dave")
910        );
911        assert_eq!(
912            base.resolve_embed_str("../martin#dave").unwrap(),
913            AssetPath::from("martin#dave")
914        );
915        assert_eq!(
916            base.resolve_embed(&AssetPath::parse("../martin#dave")),
917            AssetPath::from("martin#dave")
918        );
919    }
920
921    #[test]
922    fn test_resolve_trailing_slash() {
923        // A path which begins with "./" or "../" is treated as relative
924        let base = AssetPath::from("alice/bob/");
925        assert_eq!(
926            base.resolve_str("./martin#dave").unwrap(),
927            AssetPath::from("alice/bob/martin#dave")
928        );
929        assert_eq!(
930            base.resolve(&AssetPath::parse("./martin#dave")),
931            AssetPath::from("alice/bob/martin#dave")
932        );
933        assert_eq!(
934            base.resolve_embed_str("./martin#dave").unwrap(),
935            AssetPath::from("alice/bob/martin#dave")
936        );
937        assert_eq!(
938            base.resolve_embed(&AssetPath::parse("./martin#dave")),
939            AssetPath::from("alice/bob/martin#dave")
940        );
941        assert_eq!(
942            base.resolve_str("../martin#dave").unwrap(),
943            AssetPath::from("alice/martin#dave")
944        );
945        assert_eq!(
946            base.resolve(&AssetPath::parse("../martin#dave")),
947            AssetPath::from("alice/martin#dave")
948        );
949        assert_eq!(
950            base.resolve_embed_str("../martin#dave").unwrap(),
951            AssetPath::from("alice/martin#dave")
952        );
953        assert_eq!(
954            base.resolve_embed(&AssetPath::parse("../martin#dave")),
955            AssetPath::from("alice/martin#dave")
956        );
957    }
958
959    #[test]
960    fn test_resolve_canonicalize() {
961        // Test that ".." and "." are removed after concatenation.
962        let base = AssetPath::from("alice/bob#carol");
963        assert_eq!(
964            base.resolve_str("./martin/stephan/..#dave").unwrap(),
965            AssetPath::from("alice/bob/martin#dave")
966        );
967        assert_eq!(
968            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
969            AssetPath::from("alice/bob/martin#dave")
970        );
971        assert_eq!(
972            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
973            AssetPath::from("alice/martin#dave")
974        );
975        assert_eq!(
976            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
977            AssetPath::from("alice/martin#dave")
978        );
979        assert_eq!(
980            base.resolve_str("../martin/.#dave").unwrap(),
981            AssetPath::from("alice/martin#dave")
982        );
983        assert_eq!(
984            base.resolve(&AssetPath::parse("../martin/.#dave")),
985            AssetPath::from("alice/martin#dave")
986        );
987        assert_eq!(
988            base.resolve_embed_str("../martin/.#dave").unwrap(),
989            AssetPath::from("martin#dave")
990        );
991        assert_eq!(
992            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
993            AssetPath::from("martin#dave")
994        );
995        assert_eq!(
996            base.resolve_str("/martin/stephan/..#dave").unwrap(),
997            AssetPath::from("martin#dave")
998        );
999        assert_eq!(
1000            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1001            AssetPath::from("martin#dave")
1002        );
1003        assert_eq!(
1004            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1005            AssetPath::from("martin#dave")
1006        );
1007        assert_eq!(
1008            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1009            AssetPath::from("martin#dave")
1010        );
1011    }
1012
1013    #[test]
1014    fn test_resolve_canonicalize_base() {
1015        // Test that ".." and "." are removed after concatenation even from the base path.
1016        let base = AssetPath::from("alice/../bob#carol");
1017        assert_eq!(
1018            base.resolve_str("./martin/stephan/..#dave").unwrap(),
1019            AssetPath::from("bob/martin#dave")
1020        );
1021        assert_eq!(
1022            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1023            AssetPath::from("bob/martin#dave")
1024        );
1025        assert_eq!(
1026            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1027            AssetPath::from("martin#dave")
1028        );
1029        assert_eq!(
1030            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1031            AssetPath::from("martin#dave")
1032        );
1033        assert_eq!(
1034            base.resolve_str("../martin/.#dave").unwrap(),
1035            AssetPath::from("martin#dave")
1036        );
1037        assert_eq!(
1038            base.resolve(&AssetPath::parse("../martin/.#dave")),
1039            AssetPath::from("martin#dave")
1040        );
1041        assert_eq!(
1042            base.resolve_embed_str("../martin/.#dave").unwrap(),
1043            AssetPath::from("../martin#dave")
1044        );
1045        assert_eq!(
1046            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1047            AssetPath::from("../martin#dave")
1048        );
1049        assert_eq!(
1050            base.resolve_str("/martin/stephan/..#dave").unwrap(),
1051            AssetPath::from("martin#dave")
1052        );
1053        assert_eq!(
1054            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1055            AssetPath::from("martin#dave")
1056        );
1057        assert_eq!(
1058            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1059            AssetPath::from("martin#dave")
1060        );
1061        assert_eq!(
1062            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1063            AssetPath::from("martin#dave")
1064        );
1065    }
1066
1067    #[test]
1068    fn test_resolve_canonicalize_with_source() {
1069        // Test that ".." and "." are removed after concatenation.
1070        let base = AssetPath::from("source://alice/bob#carol");
1071        assert_eq!(
1072            base.resolve_str("./martin/stephan/..#dave").unwrap(),
1073            AssetPath::from("source://alice/bob/martin#dave")
1074        );
1075        assert_eq!(
1076            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1077            AssetPath::from("source://alice/bob/martin#dave")
1078        );
1079        assert_eq!(
1080            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1081            AssetPath::from("source://alice/martin#dave")
1082        );
1083        assert_eq!(
1084            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1085            AssetPath::from("source://alice/martin#dave")
1086        );
1087        assert_eq!(
1088            base.resolve_str("../martin/.#dave").unwrap(),
1089            AssetPath::from("source://alice/martin#dave")
1090        );
1091        assert_eq!(
1092            base.resolve(&AssetPath::parse("../martin/.#dave")),
1093            AssetPath::from("source://alice/martin#dave")
1094        );
1095        assert_eq!(
1096            base.resolve_embed_str("../martin/.#dave").unwrap(),
1097            AssetPath::from("source://martin#dave")
1098        );
1099        assert_eq!(
1100            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1101            AssetPath::from("source://martin#dave")
1102        );
1103        assert_eq!(
1104            base.resolve_str("/martin/stephan/..#dave").unwrap(),
1105            AssetPath::from("source://martin#dave")
1106        );
1107        assert_eq!(
1108            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1109            AssetPath::from("source://martin#dave")
1110        );
1111        assert_eq!(
1112            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1113            AssetPath::from("source://martin#dave")
1114        );
1115        assert_eq!(
1116            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1117            AssetPath::from("source://martin#dave")
1118        );
1119    }
1120
1121    #[test]
1122    fn test_resolve_absolute() {
1123        // Paths beginning with '/' replace the base path
1124        let base = AssetPath::from("alice/bob#carol");
1125        assert_eq!(
1126            base.resolve_str("/martin/stephan").unwrap(),
1127            AssetPath::from("martin/stephan")
1128        );
1129        assert_eq!(
1130            base.resolve(&AssetPath::parse("/martin/stephan")),
1131            AssetPath::from("martin/stephan")
1132        );
1133        assert_eq!(
1134            base.resolve_embed_str("/martin/stephan").unwrap(),
1135            AssetPath::from("martin/stephan")
1136        );
1137        assert_eq!(
1138            base.resolve_embed(&AssetPath::parse("/martin/stephan")),
1139            AssetPath::from("martin/stephan")
1140        );
1141        assert_eq!(
1142            base.resolve_str("/martin/stephan#dave").unwrap(),
1143            AssetPath::from("martin/stephan/#dave")
1144        );
1145        assert_eq!(
1146            base.resolve(&AssetPath::parse("/martin/stephan#dave")),
1147            AssetPath::from("martin/stephan/#dave")
1148        );
1149        assert_eq!(
1150            base.resolve_embed_str("/martin/stephan#dave").unwrap(),
1151            AssetPath::from("martin/stephan/#dave")
1152        );
1153        assert_eq!(
1154            base.resolve_embed(&AssetPath::parse("/martin/stephan#dave")),
1155            AssetPath::from("martin/stephan/#dave")
1156        );
1157    }
1158
1159    #[test]
1160    fn test_resolve_asset_source() {
1161        // Paths beginning with 'source://' replace the base path
1162        let base = AssetPath::from("alice/bob#carol");
1163        assert_eq!(
1164            base.resolve_str("source://martin/stephan").unwrap(),
1165            AssetPath::from("source://martin/stephan")
1166        );
1167        assert_eq!(
1168            base.resolve(&AssetPath::parse("source://martin/stephan")),
1169            AssetPath::from("source://martin/stephan")
1170        );
1171        assert_eq!(
1172            base.resolve_embed_str("source://martin/stephan").unwrap(),
1173            AssetPath::from("source://martin/stephan")
1174        );
1175        assert_eq!(
1176            base.resolve_embed(&AssetPath::parse("source://martin/stephan")),
1177            AssetPath::from("source://martin/stephan")
1178        );
1179        assert_eq!(
1180            base.resolve_str("source://martin/stephan#dave").unwrap(),
1181            AssetPath::from("source://martin/stephan/#dave")
1182        );
1183        assert_eq!(
1184            base.resolve(&AssetPath::parse("source://martin/stephan#dave")),
1185            AssetPath::from("source://martin/stephan/#dave")
1186        );
1187        assert_eq!(
1188            base.resolve_embed_str("source://martin/stephan#dave")
1189                .unwrap(),
1190            AssetPath::from("source://martin/stephan/#dave")
1191        );
1192        assert_eq!(
1193            base.resolve_embed(&AssetPath::parse("source://martin/stephan#dave")),
1194            AssetPath::from("source://martin/stephan/#dave")
1195        );
1196    }
1197
1198    #[test]
1199    fn test_resolve_label() {
1200        // A relative path with only a label should replace the label portion
1201        let base = AssetPath::from("alice/bob#carol");
1202        assert_eq!(
1203            base.resolve_str("#dave").unwrap(),
1204            AssetPath::from("alice/bob#dave")
1205        );
1206        assert_eq!(
1207            base.resolve(&AssetPath::parse("#dave")),
1208            AssetPath::from("alice/bob#dave")
1209        );
1210        assert_eq!(
1211            base.resolve_embed_str("#dave").unwrap(),
1212            AssetPath::from("alice/bob#dave")
1213        );
1214        assert_eq!(
1215            base.resolve_embed(&AssetPath::parse("#dave")),
1216            AssetPath::from("alice/bob#dave")
1217        );
1218    }
1219
1220    #[test]
1221    fn test_resolve_insufficient_elements() {
1222        // Ensure that ".." segments are preserved if there are insufficient elements to remove them.
1223        let base = AssetPath::from("alice/bob#carol");
1224        assert_eq!(
1225            base.resolve_str("../../joe/next").unwrap(),
1226            AssetPath::from("joe/next")
1227        );
1228        assert_eq!(
1229            base.resolve(&AssetPath::parse("../../joe/next")),
1230            AssetPath::from("joe/next")
1231        );
1232        assert_eq!(
1233            base.resolve_embed_str("../../joe/next").unwrap(),
1234            AssetPath::from("../joe/next")
1235        );
1236        assert_eq!(
1237            base.resolve_embed(&AssetPath::parse("../../joe/next")),
1238            AssetPath::from("../joe/next")
1239        );
1240    }
1241
1242    #[test]
1243    fn resolve_embed_relative_to_external_path() {
1244        let base = AssetPath::from("../../a/b.gltf");
1245        assert_eq!(
1246            base.resolve_embed_str("c.bin").unwrap(),
1247            AssetPath::from("../../a/c.bin")
1248        );
1249        assert_eq!(
1250            base.resolve_embed(&AssetPath::parse("c.bin")),
1251            AssetPath::from("../../a/c.bin")
1252        );
1253    }
1254
1255    #[test]
1256    fn resolve_relative_to_external_path() {
1257        let base = AssetPath::from("../../a/b.gltf");
1258        assert_eq!(
1259            base.resolve_str("c.bin").unwrap(),
1260            AssetPath::from("../../a/b.gltf/c.bin")
1261        );
1262        assert_eq!(
1263            base.resolve(&AssetPath::parse("c.bin")),
1264            AssetPath::from("../../a/b.gltf/c.bin")
1265        );
1266    }
1267
1268    #[test]
1269    fn test_get_full_extension() {
1270        let result = AssetPath::from("http://a.tar.gz#Foo");
1271        assert_eq!(result.get_full_extension(), Some("tar.gz"));
1272
1273        let result = AssetPath::from("http://a#Foo");
1274        assert_eq!(result.get_full_extension(), None);
1275
1276        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1277        assert_eq!(result.get_full_extension(), Some("tar.bz2"));
1278
1279        let result = AssetPath::from("asset.Custom");
1280        assert_eq!(result.get_full_extension(), Some("Custom"));
1281    }
1282
1283    #[test]
1284    fn test_get_extension() {
1285        let result = AssetPath::from("http://a.tar.gz#Foo");
1286        assert_eq!(result.get_extension(), Some("gz"));
1287
1288        let result = AssetPath::from("http://a#Foo");
1289        assert_eq!(result.get_extension(), None);
1290
1291        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1292        assert_eq!(result.get_extension(), Some("bz2"));
1293
1294        let result = AssetPath::from("asset.Custom");
1295        assert_eq!(result.get_extension(), Some("Custom"));
1296    }
1297}