Skip to main content

rakata_formats/gff/
label.rs

1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3use thiserror::Error;
4
5/// Maximum number of ASCII characters for a GFF field label.
6pub const MAX_GFF_LABEL_LEN: usize = 16;
7
8/// Error returned when constructing a [`GffLabel`] fails validation.
9#[derive(Debug, Clone, PartialEq, Eq, Error)]
10pub enum GffLabelError {
11    /// Input exceeded the maximum allowed label length.
12    #[error("label length {len} exceeds maximum {max}")]
13    TooLong {
14        /// Actual input length.
15        len: usize,
16        /// Maximum allowed length.
17        max: usize,
18    },
19    /// Input contained an invalid character.
20    #[error("invalid GFF label character '{ch}'")]
21    InvalidChar {
22        /// The invalid character that caused validation failure.
23        ch: char,
24    },
25}
26
27/// Canonicalized GFF field label.
28///
29/// GFF labels are identifiers with a maximum length of 16 characters. This type stores
30/// the exact casing of the label as an inline fixed-size buffer, making it `Copy` and
31/// zero-allocation.
32///
33///
34/// Valid characters are restricted to ASCII alphanumerics, underscores, and spaces (`[A-Za-z0-9_ ]`).
35#[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    /// Creates and validates a new GFF label.
45    ///
46    /// Accepted characters are ASCII alphanumerics, underscores, and spaces.
47    ///
48    /// # Errors
49    ///
50    /// [`GffLabelError::TooLong`] above 16 bytes, which is the fixed width the
51    /// on-disk label table gives every entry, and
52    /// [`GffLabelError::InvalidChar`] for anything outside the accepted set,
53    /// naming the character.
54    ///
55    /// The length is the trap: a format's documented label can be longer than
56    /// the file can hold, and the file wins. `TransitionDestination` and
57    /// `PT_CONTROLLED_NPC` are both cases, and neither appears in any real
58    /// file under the name the page gives it.
59    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            // All valid chars are single-byte ASCII.
73            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    /// Constructs a [`GffLabel`] from a string literal at compile time.
80    ///
81    /// Use this for hardcoded labels in source code. Invalid input becomes
82    /// a compile error when the call appears in a `const` context, and
83    /// panics immediately at the call site otherwise -- validation cannot
84    /// be deferred into a runtime data path.
85    ///
86    /// For runtime input (binary parsing, JSON, user data), use
87    /// [`GffLabel::new`] which returns a [`Result`].
88    ///
89    /// ```
90    /// # use rakata_formats::gff::GffLabel;
91    /// const TAG: GffLabel = GffLabel::from_static("Tag");
92    /// assert_eq!(TAG.as_str(), "Tag");
93    /// ```
94    ///
95    /// Invalid literals are rejected at compile time when used in a
96    /// `const` context:
97    ///
98    /// ```compile_fail
99    /// # use rakata_formats::gff::GffLabel;
100    /// const BAD: GffLabel = GffLabel::from_static("Tag!");
101    /// ```
102    #[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        // len is bounded by MAX_GFF_LABEL_LEN (16), so the cast is lossless.
125        // u8::try_from is not const-stable on stable Rust, so `as` is the
126        // only available option in a const context.
127        Self {
128            bytes: storage,
129            len: len as u8,
130        }
131    }
132
133    /// Returns the string representation of the label.
134    pub fn as_str(&self) -> &str {
135        // SAFETY-equivalent: all bytes in [0..len) are guaranteed to be ASCII
136        // (validated in `new`), so the slice is valid UTF-8.
137        std::str::from_utf8(&self.bytes[..usize::from(self.len)])
138            .expect("all bytes are validated ASCII during construction")
139    }
140
141    /// Returns the length in bytes (equals character count since all content is ASCII).
142    #[allow(clippy::as_conversions)]
143    pub const fn len(&self) -> usize {
144        self.len as usize
145    }
146
147    /// Returns `true` when the label is empty.
148    pub const fn is_empty(&self) -> bool {
149        self.len == 0
150    }
151}
152
153/// Builds a [`GffLabel`] from a literal, rejecting an invalid one at compile
154/// time.
155///
156/// [`GffLabel::from_static`] is a `const fn`, but calling it in an ordinary
157/// expression evaluates it at run time, so a bad literal panics on the line
158/// that runs rather than on the line that is wrong. Wrapping the call in a
159/// `const` block forces the evaluation into the compile, which is the whole
160/// point at a call site that only ever passes a literal.
161///
162/// ```
163/// # use rakata_formats::gff_label;
164/// let tag = gff_label!("Tag");
165/// assert_eq!(tag.as_str(), "Tag");
166/// ```
167///
168/// ```compile_fail
169/// # use rakata_formats::gff_label;
170/// let bad = gff_label!("Tag!");
171/// ```
172#[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        // GIT files use labels like "Creature List", "Door List" -- the
286        // space character must remain valid for K1 vanilla content.
287        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        // Outside a const context the assert! degrades to a runtime panic.
308        // Invalid literals in a `const` binding fail to compile (covered by
309        // the doctest on `from_static`).
310        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}