rakata_formats/gff/
label.rs1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3use thiserror::Error;
4
5pub const MAX_GFF_LABEL_LEN: usize = 16;
7
8#[derive(Debug, Clone, PartialEq, Eq, Error)]
10pub enum GffLabelError {
11 #[error("label length {len} exceeds maximum {max}")]
13 TooLong {
14 len: usize,
16 max: usize,
18 },
19 #[error("invalid GFF label character '{ch}'")]
21 InvalidChar {
22 ch: char,
24 },
25}
26
27#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
38pub struct GffLabel {
39 bytes: [u8; MAX_GFF_LABEL_LEN],
40 len: u8,
41}
42
43impl GffLabel {
44 pub fn new(value: impl AsRef<str>) -> Result<Self, GffLabelError> {
60 let raw = value.as_ref();
61 if raw.len() > MAX_GFF_LABEL_LEN {
62 return Err(GffLabelError::TooLong {
63 len: raw.len(),
64 max: MAX_GFF_LABEL_LEN,
65 });
66 }
67 let mut bytes = [0u8; MAX_GFF_LABEL_LEN];
68 for (i, ch) in raw.chars().enumerate() {
69 if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == ' ') {
70 return Err(GffLabelError::InvalidChar { ch });
71 }
72 bytes[i] = u8::try_from(u32::from(ch)).expect("ASCII char is guaranteed to fit in u8");
74 }
75 let len = u8::try_from(raw.len()).expect("len already bounded by MAX_GFF_LABEL_LEN");
76 Ok(Self { bytes, len })
77 }
78
79 #[allow(clippy::as_conversions)]
103 pub const fn from_static(value: &'static str) -> Self {
104 let bytes = value.as_bytes();
105 let len = bytes.len();
106 assert!(len <= MAX_GFF_LABEL_LEN, "GFF label exceeds 16 bytes");
107
108 let mut storage = [0u8; MAX_GFF_LABEL_LEN];
109 let mut i = 0;
110 while i < len {
111 let b = bytes[i];
112 assert!(
113 (b >= b'A' && b <= b'Z')
114 || (b >= b'a' && b <= b'z')
115 || (b >= b'0' && b <= b'9')
116 || b == b'_'
117 || b == b' ',
118 "GFF label contains invalid character (allowed: [A-Za-z0-9_ ])"
119 );
120 storage[i] = b;
121 i += 1;
122 }
123
124 Self {
128 bytes: storage,
129 len: len as u8,
130 }
131 }
132
133 pub fn as_str(&self) -> &str {
135 std::str::from_utf8(&self.bytes[..usize::from(self.len)])
138 .expect("all bytes are validated ASCII during construction")
139 }
140
141 #[allow(clippy::as_conversions)]
143 pub const fn len(&self) -> usize {
144 self.len as usize
145 }
146
147 pub const fn is_empty(&self) -> bool {
149 self.len == 0
150 }
151}
152
153#[macro_export]
173macro_rules! gff_label {
174 ($literal:literal) => {
175 const { $crate::gff::GffLabel::from_static($literal) }
176 };
177}
178
179impl std::fmt::Debug for GffLabel {
180 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
181 write!(f, "GffLabel({:?})", self.as_str())
182 }
183}
184
185impl Display for GffLabel {
186 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
187 f.write_str(self.as_str())
188 }
189}
190
191impl FromStr for GffLabel {
192 type Err = GffLabelError;
193
194 fn from_str(s: &str) -> Result<Self, Self::Err> {
195 Self::new(s)
196 }
197}
198
199impl TryFrom<&str> for GffLabel {
200 type Error = GffLabelError;
201
202 fn try_from(value: &str) -> Result<Self, Self::Error> {
203 Self::new(value)
204 }
205}
206
207impl TryFrom<String> for GffLabel {
208 type Error = GffLabelError;
209
210 fn try_from(value: String) -> Result<Self, Self::Error> {
211 Self::new(value)
212 }
213}
214
215impl From<GffLabel> for String {
216 fn from(val: GffLabel) -> Self {
217 val.as_str().to_owned()
218 }
219}
220
221impl AsRef<str> for GffLabel {
222 fn as_ref(&self) -> &str {
223 self.as_str()
224 }
225}
226
227impl PartialEq<str> for GffLabel {
228 fn eq(&self, other: &str) -> bool {
229 self.as_str() == other
230 }
231}
232
233impl PartialEq<&str> for GffLabel {
234 fn eq(&self, other: &&str) -> bool {
235 self.as_str() == *other
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn accepts_valid_label() {
245 let parsed = GffLabel::new("Equip_ItemList").expect("valid label");
246 assert_eq!(parsed.as_str(), "Equip_ItemList");
247 }
248
249 #[test]
250 fn rejects_too_long_label() {
251 let err = GffLabel::new("this_name_is_longer_than_sixteen").expect_err("must fail");
252 assert!(matches!(err, GffLabelError::TooLong { .. }));
253 }
254
255 #[test]
256 fn rejects_invalid_characters() {
257 let err = GffLabel::new("bad!name").expect_err("must fail");
258 assert_eq!(err, GffLabelError::InvalidChar { ch: '!' });
259 }
260
261 #[test]
262 fn is_copy() {
263 fn takes_copy<T: Copy>(_: T) {}
264 takes_copy(GffLabel::new("Test").unwrap());
265 }
266
267 #[test]
268 fn roundtrip_through_string() {
269 let original = GffLabel::new("test_label").expect("valid");
270 let s: String = original.into();
271 assert_eq!(s, "test_label");
272 let back: GffLabel = s.try_into().expect("valid");
273 assert_eq!(back, original);
274 }
275
276 #[test]
277 fn from_static_accepts_valid_label() {
278 const TAG: GffLabel = GffLabel::from_static("Tag");
279 assert_eq!(TAG.as_str(), "Tag");
280 assert_eq!(TAG.len(), 3);
281 }
282
283 #[test]
284 fn from_static_accepts_label_with_space() {
285 const LIST: GffLabel = GffLabel::from_static("Creature List");
288 assert_eq!(LIST.as_str(), "Creature List");
289 }
290
291 #[test]
292 fn from_static_accepts_max_length() {
293 const MAX: GffLabel = GffLabel::from_static("a234567890123456");
294 assert_eq!(MAX.len(), 16);
295 }
296
297 #[test]
298 fn from_static_matches_new_for_valid_input() {
299 let runtime = GffLabel::new("Equip_ItemList").expect("valid");
300 const COMPILE_TIME: GffLabel = GffLabel::from_static("Equip_ItemList");
301 assert_eq!(runtime, COMPILE_TIME);
302 }
303
304 #[test]
305 #[should_panic(expected = "GFF label exceeds 16 bytes")]
306 fn from_static_rejects_too_long_at_runtime() {
307 let _ = GffLabel::from_static("this_label_is_longer_than_sixteen");
311 }
312
313 #[test]
314 #[should_panic(expected = "GFF label contains invalid character")]
315 fn from_static_rejects_invalid_char_at_runtime() {
316 let _ = GffLabel::from_static("bad!name");
317 }
318}