rakata_core/
resref.rs

1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3use thiserror::Error;
4
5/// Maximum number of ASCII characters for a KotOR resource reference.
6pub const MAX_RESREF_LEN: usize = 16;
7
8/// Error returned when constructing a [`ResRef`] fails validation.
9#[derive(Debug, Clone, PartialEq, Eq, Error)]
10pub enum ResRefError {
11    /// Input exceeded the maximum allowed resref length.
12    #[error("resref 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 a character outside `[A-Za-z0-9_]`.
20    #[error("invalid resref character '{ch}'")]
21    InvalidChar {
22        /// The invalid character that caused validation failure.
23        ch: char,
24    },
25}
26
27/// Canonicalized resource reference.
28///
29/// KotOR resource references are case-insensitive identifiers with a maximum
30/// length of 16 characters. This type stores the lowercase canonical form as
31/// an inline fixed-size buffer, making it `Copy` and zero-allocation.
32///
33/// The valid character set is ASCII alphanumerics, underscore, and hyphen
34/// (`[A-Za-z0-9_-]`). Hyphens appear in stock KotOR content (for example
35/// `t3-m4`, `hk-47`), so they are accepted by validation.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
39pub struct ResRef {
40    bytes: [u8; MAX_RESREF_LEN],
41    len: u8,
42}
43
44impl ResRef {
45    /// Creates and validates a new resource reference.
46    ///
47    /// Accepted characters are ASCII alphanumerics, underscore, and hyphen.
48    /// The input is canonicalized to lowercase. An empty string produces a
49    /// blank ResRef.
50    pub fn new(value: impl AsRef<str>) -> Result<Self, ResRefError> {
51        let raw = value.as_ref();
52        if raw.len() > MAX_RESREF_LEN {
53            return Err(ResRefError::TooLong {
54                len: raw.len(),
55                max: MAX_RESREF_LEN,
56            });
57        }
58        let mut bytes = [0u8; MAX_RESREF_LEN];
59        for (i, ch) in raw.chars().enumerate() {
60            if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') {
61                return Err(ResRefError::InvalidChar { ch });
62            }
63            // All valid chars are single-byte ASCII, so i is also the byte index
64            // and u8::try_from is guaranteed to succeed.
65            bytes[i] = u8::try_from(ch.to_ascii_lowercase())
66                .expect("ASCII char is guaranteed to fit in u8");
67        }
68        let len = u8::try_from(raw.len()).expect("len already bounded by MAX_RESREF_LEN");
69        Ok(Self { bytes, len })
70    }
71
72    /// Returns an empty resource reference.
73    pub const fn blank() -> Self {
74        Self {
75            bytes: [0u8; MAX_RESREF_LEN],
76            len: 0,
77        }
78    }
79
80    /// Returns the canonical lowercase string representation.
81    pub fn as_str(&self) -> &str {
82        // SAFETY-equivalent: all bytes in [0..len) are guaranteed to be ASCII
83        // (validated in `new`), so the slice is valid UTF-8.
84        std::str::from_utf8(&self.bytes[..usize::from(self.len)])
85            .expect("all bytes are validated ASCII during construction")
86    }
87
88    /// Returns `true` when the resource reference is empty.
89    pub const fn is_blank(&self) -> bool {
90        self.len == 0
91    }
92
93    /// Returns `true` when the resource reference is empty.
94    ///
95    /// Alias for [`is_blank`](Self::is_blank) matching the standard collection API.
96    pub const fn is_empty(&self) -> bool {
97        self.len == 0
98    }
99
100    /// Returns the length in bytes (equals character count since all content is ASCII).
101    #[allow(clippy::as_conversions)]
102    pub const fn len(&self) -> usize {
103        // u8 to usize is a lossless widening; usize::from is not yet const-stable
104        // as of 1.93, so `as` is the only option in a const context.
105        self.len as usize
106    }
107}
108
109impl std::fmt::Debug for ResRef {
110    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
111        write!(f, "ResRef({:?})", self.as_str())
112    }
113}
114
115impl Default for ResRef {
116    fn default() -> Self {
117        Self::blank()
118    }
119}
120
121impl Display for ResRef {
122    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
123        f.write_str(self.as_str())
124    }
125}
126
127impl FromStr for ResRef {
128    type Err = ResRefError;
129
130    fn from_str(s: &str) -> Result<Self, Self::Err> {
131        Self::new(s)
132    }
133}
134
135impl TryFrom<&str> for ResRef {
136    type Error = ResRefError;
137
138    fn try_from(value: &str) -> Result<Self, Self::Error> {
139        Self::new(value)
140    }
141}
142
143impl TryFrom<String> for ResRef {
144    type Error = ResRefError;
145
146    fn try_from(value: String) -> Result<Self, Self::Error> {
147        Self::new(value)
148    }
149}
150
151impl From<ResRef> for String {
152    fn from(val: ResRef) -> Self {
153        val.as_str().to_owned()
154    }
155}
156
157impl AsRef<str> for ResRef {
158    fn as_ref(&self) -> &str {
159        self.as_str()
160    }
161}
162
163impl PartialEq<str> for ResRef {
164    fn eq(&self, other: &str) -> bool {
165        self.as_str() == other
166    }
167}
168
169impl PartialEq<&str> for ResRef {
170    fn eq(&self, other: &&str) -> bool {
171        self.as_str() == *other
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn accepts_valid_resref() {
181        let parsed = ResRef::new("P_Bastila").expect("valid resref");
182        assert_eq!(parsed.as_str(), "p_bastila");
183    }
184
185    #[test]
186    fn rejects_too_long_resref() {
187        let err = ResRef::new("this_name_is_longer_than_sixteen").expect_err("must fail");
188        assert!(matches!(err, ResRefError::TooLong { .. }));
189    }
190
191    #[test]
192    fn rejects_invalid_characters() {
193        let err = ResRef::new("bad!name").expect_err("must fail");
194        assert_eq!(err, ResRefError::InvalidChar { ch: '!' });
195    }
196
197    #[test]
198    fn accepts_hyphen_resrefs() {
199        let parsed = ResRef::new("t3-m4").expect("hyphen is valid");
200        assert_eq!(parsed.as_str(), "t3-m4");
201    }
202
203    #[test]
204    fn blank_is_empty() {
205        let r = ResRef::blank();
206        assert!(r.is_blank());
207        assert_eq!(r.as_str(), "");
208        assert_eq!(r.len(), 0);
209    }
210
211    #[test]
212    fn max_length_accepted() {
213        let parsed = ResRef::new("a23456789_123456").expect("16 chars is valid");
214        assert_eq!(parsed.as_str(), "a23456789_123456");
215        assert_eq!(parsed.len(), 16);
216    }
217
218    #[test]
219    fn is_copy() {
220        fn takes_copy<T: Copy>(_: T) {}
221        takes_copy(ResRef::blank());
222    }
223
224    #[test]
225    fn roundtrip_through_string() {
226        let original = ResRef::new("test_resref").expect("valid");
227        let s: String = original.into();
228        assert_eq!(s, "test_resref");
229        let back: ResRef = s.try_into().expect("valid");
230        assert_eq!(back, original);
231    }
232}