Skip to main content

reliakit_primitives/
bounded.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, hash::Hash, ops::Deref, str::FromStr};
4
5/// Owned string constrained by inclusive character length bounds.
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct BoundedStr<const MIN: usize, const MAX: usize>(String);
8
9impl<const MIN: usize, const MAX: usize> BoundedStr<MIN, MAX> {
10    /// Creates a new bounded string.
11    ///
12    /// Length is measured in Unicode scalar values via `chars().count()`, not
13    /// bytes. If `MIN > MAX`, construction returns `OutOfRange`.
14    pub fn new(value: impl Into<String>) -> PrimitiveResult<Self> {
15        let value = value.into();
16        let actual = value.chars().count();
17
18        if MIN > MAX {
19            return Err(PrimitiveError::OutOfRange {
20                min: MIN as u128,
21                max: MAX as u128,
22                actual: actual as u128,
23            });
24        }
25
26        if actual < MIN {
27            return Err(PrimitiveError::TooShort { min: MIN, actual });
28        }
29
30        if actual > MAX {
31            return Err(PrimitiveError::TooLong { max: MAX, actual });
32        }
33
34        if MIN > 0 && value.trim().is_empty() {
35            return Err(PrimitiveError::Empty);
36        }
37
38        Ok(Self(value))
39    }
40
41    /// Returns the underlying string slice.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    /// Returns the owned inner string.
47    pub fn into_inner(self) -> String {
48        self.0
49    }
50
51    /// Returns the character length of the inner string.
52    pub fn len(&self) -> usize {
53        self.0.chars().count()
54    }
55
56    /// Returns whether the inner string is empty.
57    pub fn is_empty(&self) -> bool {
58        self.0.is_empty()
59    }
60
61    /// Returns the minimum allowed character length.
62    pub fn min_len(&self) -> usize {
63        MIN
64    }
65
66    /// Returns the maximum allowed character length.
67    pub fn max_len(&self) -> usize {
68        MAX
69    }
70}
71
72impl<const MIN: usize, const MAX: usize> fmt::Display for BoundedStr<MIN, MAX> {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(&self.0)
75    }
76}
77
78impl<const MIN: usize, const MAX: usize> AsRef<str> for BoundedStr<MIN, MAX> {
79    fn as_ref(&self) -> &str {
80        self.as_str()
81    }
82}
83
84impl<const MIN: usize, const MAX: usize> Deref for BoundedStr<MIN, MAX> {
85    type Target = str;
86
87    fn deref(&self) -> &Self::Target {
88        self.as_str()
89    }
90}
91
92impl<const MIN: usize, const MAX: usize> TryFrom<String> for BoundedStr<MIN, MAX> {
93    type Error = PrimitiveError;
94
95    fn try_from(value: String) -> Result<Self, Self::Error> {
96        Self::new(value)
97    }
98}
99
100impl<const MIN: usize, const MAX: usize> TryFrom<&str> for BoundedStr<MIN, MAX> {
101    type Error = PrimitiveError;
102
103    fn try_from(value: &str) -> Result<Self, Self::Error> {
104        Self::new(value)
105    }
106}
107
108impl<const MIN: usize, const MAX: usize> FromStr for BoundedStr<MIN, MAX> {
109    type Err = PrimitiveError;
110
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112        Self::new(s)
113    }
114}
115
116impl<const MIN: usize, const MAX: usize> From<BoundedStr<MIN, MAX>> for String {
117    fn from(value: BoundedStr<MIN, MAX>) -> Self {
118        value.into_inner()
119    }
120}
121
122impl<const MIN: usize, const MAX: usize> PartialEq<str> for BoundedStr<MIN, MAX> {
123    fn eq(&self, other: &str) -> bool {
124        self.as_str() == other
125    }
126}
127
128impl<const MIN: usize, const MAX: usize> PartialEq<&str> for BoundedStr<MIN, MAX> {
129    fn eq(&self, other: &&str) -> bool {
130        self.as_str() == *other
131    }
132}
133
134impl<const MIN: usize, const MAX: usize> PartialEq<String> for BoundedStr<MIN, MAX> {
135    fn eq(&self, other: &String) -> bool {
136        self.as_str() == other.as_str()
137    }
138}
139
140impl<const MIN: usize, const MAX: usize> PartialEq<&String> for BoundedStr<MIN, MAX> {
141    fn eq(&self, other: &&String) -> bool {
142        self.as_str() == other.as_str()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::BoundedStr;
149    use crate::PrimitiveError;
150    use alloc::string::{String, ToString};
151
152    #[test]
153    fn accepts_valid_length() {
154        let value = BoundedStr::<3, 12>::new("service").unwrap();
155        assert_eq!(value.as_str(), "service");
156        assert_eq!(value.len(), 7);
157        assert_eq!(value.min_len(), 3);
158        assert_eq!(value.max_len(), 12);
159    }
160
161    #[test]
162    fn rejects_too_short() {
163        assert_eq!(
164            BoundedStr::<3, 12>::new("ab").unwrap_err(),
165            PrimitiveError::TooShort { min: 3, actual: 2 }
166        );
167    }
168
169    #[test]
170    fn rejects_too_long() {
171        assert_eq!(
172            BoundedStr::<3, 5>::new("service").unwrap_err(),
173            PrimitiveError::TooLong { max: 5, actual: 7 }
174        );
175    }
176
177    #[test]
178    fn counts_unicode_chars() {
179        let value = BoundedStr::<2, 2>::new("éå").unwrap();
180        assert_eq!(value.len(), 2);
181        assert_eq!(value.as_str().len(), 4);
182    }
183
184    #[test]
185    fn rejects_whitespace_only_when_min_positive() {
186        assert_eq!(
187            BoundedStr::<1, 5>::new("  ").unwrap_err(),
188            PrimitiveError::Empty
189        );
190    }
191
192    #[test]
193    fn handles_invalid_bounds() {
194        assert_eq!(
195            BoundedStr::<5, 3>::new("abcd").unwrap_err(),
196            PrimitiveError::OutOfRange {
197                min: 5,
198                max: 3,
199                actual: 4
200            }
201        );
202    }
203
204    #[test]
205    fn into_inner_returns_string() {
206        let value = BoundedStr::<3, 10>::new("hello").unwrap();
207        assert_eq!(value.into_inner(), "hello");
208    }
209
210    #[test]
211    fn is_empty_returns_false_for_valid() {
212        let value = BoundedStr::<3, 10>::new("hello").unwrap();
213        assert!(!value.is_empty());
214    }
215
216    #[test]
217    fn display_formats_inner_string() {
218        let value = BoundedStr::<3, 10>::new("hello").unwrap();
219        assert_eq!(value.to_string(), "hello");
220    }
221
222    #[test]
223    fn as_ref_returns_str() {
224        let value = BoundedStr::<3, 10>::new("hello").unwrap();
225        let s: &str = value.as_ref();
226        assert_eq!(s, "hello");
227    }
228
229    #[test]
230    fn deref_to_str() {
231        let value = BoundedStr::<3, 10>::new("hello").unwrap();
232        assert_eq!(&*value, "hello");
233    }
234
235    #[test]
236    fn try_from_string() {
237        let value = BoundedStr::<3, 10>::try_from(String::from("hello")).unwrap();
238        assert_eq!(value.as_str(), "hello");
239    }
240
241    #[test]
242    fn try_from_str_ref() {
243        let value = BoundedStr::<3, 10>::try_from("hello").unwrap();
244        assert_eq!(value.as_str(), "hello");
245    }
246
247    #[test]
248    fn from_bounded_str_into_string() {
249        let value = BoundedStr::<3, 10>::new("hello").unwrap();
250        let s = String::from(value);
251        assert_eq!(s, "hello");
252    }
253
254    #[test]
255    fn allows_zero_min_whitespace_only() {
256        let value = BoundedStr::<0, 5>::new("   ").unwrap();
257        assert_eq!(value.as_str(), "   ");
258    }
259
260    #[test]
261    fn from_str_and_string_comparisons() {
262        let value = "hello".parse::<BoundedStr<3, 10>>().unwrap();
263        let owned = String::from("hello");
264        assert_eq!(value, "hello");
265        assert_eq!(value, owned);
266        assert!("hi".parse::<BoundedStr<3, 10>>().is_err());
267    }
268}