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> {
48 let raw = value.as_ref();
49 if raw.len() > MAX_GFF_LABEL_LEN {
50 return Err(GffLabelError::TooLong {
51 len: raw.len(),
52 max: MAX_GFF_LABEL_LEN,
53 });
54 }
55 let mut bytes = [0u8; MAX_GFF_LABEL_LEN];
56 for (i, ch) in raw.chars().enumerate() {
57 if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == ' ') {
58 return Err(GffLabelError::InvalidChar { ch });
59 }
60 bytes[i] = u8::try_from(u32::from(ch)).expect("ASCII char is guaranteed to fit in u8");
62 }
63 let len = u8::try_from(raw.len()).expect("len already bounded by MAX_GFF_LABEL_LEN");
64 Ok(Self { bytes, len })
65 }
66
67 #[allow(clippy::as_conversions)]
91 pub const fn from_static(value: &'static str) -> Self {
92 let bytes = value.as_bytes();
93 let len = bytes.len();
94 assert!(len <= MAX_GFF_LABEL_LEN, "GFF label exceeds 16 bytes");
95
96 let mut storage = [0u8; MAX_GFF_LABEL_LEN];
97 let mut i = 0;
98 while i < len {
99 let b = bytes[i];
100 assert!(
101 (b >= b'A' && b <= b'Z')
102 || (b >= b'a' && b <= b'z')
103 || (b >= b'0' && b <= b'9')
104 || b == b'_'
105 || b == b' ',
106 "GFF label contains invalid character (allowed: [A-Za-z0-9_ ])"
107 );
108 storage[i] = b;
109 i += 1;
110 }
111
112 Self {
116 bytes: storage,
117 len: len as u8,
118 }
119 }
120
121 pub fn as_str(&self) -> &str {
123 std::str::from_utf8(&self.bytes[..usize::from(self.len)])
126 .expect("all bytes are validated ASCII during construction")
127 }
128
129 #[allow(clippy::as_conversions)]
131 pub const fn len(&self) -> usize {
132 self.len as usize
133 }
134
135 pub const fn is_empty(&self) -> bool {
137 self.len == 0
138 }
139}
140
141impl std::fmt::Debug for GffLabel {
142 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143 write!(f, "GffLabel({:?})", self.as_str())
144 }
145}
146
147impl Display for GffLabel {
148 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
149 f.write_str(self.as_str())
150 }
151}
152
153impl FromStr for GffLabel {
154 type Err = GffLabelError;
155
156 fn from_str(s: &str) -> Result<Self, Self::Err> {
157 Self::new(s)
158 }
159}
160
161impl TryFrom<&str> for GffLabel {
162 type Error = GffLabelError;
163
164 fn try_from(value: &str) -> Result<Self, Self::Error> {
165 Self::new(value)
166 }
167}
168
169impl TryFrom<String> for GffLabel {
170 type Error = GffLabelError;
171
172 fn try_from(value: String) -> Result<Self, Self::Error> {
173 Self::new(value)
174 }
175}
176
177impl From<GffLabel> for String {
178 fn from(val: GffLabel) -> Self {
179 val.as_str().to_owned()
180 }
181}
182
183impl AsRef<str> for GffLabel {
184 fn as_ref(&self) -> &str {
185 self.as_str()
186 }
187}
188
189impl PartialEq<str> for GffLabel {
190 fn eq(&self, other: &str) -> bool {
191 self.as_str() == other
192 }
193}
194
195impl PartialEq<&str> for GffLabel {
196 fn eq(&self, other: &&str) -> bool {
197 self.as_str() == *other
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn accepts_valid_label() {
207 let parsed = GffLabel::new("Equip_ItemList").expect("valid label");
208 assert_eq!(parsed.as_str(), "Equip_ItemList");
209 }
210
211 #[test]
212 fn rejects_too_long_label() {
213 let err = GffLabel::new("this_name_is_longer_than_sixteen").expect_err("must fail");
214 assert!(matches!(err, GffLabelError::TooLong { .. }));
215 }
216
217 #[test]
218 fn rejects_invalid_characters() {
219 let err = GffLabel::new("bad!name").expect_err("must fail");
220 assert_eq!(err, GffLabelError::InvalidChar { ch: '!' });
221 }
222
223 #[test]
224 fn is_copy() {
225 fn takes_copy<T: Copy>(_: T) {}
226 takes_copy(GffLabel::new("Test").unwrap());
227 }
228
229 #[test]
230 fn roundtrip_through_string() {
231 let original = GffLabel::new("test_label").expect("valid");
232 let s: String = original.into();
233 assert_eq!(s, "test_label");
234 let back: GffLabel = s.try_into().expect("valid");
235 assert_eq!(back, original);
236 }
237
238 #[test]
239 fn from_static_accepts_valid_label() {
240 const TAG: GffLabel = GffLabel::from_static("Tag");
241 assert_eq!(TAG.as_str(), "Tag");
242 assert_eq!(TAG.len(), 3);
243 }
244
245 #[test]
246 fn from_static_accepts_label_with_space() {
247 const LIST: GffLabel = GffLabel::from_static("Creature List");
250 assert_eq!(LIST.as_str(), "Creature List");
251 }
252
253 #[test]
254 fn from_static_accepts_max_length() {
255 const MAX: GffLabel = GffLabel::from_static("a234567890123456");
256 assert_eq!(MAX.len(), 16);
257 }
258
259 #[test]
260 fn from_static_matches_new_for_valid_input() {
261 let runtime = GffLabel::new("Equip_ItemList").expect("valid");
262 const COMPILE_TIME: GffLabel = GffLabel::from_static("Equip_ItemList");
263 assert_eq!(runtime, COMPILE_TIME);
264 }
265
266 #[test]
267 #[should_panic(expected = "GFF label exceeds 16 bytes")]
268 fn from_static_rejects_too_long_at_runtime() {
269 let _ = GffLabel::from_static("this_label_is_longer_than_sixteen");
273 }
274
275 #[test]
276 #[should_panic(expected = "GFF label contains invalid character")]
277 fn from_static_rejects_invalid_char_at_runtime() {
278 let _ = GffLabel::from_static("bad!name");
279 }
280}