basalt_types/
identifier.rs1use std::fmt;
2
3use crate::{Decode, Encode, EncodedSize, Error, Result, VarInt};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct Identifier {
19 pub namespace: String,
21 pub path: String,
23}
24
25impl Identifier {
26 pub fn new(namespace: impl Into<String>, path: impl Into<String>) -> Result<Self> {
31 let namespace = namespace.into();
32 let path = path.into();
33
34 if !namespace.chars().all(is_valid_namespace_char) {
35 return Err(Error::InvalidData(format!(
36 "invalid namespace character in '{namespace}'"
37 )));
38 }
39 if !path.chars().all(is_valid_path_char) {
40 return Err(Error::InvalidData(format!(
41 "invalid path character in '{path}'"
42 )));
43 }
44
45 Ok(Self { namespace, path })
46 }
47
48 pub fn minecraft(path: impl Into<String>) -> Result<Self> {
53 Self::new("minecraft", path)
54 }
55
56 #[deprecated(note = "use Display impl via to_string() instead")]
61 pub fn as_str(&self) -> String {
62 self.to_string()
63 }
64}
65
66fn is_valid_namespace_char(c: char) -> bool {
71 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-'
72}
73
74fn is_valid_path_char(c: char) -> bool {
80 is_valid_namespace_char(c) || c == '/'
81}
82
83fn parse_identifier(s: &str) -> Result<Identifier> {
89 if s.is_empty() {
90 return Err(Error::InvalidData("empty identifier".into()));
91 }
92
93 let (namespace, path) = match s.find(':') {
94 Some(pos) => (&s[..pos], &s[pos + 1..]),
95 None => ("minecraft", s),
96 };
97
98 Identifier::new(namespace, path)
99}
100
101impl Encode for Identifier {
107 fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
109 self.to_string().encode(buf)
110 }
111}
112
113impl Decode for Identifier {
120 fn decode(buf: &mut &[u8]) -> Result<Self> {
126 let s = String::decode(buf)?;
127 parse_identifier(&s)
128 }
129}
130
131impl EncodedSize for Identifier {
136 fn encoded_size(&self) -> usize {
138 let str_len = self.namespace.len() + 1 + self.path.len();
139 VarInt(str_len as i32).encoded_size() + str_len
140 }
141}
142
143impl fmt::Display for Identifier {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(f, "{}:{}", self.namespace, self.path)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn roundtrip(namespace: &str, path: &str) {
155 let id = Identifier::new(namespace, path).unwrap();
156 let mut buf = Vec::with_capacity(id.encoded_size());
157 id.encode(&mut buf).unwrap();
158 assert_eq!(buf.len(), id.encoded_size());
159
160 let mut cursor = buf.as_slice();
161 let decoded = Identifier::decode(&mut cursor).unwrap();
162 assert!(cursor.is_empty());
163 assert_eq!(decoded, id);
164 }
165
166 #[test]
169 fn new_valid() {
170 let id = Identifier::new("minecraft", "stone").unwrap();
171 assert_eq!(id.namespace, "minecraft");
172 assert_eq!(id.path, "stone");
173 }
174
175 #[test]
176 fn minecraft_shorthand() {
177 let id = Identifier::minecraft("diamond").unwrap();
178 assert_eq!(id.namespace, "minecraft");
179 assert_eq!(id.path, "diamond");
180 }
181
182 #[test]
183 fn custom_namespace() {
184 let id = Identifier::new("mymod", "custom_block").unwrap();
185 assert_eq!(id.namespace, "mymod");
186 assert_eq!(id.path, "custom_block");
187 }
188
189 #[test]
190 fn path_with_slashes() {
191 let id = Identifier::new("minecraft", "textures/block/dirt").unwrap();
192 assert_eq!(id.path, "textures/block/dirt");
193 }
194
195 #[test]
196 fn invalid_namespace_uppercase() {
197 assert!(Identifier::new("Minecraft", "stone").is_err());
198 }
199
200 #[test]
201 fn invalid_namespace_space() {
202 assert!(Identifier::new("my mod", "stone").is_err());
203 }
204
205 #[test]
206 fn invalid_path_uppercase() {
207 assert!(Identifier::new("minecraft", "Stone").is_err());
208 }
209
210 #[test]
211 fn valid_special_chars() {
212 assert!(Identifier::new("my-mod.v2", "custom_item-v3").is_ok());
213 }
214
215 #[test]
218 fn parse_with_namespace() {
219 let id = parse_identifier("minecraft:stone").unwrap();
220 assert_eq!(id.namespace, "minecraft");
221 assert_eq!(id.path, "stone");
222 }
223
224 #[test]
225 fn parse_without_namespace() {
226 let id = parse_identifier("stone").unwrap();
227 assert_eq!(id.namespace, "minecraft");
228 assert_eq!(id.path, "stone");
229 }
230
231 #[test]
232 fn parse_empty() {
233 assert!(parse_identifier("").is_err());
234 }
235
236 #[test]
237 fn parse_custom_namespace() {
238 let id = parse_identifier("mymod:custom_block").unwrap();
239 assert_eq!(id.namespace, "mymod");
240 assert_eq!(id.path, "custom_block");
241 }
242
243 #[test]
246 fn roundtrip_minecraft() {
247 roundtrip("minecraft", "stone");
248 }
249
250 #[test]
251 fn roundtrip_custom() {
252 roundtrip("mymod", "custom_block");
253 }
254
255 #[test]
256 fn roundtrip_with_path_slashes() {
257 roundtrip("minecraft", "textures/block/dirt");
258 }
259
260 #[test]
261 fn decode_bare_path() {
262 let mut buf = Vec::new();
264 "stone".to_string().encode(&mut buf).unwrap();
265
266 let mut cursor = buf.as_slice();
267 let id = Identifier::decode(&mut cursor).unwrap();
268 assert_eq!(id.namespace, "minecraft");
269 assert_eq!(id.path, "stone");
270 }
271
272 #[test]
275 fn display() {
276 let id = Identifier::new("minecraft", "stone").unwrap();
277 assert_eq!(id.to_string(), "minecraft:stone");
278 }
279
280 #[test]
281 fn to_string_format() {
282 let id = Identifier::new("mymod", "item").unwrap();
283 assert_eq!(id.to_string(), "mymod:item");
284 }
285
286 #[test]
289 fn encoded_size_includes_colon() {
290 let id = Identifier::new("minecraft", "stone").unwrap();
291 assert_eq!(id.encoded_size(), 16);
293 }
294
295 mod proptests {
296 use super::*;
297 use proptest::prelude::*;
298
299 fn namespace_strategy() -> impl Strategy<Value = String> {
300 "[a-z0-9._\\-]{1,20}"
301 }
302
303 fn path_strategy() -> impl Strategy<Value = String> {
304 "[a-z0-9._\\-/]{1,50}"
305 }
306
307 proptest! {
308 #[test]
309 fn identifier_roundtrip(
310 namespace in namespace_strategy(),
311 path in path_strategy(),
312 ) {
313 roundtrip(&namespace, &path);
314 }
315 }
316 }
317}