Skip to main content

reliakit_primitives/
bounded.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::String;
3use core::{fmt, hash::Hash, ops::Deref};
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> From<BoundedStr<MIN, MAX>> for String {
109    fn from(value: BoundedStr<MIN, MAX>) -> Self {
110        value.into_inner()
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::BoundedStr;
117    use crate::PrimitiveError;
118    use alloc::string::{String, ToString};
119
120    #[test]
121    fn accepts_valid_length() {
122        let value = BoundedStr::<3, 12>::new("service").unwrap();
123        assert_eq!(value.as_str(), "service");
124        assert_eq!(value.len(), 7);
125        assert_eq!(value.min_len(), 3);
126        assert_eq!(value.max_len(), 12);
127    }
128
129    #[test]
130    fn rejects_too_short() {
131        assert_eq!(
132            BoundedStr::<3, 12>::new("ab").unwrap_err(),
133            PrimitiveError::TooShort { min: 3, actual: 2 }
134        );
135    }
136
137    #[test]
138    fn rejects_too_long() {
139        assert_eq!(
140            BoundedStr::<3, 5>::new("service").unwrap_err(),
141            PrimitiveError::TooLong { max: 5, actual: 7 }
142        );
143    }
144
145    #[test]
146    fn counts_unicode_chars() {
147        let value = BoundedStr::<2, 2>::new("éå").unwrap();
148        assert_eq!(value.len(), 2);
149        assert_eq!(value.as_str().len(), 4);
150    }
151
152    #[test]
153    fn rejects_whitespace_only_when_min_positive() {
154        assert_eq!(
155            BoundedStr::<1, 5>::new("  ").unwrap_err(),
156            PrimitiveError::Empty
157        );
158    }
159
160    #[test]
161    fn handles_invalid_bounds() {
162        assert_eq!(
163            BoundedStr::<5, 3>::new("abcd").unwrap_err(),
164            PrimitiveError::OutOfRange {
165                min: 5,
166                max: 3,
167                actual: 4
168            }
169        );
170    }
171
172    #[test]
173    fn into_inner_returns_string() {
174        let value = BoundedStr::<3, 10>::new("hello").unwrap();
175        assert_eq!(value.into_inner(), "hello");
176    }
177
178    #[test]
179    fn is_empty_returns_false_for_valid() {
180        let value = BoundedStr::<3, 10>::new("hello").unwrap();
181        assert!(!value.is_empty());
182    }
183
184    #[test]
185    fn display_formats_inner_string() {
186        let value = BoundedStr::<3, 10>::new("hello").unwrap();
187        assert_eq!(value.to_string(), "hello");
188    }
189
190    #[test]
191    fn as_ref_returns_str() {
192        let value = BoundedStr::<3, 10>::new("hello").unwrap();
193        let s: &str = value.as_ref();
194        assert_eq!(s, "hello");
195    }
196
197    #[test]
198    fn deref_to_str() {
199        let value = BoundedStr::<3, 10>::new("hello").unwrap();
200        assert_eq!(&*value, "hello");
201    }
202
203    #[test]
204    fn try_from_string() {
205        let value = BoundedStr::<3, 10>::try_from(String::from("hello")).unwrap();
206        assert_eq!(value.as_str(), "hello");
207    }
208
209    #[test]
210    fn try_from_str_ref() {
211        let value = BoundedStr::<3, 10>::try_from("hello").unwrap();
212        assert_eq!(value.as_str(), "hello");
213    }
214
215    #[test]
216    fn from_bounded_str_into_string() {
217        let value = BoundedStr::<3, 10>::new("hello").unwrap();
218        let s = String::from(value);
219        assert_eq!(s, "hello");
220    }
221
222    #[test]
223    fn allows_zero_min_whitespace_only() {
224        let value = BoundedStr::<0, 5>::new("   ").unwrap();
225        assert_eq!(value.as_str(), "   ");
226    }
227}