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    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            // All valid chars are single-byte ASCII.
61            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    /// Constructs a [`GffLabel`] from a string literal at compile time.
68    ///
69    /// Use this for hardcoded labels in source code. Invalid input becomes
70    /// a compile error when the call appears in a `const` context, and
71    /// panics immediately at the call site otherwise -- validation cannot
72    /// be deferred into a runtime data path.
73    ///
74    /// For runtime input (binary parsing, JSON, user data), use
75    /// [`GffLabel::new`] which returns a [`Result`].
76    ///
77    /// ```
78    /// # use rakata_formats::gff::GffLabel;
79    /// const TAG: GffLabel = GffLabel::from_static("Tag");
80    /// assert_eq!(TAG.as_str(), "Tag");
81    /// ```
82    ///
83    /// Invalid literals are rejected at compile time when used in a
84    /// `const` context:
85    ///
86    /// ```compile_fail
87    /// # use rakata_formats::gff::GffLabel;
88    /// const BAD: GffLabel = GffLabel::from_static("Tag!");
89    /// ```
90    #[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        // len is bounded by MAX_GFF_LABEL_LEN (16), so the cast is lossless.
113        // u8::try_from is not const-stable on stable Rust, so `as` is the
114        // only available option in a const context.
115        Self {
116            bytes: storage,
117            len: len as u8,
118        }
119    }
120
121    /// Returns the string representation of the label.
122    pub fn as_str(&self) -> &str {
123        // SAFETY-equivalent: all bytes in [0..len) are guaranteed to be ASCII
124        // (validated in `new`), so the slice is valid UTF-8.
125        std::str::from_utf8(&self.bytes[..usize::from(self.len)])
126            .expect("all bytes are validated ASCII during construction")
127    }
128
129    /// Returns the length in bytes (equals character count since all content is ASCII).
130    #[allow(clippy::as_conversions)]
131    pub const fn len(&self) -> usize {
132        self.len as usize
133    }
134
135    /// Returns `true` when the label is empty.
136    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        // GIT files use labels like "Creature List", "Door List" -- the
248        // space character must remain valid for K1 vanilla content.
249        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        // Outside a const context the assert! degrades to a runtime panic.
270        // Invalid literals in a `const` binding fail to compile (covered by
271        // the doctest on `from_static`).
272        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}