Skip to main content

reliakit_primitives/
inline.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use core::{
3    cmp::Ordering,
4    fmt,
5    hash::{Hash, Hasher},
6    ops::Deref,
7    str::FromStr,
8};
9
10/// A UTF-8 string of `MIN..=MAX` bytes stored inline, with no heap allocation.
11///
12/// Unlike [`BoundedStr`](crate::BoundedStr), which lives on the heap and counts
13/// length in Unicode scalar values (`char`s), `InlineStr` keeps its bytes in a
14/// `[u8; MAX]` buffer owned by the value and bounds the **byte** length, since
15/// that is what a fixed buffer holds. A multi-byte character therefore spends
16/// more than one unit of the bound: `"é"` is two bytes, so it needs `MAX >= 2`.
17///
18/// Because the bytes live in the value itself, `InlineStr` is `Copy` and needs
19/// no allocator, so it works in `no_std` builds without the `alloc` feature.
20///
21/// Reads go through [`as_str`](Self::as_str), which revalidates the bytes as
22/// UTF-8 on each call: the crate forbids unsafe code, so it cannot use
23/// `from_utf8_unchecked`. The check is over at most `MAX` bytes.
24///
25/// # Examples
26///
27/// ```
28/// use reliakit_primitives::InlineStr;
29///
30/// type Code = InlineStr<1, 8>;
31///
32/// let code = Code::new("AB12").unwrap();
33/// assert_eq!(code.as_str(), "AB12");
34/// assert_eq!(code.len(), 4);
35///
36/// // Nine bytes do not fit the eight-byte budget.
37/// assert!(Code::new("123456789").is_err());
38/// ```
39#[derive(Clone, Copy)]
40pub struct InlineStr<const MIN: usize, const MAX: usize> {
41    buf: [u8; MAX],
42    len: usize,
43}
44
45impl<const MIN: usize, const MAX: usize> InlineStr<MIN, MAX> {
46    /// Creates a new inline string.
47    ///
48    /// Length is measured in UTF-8 bytes, not characters. If `MIN > MAX`,
49    /// construction returns `OutOfRange`. When `MIN > 0`, an input that is empty
50    /// or contains only whitespace is rejected with `Empty`, even if its byte
51    /// length would otherwise satisfy `MIN`.
52    pub fn new(value: &str) -> PrimitiveResult<Self> {
53        let bytes = value.as_bytes();
54        let actual = bytes.len();
55
56        if MIN > MAX {
57            return Err(PrimitiveError::OutOfRange {
58                min: MIN as u128,
59                max: MAX as u128,
60                actual: actual as u128,
61            });
62        }
63        if actual < MIN {
64            return Err(PrimitiveError::TooShort { min: MIN, actual });
65        }
66        if actual > MAX {
67            return Err(PrimitiveError::TooLong { max: MAX, actual });
68        }
69        if MIN > 0 && value.trim().is_empty() {
70            return Err(PrimitiveError::Empty);
71        }
72
73        let mut buf = [0u8; MAX];
74        buf[..actual].copy_from_slice(bytes);
75        Ok(Self { buf, len: actual })
76    }
77
78    /// Returns the underlying string slice.
79    pub fn as_str(&self) -> &str {
80        core::str::from_utf8(self.as_bytes())
81            .expect("InlineStr always holds valid UTF-8 by construction")
82    }
83
84    /// Returns the string's bytes, without the unused buffer tail.
85    pub fn as_bytes(&self) -> &[u8] {
86        &self.buf[..self.len]
87    }
88
89    /// Returns the byte length of the string.
90    pub fn len(&self) -> usize {
91        self.len
92    }
93
94    /// Returns whether the string is empty.
95    pub fn is_empty(&self) -> bool {
96        self.len == 0
97    }
98
99    /// Returns the minimum allowed byte length.
100    pub fn min_len(&self) -> usize {
101        MIN
102    }
103
104    /// Returns the maximum byte length, which is the inline capacity.
105    pub fn max_len(&self) -> usize {
106        MAX
107    }
108}
109
110impl<const MIN: usize, const MAX: usize> fmt::Display for InlineStr<MIN, MAX> {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl<const MIN: usize, const MAX: usize> fmt::Debug for InlineStr<MIN, MAX> {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        fmt::Debug::fmt(self.as_str(), f)
119    }
120}
121
122impl<const MIN: usize, const MAX: usize> AsRef<str> for InlineStr<MIN, MAX> {
123    fn as_ref(&self) -> &str {
124        self.as_str()
125    }
126}
127
128impl<const MIN: usize, const MAX: usize> Deref for InlineStr<MIN, MAX> {
129    type Target = str;
130
131    fn deref(&self) -> &Self::Target {
132        self.as_str()
133    }
134}
135
136// Equality, hashing, and ordering compare the bytes directly: UTF-8 byte order
137// matches code-point order, so this agrees with comparing the `&str`s, and it
138// skips the UTF-8 revalidation that `as_str` would do.
139impl<const MIN: usize, const MAX: usize> PartialEq for InlineStr<MIN, MAX> {
140    fn eq(&self, other: &Self) -> bool {
141        self.as_bytes() == other.as_bytes()
142    }
143}
144
145impl<const MIN: usize, const MAX: usize> Eq for InlineStr<MIN, MAX> {}
146
147impl<const MIN: usize, const MAX: usize> Hash for InlineStr<MIN, MAX> {
148    fn hash<H: Hasher>(&self, state: &mut H) {
149        self.as_bytes().hash(state);
150    }
151}
152
153impl<const MIN: usize, const MAX: usize> PartialOrd for InlineStr<MIN, MAX> {
154    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
155        Some(self.cmp(other))
156    }
157}
158
159impl<const MIN: usize, const MAX: usize> Ord for InlineStr<MIN, MAX> {
160    fn cmp(&self, other: &Self) -> Ordering {
161        self.as_bytes().cmp(other.as_bytes())
162    }
163}
164
165impl<const MIN: usize, const MAX: usize> PartialEq<str> for InlineStr<MIN, MAX> {
166    fn eq(&self, other: &str) -> bool {
167        self.as_bytes() == other.as_bytes()
168    }
169}
170
171impl<const MIN: usize, const MAX: usize> PartialEq<&str> for InlineStr<MIN, MAX> {
172    fn eq(&self, other: &&str) -> bool {
173        self.as_bytes() == other.as_bytes()
174    }
175}
176
177impl<const MIN: usize, const MAX: usize> TryFrom<&str> for InlineStr<MIN, MAX> {
178    type Error = PrimitiveError;
179
180    fn try_from(value: &str) -> Result<Self, Self::Error> {
181        Self::new(value)
182    }
183}
184
185impl<const MIN: usize, const MAX: usize> FromStr for InlineStr<MIN, MAX> {
186    type Err = PrimitiveError;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        Self::new(s)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::InlineStr;
196    use crate::PrimitiveError;
197
198    #[test]
199    fn accepts_valid_length() {
200        let value = InlineStr::<3, 12>::new("service").unwrap();
201        assert_eq!(value.as_str(), "service");
202        assert_eq!(value.len(), 7);
203        assert_eq!(value.min_len(), 3);
204        assert_eq!(value.max_len(), 12);
205        assert!(!value.is_empty());
206    }
207
208    #[test]
209    fn rejects_too_short() {
210        assert_eq!(
211            InlineStr::<3, 12>::new("ab").unwrap_err(),
212            PrimitiveError::TooShort { min: 3, actual: 2 }
213        );
214    }
215
216    #[test]
217    fn rejects_too_long() {
218        assert_eq!(
219            InlineStr::<1, 5>::new("service").unwrap_err(),
220            PrimitiveError::TooLong { max: 5, actual: 7 }
221        );
222    }
223
224    #[test]
225    fn counts_bytes_not_chars() {
226        // "éå" is two characters but four UTF-8 bytes.
227        let value = InlineStr::<4, 4>::new("éå").unwrap();
228        assert_eq!(value.len(), 4);
229        assert_eq!(value.as_str(), "éå");
230        // A single two-byte character does not fit a one-byte budget.
231        assert_eq!(
232            InlineStr::<1, 1>::new("é").unwrap_err(),
233            PrimitiveError::TooLong { max: 1, actual: 2 }
234        );
235    }
236
237    #[test]
238    fn rejects_whitespace_only_when_min_positive() {
239        assert_eq!(
240            InlineStr::<1, 5>::new("  ").unwrap_err(),
241            PrimitiveError::Empty
242        );
243    }
244
245    #[test]
246    fn allows_zero_min_whitespace_only() {
247        let value = InlineStr::<0, 5>::new("   ").unwrap();
248        assert_eq!(value.as_str(), "   ");
249        assert_eq!(value.len(), 3);
250    }
251
252    #[test]
253    fn allows_empty_when_min_zero() {
254        let value = InlineStr::<0, 4>::new("").unwrap();
255        assert!(value.is_empty());
256        assert_eq!(value.as_str(), "");
257    }
258
259    #[test]
260    fn zero_capacity_only_holds_empty() {
261        assert!(InlineStr::<0, 0>::new("").unwrap().is_empty());
262        assert_eq!(
263            InlineStr::<0, 0>::new("x").unwrap_err(),
264            PrimitiveError::TooLong { max: 0, actual: 1 }
265        );
266    }
267
268    #[test]
269    fn handles_invalid_bounds() {
270        assert_eq!(
271            InlineStr::<5, 3>::new("abcd").unwrap_err(),
272            PrimitiveError::OutOfRange {
273                min: 5,
274                max: 3,
275                actual: 4
276            }
277        );
278    }
279
280    #[test]
281    fn exact_min_and_max_fit() {
282        assert_eq!(InlineStr::<3, 3>::new("abc").unwrap().len(), 3);
283        assert!(InlineStr::<3, 3>::new("ab").is_err());
284        assert!(InlineStr::<3, 3>::new("abcd").is_err());
285    }
286
287    #[test]
288    fn is_copy() {
289        let a = InlineStr::<1, 8>::new("copy").unwrap();
290        let b = a;
291        // `a` is still usable because the value is `Copy`, not moved.
292        assert_eq!(a.as_str(), "copy");
293        assert_eq!(b.as_str(), "copy");
294    }
295
296    #[test]
297    fn as_bytes_excludes_unused_tail() {
298        let value = InlineStr::<1, 16>::new("hi").unwrap();
299        assert_eq!(value.as_bytes(), b"hi");
300    }
301
302    #[test]
303    fn deref_and_as_ref_to_str() {
304        let value = InlineStr::<1, 8>::new("api").unwrap();
305        let s: &str = value.as_ref();
306        assert_eq!(s, "api");
307        // `starts_with` is a `str` method reached through `Deref`.
308        assert!(value.starts_with("ap"));
309    }
310
311    #[test]
312    fn equality_and_ordering() {
313        let a = InlineStr::<1, 8>::new("alpha").unwrap();
314        let b = InlineStr::<1, 8>::new("beta").unwrap();
315        assert_eq!(a, InlineStr::<1, 8>::new("alpha").unwrap());
316        assert_ne!(a, b);
317        assert!(a < b);
318        assert_eq!(a, "alpha");
319    }
320
321    #[test]
322    fn from_str_and_try_from() {
323        let parsed: InlineStr<1, 8> = "node".parse().unwrap();
324        assert_eq!(parsed.as_str(), "node");
325        let converted = InlineStr::<1, 8>::try_from("node").unwrap();
326        assert_eq!(parsed, converted);
327        assert!("toolongforbudget".parse::<InlineStr<1, 8>>().is_err());
328    }
329
330    #[test]
331    fn display_and_debug() {
332        use alloc::string::ToString;
333        let value = InlineStr::<3, 12>::new("service").unwrap();
334        assert_eq!(value.to_string(), "service");
335        assert_eq!(alloc::format!("{value:?}"), "\"service\"");
336    }
337
338    #[test]
339    fn hash_distinguishes_contents() {
340        use core::hash::{Hash, Hasher};
341
342        // A small FNV-1a hasher keeps this pure-core and dependency-free; it only
343        // needs to tell different contents apart.
344        fn fnv(value: &InlineStr<1, 8>) -> u64 {
345            struct Fnv(u64);
346            impl Hasher for Fnv {
347                fn finish(&self) -> u64 {
348                    self.0
349                }
350                fn write(&mut self, bytes: &[u8]) {
351                    for &b in bytes {
352                        self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
353                    }
354                }
355            }
356            let mut hasher = Fnv(0xcbf2_9ce4_8422_2325);
357            value.hash(&mut hasher);
358            hasher.finish()
359        }
360
361        let a = InlineStr::<1, 8>::new("alpha").unwrap();
362        assert_eq!(fnv(&a), fnv(&InlineStr::<1, 8>::new("alpha").unwrap()));
363        // Different contents hash differently, so a no-op hash impl is caught.
364        assert_ne!(fnv(&a), fnv(&InlineStr::<1, 8>::new("beta").unwrap()));
365    }
366
367    #[test]
368    fn str_equality_both_impls() {
369        let a = InlineStr::<1, 8>::new("alpha").unwrap();
370        // `PartialEq<&str>`: both equal and not equal.
371        assert!(a == "alpha");
372        assert!(a != "other");
373        // `PartialEq<str>` (the unsized form) is selected by a generic bound.
374        fn eq_str<T: PartialEq<str> + Copy>(value: T, s: &str) -> bool {
375            value == *s
376        }
377        assert!(eq_str(a, "alpha"));
378        assert!(!eq_str(a, "other"));
379    }
380}