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    /// Returns the string representation of the label.
68    pub fn as_str(&self) -> &str {
69        // SAFETY-equivalent: all bytes in [0..len) are guaranteed to be ASCII
70        // (validated in `new`), so the slice is valid UTF-8.
71        std::str::from_utf8(&self.bytes[..usize::from(self.len)])
72            .expect("all bytes are validated ASCII during construction")
73    }
74
75    /// Returns the length in bytes (equals character count since all content is ASCII).
76    #[allow(clippy::as_conversions)]
77    pub const fn len(&self) -> usize {
78        self.len as usize
79    }
80
81    /// Returns `true` when the label is empty.
82    pub const fn is_empty(&self) -> bool {
83        self.len == 0
84    }
85}
86
87impl std::fmt::Debug for GffLabel {
88    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89        write!(f, "GffLabel({:?})", self.as_str())
90    }
91}
92
93impl Display for GffLabel {
94    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99impl FromStr for GffLabel {
100    type Err = GffLabelError;
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        Self::new(s)
104    }
105}
106
107impl TryFrom<&str> for GffLabel {
108    type Error = GffLabelError;
109
110    fn try_from(value: &str) -> Result<Self, Self::Error> {
111        Self::new(value)
112    }
113}
114
115impl TryFrom<String> for GffLabel {
116    type Error = GffLabelError;
117
118    fn try_from(value: String) -> Result<Self, Self::Error> {
119        Self::new(value)
120    }
121}
122
123impl From<GffLabel> for String {
124    fn from(val: GffLabel) -> Self {
125        val.as_str().to_owned()
126    }
127}
128
129impl AsRef<str> for GffLabel {
130    fn as_ref(&self) -> &str {
131        self.as_str()
132    }
133}
134
135impl PartialEq<str> for GffLabel {
136    fn eq(&self, other: &str) -> bool {
137        self.as_str() == other
138    }
139}
140
141impl PartialEq<&str> for GffLabel {
142    fn eq(&self, other: &&str) -> bool {
143        self.as_str() == *other
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn accepts_valid_label() {
153        let parsed = GffLabel::new("Equip_ItemList").expect("valid label");
154        assert_eq!(parsed.as_str(), "Equip_ItemList");
155    }
156
157    #[test]
158    fn rejects_too_long_label() {
159        let err = GffLabel::new("this_name_is_longer_than_sixteen").expect_err("must fail");
160        assert!(matches!(err, GffLabelError::TooLong { .. }));
161    }
162
163    #[test]
164    fn rejects_invalid_characters() {
165        let err = GffLabel::new("bad!name").expect_err("must fail");
166        assert_eq!(err, GffLabelError::InvalidChar { ch: '!' });
167    }
168
169    #[test]
170    fn is_copy() {
171        fn takes_copy<T: Copy>(_: T) {}
172        takes_copy(GffLabel::new("Test").unwrap());
173    }
174
175    #[test]
176    fn roundtrip_through_string() {
177        let original = GffLabel::new("test_label").expect("valid");
178        let s: String = original.into();
179        assert_eq!(s, "test_label");
180        let back: GffLabel = s.try_into().expect("valid");
181        assert_eq!(back, original);
182    }
183}