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