Skip to main content

goud_engine/assets/handle/
path.rs

1//! Path-based asset identifiers.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::hash::Hash;
6use std::path::Path;
7
8// =============================================================================
9// AssetPath
10// =============================================================================
11
12/// A path identifier for assets.
13///
14/// `AssetPath` represents the path used to load an asset, supporting both
15/// owned and borrowed string data. It provides utility methods for working
16/// with asset paths.
17///
18/// # Path Format
19///
20/// Asset paths use forward slashes as separators, regardless of platform:
21/// - `textures/player.png`
22/// - `audio/music/theme.ogg`
23/// - `shaders/basic.vert`
24///
25/// # FFI Considerations
26///
27/// For FFI, convert to a C string using `as_str()` and standard FFI string
28/// handling. The path does not include a null terminator by default.
29///
30/// # Example
31///
32/// ```
33/// use goud_engine::assets::AssetPath;
34///
35/// let path = AssetPath::new("textures/player.png");
36///
37/// assert_eq!(path.as_str(), "textures/player.png");
38/// assert_eq!(path.file_name(), Some("player.png"));
39/// assert_eq!(path.extension(), Some("png"));
40/// assert_eq!(path.directory(), Some("textures"));
41///
42/// // From owned string
43/// let owned = AssetPath::from_string("audio/sfx/jump.wav".to_string());
44/// assert_eq!(owned.extension(), Some("wav"));
45/// ```
46#[derive(Clone, PartialEq, Eq, Hash)]
47pub struct AssetPath<'a> {
48    /// The path string, either borrowed or owned.
49    path: Cow<'a, str>,
50}
51
52impl<'a> AssetPath<'a> {
53    /// Creates a new asset path from a string slice.
54    ///
55    /// # Example
56    ///
57    /// ```
58    /// use goud_engine::assets::AssetPath;
59    ///
60    /// let path = AssetPath::new("textures/player.png");
61    /// assert_eq!(path.as_str(), "textures/player.png");
62    /// ```
63    #[inline]
64    pub fn new(path: &'a str) -> Self {
65        Self {
66            path: Cow::Borrowed(path),
67        }
68    }
69
70    /// Creates a new asset path from an owned string.
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use goud_engine::assets::AssetPath;
76    ///
77    /// let path = AssetPath::from_string("textures/player.png".to_string());
78    /// assert_eq!(path.as_str(), "textures/player.png");
79    /// ```
80    #[inline]
81    pub fn from_string(path: String) -> AssetPath<'static> {
82        AssetPath {
83            path: Cow::Owned(path),
84        }
85    }
86
87    /// Returns the path as a string slice.
88    #[inline]
89    pub fn as_str(&self) -> &str {
90        &self.path
91    }
92
93    /// Returns `true` if the path is empty.
94    #[inline]
95    pub fn is_empty(&self) -> bool {
96        self.path.is_empty()
97    }
98
99    /// Returns the length of the path in bytes.
100    #[inline]
101    pub fn len(&self) -> usize {
102        self.path.len()
103    }
104
105    /// Returns the file name component of the path.
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use goud_engine::assets::AssetPath;
111    ///
112    /// assert_eq!(AssetPath::new("textures/player.png").file_name(), Some("player.png"));
113    /// assert_eq!(AssetPath::new("player.png").file_name(), Some("player.png"));
114    /// assert_eq!(AssetPath::new("textures/").file_name(), None);
115    /// ```
116    pub fn file_name(&self) -> Option<&str> {
117        let path = self.path.as_ref();
118        if path.ends_with('/') {
119            return None;
120        }
121        path.rsplit('/').next().filter(|s| !s.is_empty())
122    }
123
124    /// Returns the file extension, if any.
125    ///
126    /// # Example
127    ///
128    /// ```
129    /// use goud_engine::assets::AssetPath;
130    ///
131    /// assert_eq!(AssetPath::new("player.png").extension(), Some("png"));
132    /// assert_eq!(AssetPath::new("textures/player.png").extension(), Some("png"));
133    /// assert_eq!(AssetPath::new("Makefile").extension(), None);
134    /// assert_eq!(AssetPath::new(".gitignore").extension(), None);
135    /// ```
136    pub fn extension(&self) -> Option<&str> {
137        let file_name = self.file_name()?;
138        let dot_pos = file_name.rfind('.')?;
139
140        // Handle hidden files like ".gitignore" (no extension)
141        if dot_pos == 0 {
142            return None;
143        }
144
145        Some(&file_name[dot_pos + 1..])
146    }
147
148    /// Returns the directory component of the path.
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// use goud_engine::assets::AssetPath;
154    ///
155    /// assert_eq!(AssetPath::new("textures/player.png").directory(), Some("textures"));
156    /// assert_eq!(AssetPath::new("a/b/c/file.txt").directory(), Some("a/b/c"));
157    /// assert_eq!(AssetPath::new("file.txt").directory(), None);
158    /// ```
159    pub fn directory(&self) -> Option<&str> {
160        let path = self.path.as_ref();
161        let pos = path.rfind('/')?;
162        if pos == 0 {
163            return None;
164        }
165        Some(&path[..pos])
166    }
167
168    /// Returns the file stem (file name without extension).
169    ///
170    /// # Example
171    ///
172    /// ```
173    /// use goud_engine::assets::AssetPath;
174    ///
175    /// assert_eq!(AssetPath::new("player.png").stem(), Some("player"));
176    /// assert_eq!(AssetPath::new("textures/player.png").stem(), Some("player"));
177    /// assert_eq!(AssetPath::new("archive.tar.gz").stem(), Some("archive.tar"));
178    /// assert_eq!(AssetPath::new(".gitignore").stem(), Some(".gitignore"));
179    /// ```
180    pub fn stem(&self) -> Option<&str> {
181        let file_name = self.file_name()?;
182        if let Some(dot_pos) = file_name.rfind('.') {
183            if dot_pos == 0 {
184                // Hidden file with no extension
185                Some(file_name)
186            } else {
187                Some(&file_name[..dot_pos])
188            }
189        } else {
190            // No extension
191            Some(file_name)
192        }
193    }
194
195    /// Converts this path to an owned `AssetPath<'static>`.
196    ///
197    /// If the path is already owned, this is a no-op. If borrowed,
198    /// the string is cloned.
199    pub fn into_owned(self) -> AssetPath<'static> {
200        AssetPath {
201            path: Cow::Owned(self.path.into_owned()),
202        }
203    }
204
205    /// Creates an `AssetPath` from a `std::path::Path`.
206    ///
207    /// Converts backslashes to forward slashes for platform consistency.
208    ///
209    /// # Example
210    ///
211    /// ```
212    /// use goud_engine::assets::AssetPath;
213    /// use std::path::Path;
214    ///
215    /// let path = AssetPath::from_path(Path::new("textures/player.png"));
216    /// assert_eq!(path.as_str(), "textures/player.png");
217    /// ```
218    pub fn from_path(path: &Path) -> AssetPath<'static> {
219        let path_str = path.to_string_lossy();
220        // Normalize to forward slashes
221        let normalized = path_str.replace('\\', "/");
222        AssetPath::from_string(normalized)
223    }
224
225    /// Joins this path with another path component.
226    ///
227    /// # Example
228    ///
229    /// ```
230    /// use goud_engine::assets::AssetPath;
231    ///
232    /// let base = AssetPath::new("textures");
233    /// let full = base.join("player.png");
234    /// assert_eq!(full.as_str(), "textures/player.png");
235    ///
236    /// // Handles trailing slashes
237    /// let base = AssetPath::new("textures/");
238    /// let full = base.join("player.png");
239    /// assert_eq!(full.as_str(), "textures/player.png");
240    /// ```
241    pub fn join(&self, other: &str) -> AssetPath<'static> {
242        let base = self.path.trim_end_matches('/');
243        let other = other.trim_start_matches('/');
244
245        if base.is_empty() {
246            AssetPath::from_string(other.to_string())
247        } else if other.is_empty() {
248            AssetPath::from_string(base.to_string())
249        } else {
250            AssetPath::from_string(format!("{}/{}", base, other))
251        }
252    }
253
254    /// Returns the path with a different extension.
255    ///
256    /// # Example
257    ///
258    /// ```
259    /// use goud_engine::assets::AssetPath;
260    ///
261    /// let path = AssetPath::new("textures/player.png");
262    /// let new_path = path.with_extension("jpg");
263    /// assert_eq!(new_path.as_str(), "textures/player.jpg");
264    ///
265    /// // Add extension to file without one
266    /// let path = AssetPath::new("Makefile");
267    /// let new_path = path.with_extension("bak");
268    /// assert_eq!(new_path.as_str(), "Makefile.bak");
269    /// ```
270    pub fn with_extension(&self, ext: &str) -> AssetPath<'static> {
271        if let Some(stem) = self.stem() {
272            if let Some(dir) = self.directory() {
273                AssetPath::from_string(format!("{}/{}.{}", dir, stem, ext))
274            } else {
275                AssetPath::from_string(format!("{}.{}", stem, ext))
276            }
277        } else {
278            // No file name, just append
279            AssetPath::from_string(format!("{}.{}", self.path, ext))
280        }
281    }
282}
283
284impl<'a> fmt::Debug for AssetPath<'a> {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        write!(f, "AssetPath({:?})", self.path)
287    }
288}
289
290impl<'a> fmt::Display for AssetPath<'a> {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        write!(f, "{}", self.path)
293    }
294}
295
296impl<'a> AsRef<str> for AssetPath<'a> {
297    #[inline]
298    fn as_ref(&self) -> &str {
299        &self.path
300    }
301}
302
303impl<'a> From<&'a str> for AssetPath<'a> {
304    #[inline]
305    fn from(s: &'a str) -> Self {
306        Self::new(s)
307    }
308}
309
310impl From<String> for AssetPath<'static> {
311    #[inline]
312    fn from(s: String) -> Self {
313        Self::from_string(s)
314    }
315}
316
317impl<'a> PartialEq<str> for AssetPath<'a> {
318    #[inline]
319    fn eq(&self, other: &str) -> bool {
320        self.path.as_ref() == other
321    }
322}
323
324impl<'a> PartialEq<&str> for AssetPath<'a> {
325    #[inline]
326    fn eq(&self, other: &&str) -> bool {
327        self.path.as_ref() == *other
328    }
329}