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}