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) => CowArc::Owned(path.parent()?.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
572        let mut component_count: usize = 0;
573
574        for component in self.path.components() {
575            match component {
576                Component::Prefix(_) | Component::RootDir => return true,
577                Component::CurDir => {}
578                Component::ParentDir if component_count == 0 => return true,
579                Component::ParentDir => component_count -= 1,
580                Component::Normal(_) => component_count += 1,
581            }
582        }
583
584        false
585    }
586}
587
588// This is only implemented for static lifetimes to ensure `Path::clone` does not allocate
589// by ensuring that this is stored as a `CowArc::Static`.
590// Please read https://github.com/bevyengine/bevy/issues/19844 before changing this!
591impl From<&'static str> for AssetPath<'static> {
592    #[inline]
593    fn from(asset_path: &'static str) -> Self {
594        let (source, path, label) = Self::parse_internal(asset_path).unwrap();
595        AssetPath {
596            source: source.into(),
597            path: CowArc::Static(path),
598            label: label.map(CowArc::Static),
599        }
600    }
601}
602
603impl<'a> From<&'a String> for AssetPath<'a> {
604    #[inline]
605    fn from(asset_path: &'a String) -> Self {
606        AssetPath::parse(asset_path.as_str())
607    }
608}
609
610impl From<String> for AssetPath<'static> {
611    #[inline]
612    fn from(asset_path: String) -> Self {
613        AssetPath::parse(asset_path.as_str()).into_owned()
614    }
615}
616
617impl From<&'static Path> for AssetPath<'static> {
618    #[inline]
619    fn from(path: &'static Path) -> Self {
620        Self {
621            source: AssetSourceId::Default,
622            path: CowArc::Static(path),
623            label: None,
624        }
625    }
626}
627
628impl From<PathBuf> for AssetPath<'static> {
629    #[inline]
630    fn from(path: PathBuf) -> Self {
631        Self {
632            source: AssetSourceId::Default,
633            path: path.into(),
634            label: None,
635        }
636    }
637}
638
639impl<'a, 'b> From<&'a AssetPath<'b>> for AssetPath<'b> {
640    fn from(value: &'a AssetPath<'b>) -> Self {
641        value.clone()
642    }
643}
644
645impl<'a> From<AssetPath<'a>> for PathBuf {
646    fn from(value: AssetPath<'a>) -> Self {
647        value.path().to_path_buf()
648    }
649}
650
651impl<'a> Serialize for AssetPath<'a> {
652    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
653    where
654        S: serde::Serializer,
655    {
656        self.to_string().serialize(serializer)
657    }
658}
659
660impl<'de> Deserialize<'de> for AssetPath<'static> {
661    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
662    where
663        D: serde::Deserializer<'de>,
664    {
665        deserializer.deserialize_string(AssetPathVisitor)
666    }
667}
668
669struct AssetPathVisitor;
670
671impl<'de> Visitor<'de> for AssetPathVisitor {
672    type Value = AssetPath<'static>;
673
674    fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
675        formatter.write_str("string AssetPath")
676    }
677
678    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
679    where
680        E: serde::de::Error,
681    {
682        match AssetPath::try_parse(v) {
683            Ok(path) => Ok(path.into_owned()),
684            Err(err) => Err(E::custom(err)),
685        }
686    }
687}
688
689/// Normalizes the path by collapsing all occurrences of '.' and '..' dot-segments where possible
690/// as per [RFC 1808](https://datatracker.ietf.org/doc/html/rfc1808)
691pub(crate) fn normalize_path(path: &Path) -> PathBuf {
692    let mut result_path = PathBuf::new();
693    for elt in path.iter() {
694        if elt == "." {
695            // Skip
696        } else if elt == ".." {
697            // Note: If the result_path ends in `..`, Path::file_name returns None, so we'll end up
698            // preserving it.
699            if result_path.file_name().is_some() {
700                // This assert is just a sanity check - we already know the path has a file_name, so
701                // we know there is something to pop.
702                assert!(result_path.pop());
703            } else {
704                // Preserve ".." if insufficient matches (per RFC 1808).
705                result_path.push(elt);
706            }
707        } else {
708            result_path.push(elt);
709        }
710    }
711    result_path
712}
713
714#[cfg(test)]
715mod tests {
716    use crate::AssetPath;
717    use std::path::Path;
718
719    #[test]
720    fn parse_asset_path() {
721        let result = AssetPath::parse_internal("a/b.test");
722        assert_eq!(result, Ok((None, Path::new("a/b.test"), None)));
723
724        let result = AssetPath::parse_internal("http://a/b.test");
725        assert_eq!(result, Ok((Some("http"), Path::new("a/b.test"), None)));
726
727        let result = AssetPath::parse_internal("http://a/b.test#Foo");
728        assert_eq!(
729            result,
730            Ok((Some("http"), Path::new("a/b.test"), Some("Foo")))
731        );
732
733        let result = AssetPath::parse_internal("localhost:80/b.test");
734        assert_eq!(result, Ok((None, Path::new("localhost:80/b.test"), None)));
735
736        let result = AssetPath::parse_internal("http://localhost:80/b.test");
737        assert_eq!(
738            result,
739            Ok((Some("http"), Path::new("localhost:80/b.test"), None))
740        );
741
742        let result = AssetPath::parse_internal("http://localhost:80/b.test#Foo");
743        assert_eq!(
744            result,
745            Ok((Some("http"), Path::new("localhost:80/b.test"), Some("Foo")))
746        );
747
748        let result = AssetPath::parse_internal("#insource://a/b.test");
749        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidSourceSyntax));
750
751        let result = AssetPath::parse_internal("source://a/b.test#://inlabel");
752        assert_eq!(result, Err(crate::ParseAssetPathError::InvalidLabelSyntax));
753
754        let result = AssetPath::parse_internal("#insource://a/b.test#://inlabel");
755        assert!(
756            result == Err(crate::ParseAssetPathError::InvalidSourceSyntax)
757                || result == Err(crate::ParseAssetPathError::InvalidLabelSyntax)
758        );
759
760        let result = AssetPath::parse_internal("http://");
761        assert_eq!(result, Ok((Some("http"), Path::new(""), None)));
762
763        let result = AssetPath::parse_internal("://x");
764        assert_eq!(result, Err(crate::ParseAssetPathError::MissingSource));
765
766        let result = AssetPath::parse_internal("a/b.test#");
767        assert_eq!(result, Err(crate::ParseAssetPathError::MissingLabel));
768    }
769
770    #[test]
771    fn test_serialize() {
772        assert!(ron::de::from_str::<AssetPath>("\"a/b.test\"").is_ok());
773        assert!(ron::de::from_str::<AssetPath>("\"a/b.test#\"").is_err());
774    }
775
776    #[test]
777    fn test_parent() {
778        // Parent consumes path segments, returns None when insufficient
779        let result = AssetPath::from("a/b.test");
780        assert_eq!(result.parent(), Some(AssetPath::from("a")));
781        assert_eq!(result.parent().unwrap().parent(), Some(AssetPath::from("")));
782        assert_eq!(result.parent().unwrap().parent().unwrap().parent(), None);
783
784        // Parent cannot consume asset source
785        let result = AssetPath::from("http://a");
786        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
787        assert_eq!(result.parent().unwrap().parent(), None);
788
789        // Parent consumes labels
790        let result = AssetPath::from("http://a#Foo");
791        assert_eq!(result.parent(), Some(AssetPath::from("http://")));
792    }
793
794    #[test]
795    fn test_with_source() {
796        let result = AssetPath::from("http://a#Foo");
797        assert_eq!(result.with_source("ftp"), AssetPath::from("ftp://a#Foo"));
798    }
799
800    #[test]
801    fn test_without_label() {
802        let result = AssetPath::from("http://a#Foo");
803        assert_eq!(result.without_label(), AssetPath::from("http://a"));
804    }
805
806    #[test]
807    fn test_resolve_full() {
808        // A "full" path should ignore the base path.
809        let base = AssetPath::from("alice/bob#carol");
810        assert_eq!(
811            base.resolve_str("/joe/next").unwrap(),
812            AssetPath::from("joe/next")
813        );
814        assert_eq!(
815            base.resolve(&AssetPath::parse("/joe/next")),
816            AssetPath::from("joe/next")
817        );
818        assert_eq!(
819            base.resolve_embed_str("/joe/next").unwrap(),
820            AssetPath::from("joe/next")
821        );
822        assert_eq!(
823            base.resolve_embed(&AssetPath::parse("/joe/next")),
824            AssetPath::from("joe/next")
825        );
826        assert_eq!(
827            base.resolve_str("/joe/next#dave").unwrap(),
828            AssetPath::from("joe/next#dave")
829        );
830        assert_eq!(
831            base.resolve(&AssetPath::parse("/joe/next#dave")),
832            AssetPath::from("joe/next#dave")
833        );
834        assert_eq!(
835            base.resolve_embed_str("/joe/next#dave").unwrap(),
836            AssetPath::from("joe/next#dave")
837        );
838        assert_eq!(
839            base.resolve_embed(&AssetPath::parse("/joe/next#dave")),
840            AssetPath::from("joe/next#dave")
841        );
842    }
843
844    #[test]
845    fn test_resolve_implicit_relative() {
846        // A path with no initial directory separator should be considered relative.
847        let base = AssetPath::from("alice/bob#carol");
848        assert_eq!(
849            base.resolve_str("joe/next").unwrap(),
850            AssetPath::from("alice/bob/joe/next")
851        );
852        assert_eq!(
853            base.resolve(&AssetPath::parse("joe/next")),
854            AssetPath::from("alice/bob/joe/next")
855        );
856        assert_eq!(
857            base.resolve_embed_str("joe/next").unwrap(),
858            AssetPath::from("alice/joe/next")
859        );
860        assert_eq!(
861            base.resolve_embed(&AssetPath::parse("joe/next")),
862            AssetPath::from("alice/joe/next")
863        );
864        assert_eq!(
865            base.resolve_str("joe/next#dave").unwrap(),
866            AssetPath::from("alice/bob/joe/next#dave")
867        );
868        assert_eq!(
869            base.resolve(&AssetPath::parse("joe/next#dave")),
870            AssetPath::from("alice/bob/joe/next#dave")
871        );
872        assert_eq!(
873            base.resolve_embed_str("joe/next#dave").unwrap(),
874            AssetPath::from("alice/joe/next#dave")
875        );
876        assert_eq!(
877            base.resolve_embed(&AssetPath::parse("joe/next#dave")),
878            AssetPath::from("alice/joe/next#dave")
879        );
880    }
881
882    #[test]
883    fn test_resolve_explicit_relative() {
884        // A path which begins with "./" or "../" is treated as relative
885        let base = AssetPath::from("alice/bob#carol");
886        assert_eq!(
887            base.resolve_str("./martin#dave").unwrap(),
888            AssetPath::from("alice/bob/martin#dave")
889        );
890        assert_eq!(
891            base.resolve(&AssetPath::parse("./martin#dave")),
892            AssetPath::from("alice/bob/martin#dave")
893        );
894        assert_eq!(
895            base.resolve_embed_str("./martin#dave").unwrap(),
896            AssetPath::from("alice/martin#dave")
897        );
898        assert_eq!(
899            base.resolve_embed(&AssetPath::parse("./martin#dave")),
900            AssetPath::from("alice/martin#dave")
901        );
902        assert_eq!(
903            base.resolve_str("../martin#dave").unwrap(),
904            AssetPath::from("alice/martin#dave")
905        );
906        assert_eq!(
907            base.resolve(&AssetPath::parse("../martin#dave")),
908            AssetPath::from("alice/martin#dave")
909        );
910        assert_eq!(
911            base.resolve_embed_str("../martin#dave").unwrap(),
912            AssetPath::from("martin#dave")
913        );
914        assert_eq!(
915            base.resolve_embed(&AssetPath::parse("../martin#dave")),
916            AssetPath::from("martin#dave")
917        );
918    }
919
920    #[test]
921    fn test_resolve_trailing_slash() {
922        // A path which begins with "./" or "../" is treated as relative
923        let base = AssetPath::from("alice/bob/");
924        assert_eq!(
925            base.resolve_str("./martin#dave").unwrap(),
926            AssetPath::from("alice/bob/martin#dave")
927        );
928        assert_eq!(
929            base.resolve(&AssetPath::parse("./martin#dave")),
930            AssetPath::from("alice/bob/martin#dave")
931        );
932        assert_eq!(
933            base.resolve_embed_str("./martin#dave").unwrap(),
934            AssetPath::from("alice/bob/martin#dave")
935        );
936        assert_eq!(
937            base.resolve_embed(&AssetPath::parse("./martin#dave")),
938            AssetPath::from("alice/bob/martin#dave")
939        );
940        assert_eq!(
941            base.resolve_str("../martin#dave").unwrap(),
942            AssetPath::from("alice/martin#dave")
943        );
944        assert_eq!(
945            base.resolve(&AssetPath::parse("../martin#dave")),
946            AssetPath::from("alice/martin#dave")
947        );
948        assert_eq!(
949            base.resolve_embed_str("../martin#dave").unwrap(),
950            AssetPath::from("alice/martin#dave")
951        );
952        assert_eq!(
953            base.resolve_embed(&AssetPath::parse("../martin#dave")),
954            AssetPath::from("alice/martin#dave")
955        );
956    }
957
958    #[test]
959    fn test_resolve_canonicalize() {
960        // Test that ".." and "." are removed after concatenation.
961        let base = AssetPath::from("alice/bob#carol");
962        assert_eq!(
963            base.resolve_str("./martin/stephan/..#dave").unwrap(),
964            AssetPath::from("alice/bob/martin#dave")
965        );
966        assert_eq!(
967            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
968            AssetPath::from("alice/bob/martin#dave")
969        );
970        assert_eq!(
971            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
972            AssetPath::from("alice/martin#dave")
973        );
974        assert_eq!(
975            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
976            AssetPath::from("alice/martin#dave")
977        );
978        assert_eq!(
979            base.resolve_str("../martin/.#dave").unwrap(),
980            AssetPath::from("alice/martin#dave")
981        );
982        assert_eq!(
983            base.resolve(&AssetPath::parse("../martin/.#dave")),
984            AssetPath::from("alice/martin#dave")
985        );
986        assert_eq!(
987            base.resolve_embed_str("../martin/.#dave").unwrap(),
988            AssetPath::from("martin#dave")
989        );
990        assert_eq!(
991            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
992            AssetPath::from("martin#dave")
993        );
994        assert_eq!(
995            base.resolve_str("/martin/stephan/..#dave").unwrap(),
996            AssetPath::from("martin#dave")
997        );
998        assert_eq!(
999            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1000            AssetPath::from("martin#dave")
1001        );
1002        assert_eq!(
1003            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1004            AssetPath::from("martin#dave")
1005        );
1006        assert_eq!(
1007            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1008            AssetPath::from("martin#dave")
1009        );
1010    }
1011
1012    #[test]
1013    fn test_resolve_canonicalize_base() {
1014        // Test that ".." and "." are removed after concatenation even from the base path.
1015        let base = AssetPath::from("alice/../bob#carol");
1016        assert_eq!(
1017            base.resolve_str("./martin/stephan/..#dave").unwrap(),
1018            AssetPath::from("bob/martin#dave")
1019        );
1020        assert_eq!(
1021            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1022            AssetPath::from("bob/martin#dave")
1023        );
1024        assert_eq!(
1025            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1026            AssetPath::from("martin#dave")
1027        );
1028        assert_eq!(
1029            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1030            AssetPath::from("martin#dave")
1031        );
1032        assert_eq!(
1033            base.resolve_str("../martin/.#dave").unwrap(),
1034            AssetPath::from("martin#dave")
1035        );
1036        assert_eq!(
1037            base.resolve(&AssetPath::parse("../martin/.#dave")),
1038            AssetPath::from("martin#dave")
1039        );
1040        assert_eq!(
1041            base.resolve_embed_str("../martin/.#dave").unwrap(),
1042            AssetPath::from("../martin#dave")
1043        );
1044        assert_eq!(
1045            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1046            AssetPath::from("../martin#dave")
1047        );
1048        assert_eq!(
1049            base.resolve_str("/martin/stephan/..#dave").unwrap(),
1050            AssetPath::from("martin#dave")
1051        );
1052        assert_eq!(
1053            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1054            AssetPath::from("martin#dave")
1055        );
1056        assert_eq!(
1057            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1058            AssetPath::from("martin#dave")
1059        );
1060        assert_eq!(
1061            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1062            AssetPath::from("martin#dave")
1063        );
1064    }
1065
1066    #[test]
1067    fn test_resolve_canonicalize_with_source() {
1068        // Test that ".." and "." are removed after concatenation.
1069        let base = AssetPath::from("source://alice/bob#carol");
1070        assert_eq!(
1071            base.resolve_str("./martin/stephan/..#dave").unwrap(),
1072            AssetPath::from("source://alice/bob/martin#dave")
1073        );
1074        assert_eq!(
1075            base.resolve(&AssetPath::parse("./martin/stephan/..#dave")),
1076            AssetPath::from("source://alice/bob/martin#dave")
1077        );
1078        assert_eq!(
1079            base.resolve_embed_str("./martin/stephan/..#dave").unwrap(),
1080            AssetPath::from("source://alice/martin#dave")
1081        );
1082        assert_eq!(
1083            base.resolve_embed(&AssetPath::parse("./martin/stephan/..#dave")),
1084            AssetPath::from("source://alice/martin#dave")
1085        );
1086        assert_eq!(
1087            base.resolve_str("../martin/.#dave").unwrap(),
1088            AssetPath::from("source://alice/martin#dave")
1089        );
1090        assert_eq!(
1091            base.resolve(&AssetPath::parse("../martin/.#dave")),
1092            AssetPath::from("source://alice/martin#dave")
1093        );
1094        assert_eq!(
1095            base.resolve_embed_str("../martin/.#dave").unwrap(),
1096            AssetPath::from("source://martin#dave")
1097        );
1098        assert_eq!(
1099            base.resolve_embed(&AssetPath::parse("../martin/.#dave")),
1100            AssetPath::from("source://martin#dave")
1101        );
1102        assert_eq!(
1103            base.resolve_str("/martin/stephan/..#dave").unwrap(),
1104            AssetPath::from("source://martin#dave")
1105        );
1106        assert_eq!(
1107            base.resolve(&AssetPath::parse("/martin/stephan/..#dave")),
1108            AssetPath::from("source://martin#dave")
1109        );
1110        assert_eq!(
1111            base.resolve_embed_str("/martin/stephan/..#dave").unwrap(),
1112            AssetPath::from("source://martin#dave")
1113        );
1114        assert_eq!(
1115            base.resolve_embed(&AssetPath::parse("/martin/stephan/..#dave")),
1116            AssetPath::from("source://martin#dave")
1117        );
1118    }
1119
1120    #[test]
1121    fn test_resolve_absolute() {
1122        // Paths beginning with '/' replace the base path
1123        let base = AssetPath::from("alice/bob#carol");
1124        assert_eq!(
1125            base.resolve_str("/martin/stephan").unwrap(),
1126            AssetPath::from("martin/stephan")
1127        );
1128        assert_eq!(
1129            base.resolve(&AssetPath::parse("/martin/stephan")),
1130            AssetPath::from("martin/stephan")
1131        );
1132        assert_eq!(
1133            base.resolve_embed_str("/martin/stephan").unwrap(),
1134            AssetPath::from("martin/stephan")
1135        );
1136        assert_eq!(
1137            base.resolve_embed(&AssetPath::parse("/martin/stephan")),
1138            AssetPath::from("martin/stephan")
1139        );
1140        assert_eq!(
1141            base.resolve_str("/martin/stephan#dave").unwrap(),
1142            AssetPath::from("martin/stephan/#dave")
1143        );
1144        assert_eq!(
1145            base.resolve(&AssetPath::parse("/martin/stephan#dave")),
1146            AssetPath::from("martin/stephan/#dave")
1147        );
1148        assert_eq!(
1149            base.resolve_embed_str("/martin/stephan#dave").unwrap(),
1150            AssetPath::from("martin/stephan/#dave")
1151        );
1152        assert_eq!(
1153            base.resolve_embed(&AssetPath::parse("/martin/stephan#dave")),
1154            AssetPath::from("martin/stephan/#dave")
1155        );
1156    }
1157
1158    #[test]
1159    fn test_resolve_asset_source() {
1160        // Paths beginning with 'source://' replace the base path
1161        let base = AssetPath::from("alice/bob#carol");
1162        assert_eq!(
1163            base.resolve_str("source://martin/stephan").unwrap(),
1164            AssetPath::from("source://martin/stephan")
1165        );
1166        assert_eq!(
1167            base.resolve(&AssetPath::parse("source://martin/stephan")),
1168            AssetPath::from("source://martin/stephan")
1169        );
1170        assert_eq!(
1171            base.resolve_embed_str("source://martin/stephan").unwrap(),
1172            AssetPath::from("source://martin/stephan")
1173        );
1174        assert_eq!(
1175            base.resolve_embed(&AssetPath::parse("source://martin/stephan")),
1176            AssetPath::from("source://martin/stephan")
1177        );
1178        assert_eq!(
1179            base.resolve_str("source://martin/stephan#dave").unwrap(),
1180            AssetPath::from("source://martin/stephan/#dave")
1181        );
1182        assert_eq!(
1183            base.resolve(&AssetPath::parse("source://martin/stephan#dave")),
1184            AssetPath::from("source://martin/stephan/#dave")
1185        );
1186        assert_eq!(
1187            base.resolve_embed_str("source://martin/stephan#dave")
1188                .unwrap(),
1189            AssetPath::from("source://martin/stephan/#dave")
1190        );
1191        assert_eq!(
1192            base.resolve_embed(&AssetPath::parse("source://martin/stephan#dave")),
1193            AssetPath::from("source://martin/stephan/#dave")
1194        );
1195    }
1196
1197    #[test]
1198    fn test_resolve_label() {
1199        // A relative path with only a label should replace the label portion
1200        let base = AssetPath::from("alice/bob#carol");
1201        assert_eq!(
1202            base.resolve_str("#dave").unwrap(),
1203            AssetPath::from("alice/bob#dave")
1204        );
1205        assert_eq!(
1206            base.resolve(&AssetPath::parse("#dave")),
1207            AssetPath::from("alice/bob#dave")
1208        );
1209        assert_eq!(
1210            base.resolve_embed_str("#dave").unwrap(),
1211            AssetPath::from("alice/bob#dave")
1212        );
1213        assert_eq!(
1214            base.resolve_embed(&AssetPath::parse("#dave")),
1215            AssetPath::from("alice/bob#dave")
1216        );
1217    }
1218
1219    #[test]
1220    fn test_resolve_insufficient_elements() {
1221        // Ensure that ".." segments are preserved if there are insufficient elements to remove them.
1222        let base = AssetPath::from("alice/bob#carol");
1223        assert_eq!(
1224            base.resolve_str("../../joe/next").unwrap(),
1225            AssetPath::from("joe/next")
1226        );
1227        assert_eq!(
1228            base.resolve(&AssetPath::parse("../../joe/next")),
1229            AssetPath::from("joe/next")
1230        );
1231        assert_eq!(
1232            base.resolve_embed_str("../../joe/next").unwrap(),
1233            AssetPath::from("../joe/next")
1234        );
1235        assert_eq!(
1236            base.resolve_embed(&AssetPath::parse("../../joe/next")),
1237            AssetPath::from("../joe/next")
1238        );
1239    }
1240
1241    #[test]
1242    fn resolve_embed_relative_to_external_path() {
1243        let base = AssetPath::from("../../a/b.gltf");
1244        assert_eq!(
1245            base.resolve_embed_str("c.bin").unwrap(),
1246            AssetPath::from("../../a/c.bin")
1247        );
1248        assert_eq!(
1249            base.resolve_embed(&AssetPath::parse("c.bin")),
1250            AssetPath::from("../../a/c.bin")
1251        );
1252    }
1253
1254    #[test]
1255    fn resolve_relative_to_external_path() {
1256        let base = AssetPath::from("../../a/b.gltf");
1257        assert_eq!(
1258            base.resolve_str("c.bin").unwrap(),
1259            AssetPath::from("../../a/b.gltf/c.bin")
1260        );
1261        assert_eq!(
1262            base.resolve(&AssetPath::parse("c.bin")),
1263            AssetPath::from("../../a/b.gltf/c.bin")
1264        );
1265    }
1266
1267    #[test]
1268    fn test_get_full_extension() {
1269        let result = AssetPath::from("http://a.tar.gz#Foo");
1270        assert_eq!(result.get_full_extension(), Some("tar.gz"));
1271
1272        let result = AssetPath::from("http://a#Foo");
1273        assert_eq!(result.get_full_extension(), None);
1274
1275        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1276        assert_eq!(result.get_full_extension(), Some("tar.bz2"));
1277
1278        let result = AssetPath::from("asset.Custom");
1279        assert_eq!(result.get_full_extension(), Some("Custom"));
1280    }
1281
1282    #[test]
1283    fn test_get_extension() {
1284        let result = AssetPath::from("http://a.tar.gz#Foo");
1285        assert_eq!(result.get_extension(), Some("gz"));
1286
1287        let result = AssetPath::from("http://a#Foo");
1288        assert_eq!(result.get_extension(), None);
1289
1290        let result = AssetPath::from("http://a.tar.bz2?foo=bar#Baz");
1291        assert_eq!(result.get_extension(), Some("bz2"));
1292
1293        let result = AssetPath::from("asset.Custom");
1294        assert_eq!(result.get_extension(), Some("Custom"));
1295    }
1296}