Skip to main content

ocpi_kit/types/
cistring.rs

1//! `CiString` — the OCPI case-insensitive, printable-ASCII string type.
2
3use core::fmt;
4use core::hash::{Hash, Hasher};
5use core::str::FromStr;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::text::{InvalidString, StringKind, check_printable_ascii};
10use super::validate::{Validate, Validator, ViolationCode};
11
12/// A case-insensitive OCPI string of at most `N` characters.
13///
14/// > *Case Insensitive String. Only printable ASCII allowed. (Non-printable characters like:
15/// > Carriage returns, Tabs, Line breaks, etc are not allowed)*
16///
17/// `N` is the maximum length from the spec's property table, so `CiString<36>` is the spec's
18/// `CiString(36)`. Because the character set is printable ASCII, "characters" and "bytes" are
19/// the same thing here and the limit is unambiguous — unlike [`OcpiString`](super::OcpiString).
20///
21/// # Case insensitivity
22///
23/// [`PartialEq`], [`Eq`], [`Hash`] and [`Ord`] are **case-insensitive**, so a `CiString` used as
24/// a map key behaves the way the spec says identifiers compare. [`Display`](fmt::Display) and
25/// [`AsRef<str>`] preserve the original case, so re-serialising an object never rewrites a
26/// peer's identifiers.
27///
28/// ```
29/// use ocpi_kit::types::CiString;
30///
31/// let a: CiString<36> = "NL*TNM*001".parse().unwrap();
32/// let b: CiString<36> = "nl*tnm*001".parse().unwrap();
33/// assert_eq!(a, b);
34/// assert_eq!(a.as_str(), "NL*TNM*001"); // original case is preserved
35/// ```
36///
37/// # Strict and lenient construction
38///
39/// [`CiString::new`] and [`FromStr`] reject anything the spec forbids:
40///
41/// ```
42/// # use ocpi_kit::types::CiString;
43/// assert!("far too long for three".parse::<CiString<3>>().is_err());
44/// ```
45///
46/// The infallible `From<&str>`/`From<String>` conversions — which is what a builder setter uses
47/// — are **lenient**, exactly like `Deserialize`: they accept what they are given and leave the
48/// complaint to [`Validate::validate`]. That is what keeps a peer's over-long identifier from
49/// making a whole page of Locations undecodable, and it is why the
50/// [`client`](crate::client) validates outgoing objects before sending them. See
51/// [`types::validate`](super::validate) for the full reasoning.
52///
53/// ```
54/// # use ocpi_kit::types::{CiString, Validate};
55/// let lenient: CiString<3> = "far too long for three".into();
56/// assert!(lenient.validate().is_err());
57/// ```
58///
59/// Spec: 2.3.0 §types_cistring_type
60#[derive(Clone, Default)]
61pub struct CiString<const N: usize>(String);
62
63impl<const N: usize> CiString<N> {
64    /// The maximum length the spec allows for this string, in characters.
65    pub const MAX_LEN: usize = N;
66
67    /// Creates a `CiString`, enforcing the character set and the length limit.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`InvalidString`] if `value` contains a character outside printable ASCII
72    /// (U+0020..=U+007E) or is longer than `N` characters.
73    pub fn new(value: impl Into<String>) -> Result<Self, InvalidString> {
74        let value = value.into();
75        check_printable_ascii(&value, StringKind::Ci)?;
76        if value.len() > N {
77            return Err(InvalidString::too_long(value.len(), N, StringKind::Ci));
78        }
79        Ok(Self(value))
80    }
81
82    /// Creates a `CiString` without enforcing anything.
83    ///
84    /// This is what `Deserialize` uses. Prefer [`CiString::new`] for values this process
85    /// originates; use [`Validate::validate`] to find out afterwards whether a received value
86    /// is conformant.
87    pub fn new_lenient(value: impl Into<String>) -> Self {
88        Self(value.into())
89    }
90
91    /// The string with its original case preserved.
92    #[must_use]
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96
97    /// Consumes this string and yields the inner [`String`], original case preserved.
98    #[must_use]
99    pub fn into_string(self) -> String {
100        self.0
101    }
102
103    /// The length in characters, which for printable ASCII equals the length in bytes.
104    #[must_use]
105    pub fn len(&self) -> usize {
106        self.0.len()
107    }
108
109    /// Whether the string is empty.
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.0.is_empty()
113    }
114
115    /// Whether this value satisfies the spec constraints that [`CiString::new`] enforces.
116    #[must_use]
117    pub fn is_conformant(&self) -> bool {
118        self.0.len() <= N && check_printable_ascii(&self.0, StringKind::Ci).is_ok()
119    }
120
121    /// Re-types this string to a different maximum length.
122    ///
123    /// Used where the spec reuses one identifier under two limits, for example a
124    /// `CiString(36)` `CDR.id` widened to the `CiString(39)` of a credit CDR.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`InvalidString`] if the value does not fit in `M` characters.
129    pub fn resize<const M: usize>(self) -> Result<CiString<M>, InvalidString> {
130        CiString::<M>::new(self.0)
131    }
132
133    /// Compares case-insensitively against a plain string.
134    #[must_use]
135    pub fn eq_ignore_case(&self, other: &str) -> bool {
136        self.0.eq_ignore_ascii_case(other)
137    }
138
139    /// The `#NA` sentinel the spec allows where a required string cannot be filled.
140    ///
141    /// Spec: 2.3.0 §transport_and_format_not_available
142    pub const NOT_AVAILABLE: &'static str = "#NA";
143
144    /// Whether this value is the `#NA` sentinel.
145    ///
146    /// Spec: 2.3.0 §transport_and_format_not_available
147    #[must_use]
148    pub fn is_not_available(&self) -> bool {
149        self.0.eq_ignore_ascii_case(Self::NOT_AVAILABLE)
150    }
151}
152
153impl<const N: usize> Validate for CiString<N> {
154    fn validate_in(&self, v: &mut Validator) {
155        if let Err(e) = check_printable_ascii(&self.0, StringKind::Ci) {
156            v.report(ViolationCode::IllegalCharacter, e.to_string());
157        }
158        if self.0.len() > N {
159            v.report(ViolationCode::TooLong, format!("CiString({N}) holds {} characters", self.0.len()));
160        }
161    }
162}
163
164impl<const N: usize> fmt::Display for CiString<N> {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(&self.0)
167    }
168}
169
170impl<const N: usize> fmt::Debug for CiString<N> {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        fmt::Debug::fmt(&self.0, f)
173    }
174}
175
176impl<const N: usize> PartialEq for CiString<N> {
177    fn eq(&self, other: &Self) -> bool {
178        self.0.eq_ignore_ascii_case(&other.0)
179    }
180}
181
182impl<const N: usize> Eq for CiString<N> {}
183
184impl<const N: usize> PartialOrd for CiString<N> {
185    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
186        Some(self.cmp(other))
187    }
188}
189
190impl<const N: usize> Ord for CiString<N> {
191    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
192        let a = self.0.bytes().map(|b| b.to_ascii_lowercase());
193        let b = other.0.bytes().map(|b| b.to_ascii_lowercase());
194        a.cmp(b)
195    }
196}
197
198impl<const N: usize> Hash for CiString<N> {
199    fn hash<H: Hasher>(&self, state: &mut H) {
200        // Hash the case-folded form so that `a == b` implies `hash(a) == hash(b)`.
201        for byte in self.0.bytes() {
202            state.write_u8(byte.to_ascii_lowercase());
203        }
204        state.write_u8(0xff);
205    }
206}
207
208impl<const N: usize> AsRef<str> for CiString<N> {
209    fn as_ref(&self) -> &str {
210        &self.0
211    }
212}
213
214impl<const N: usize> core::ops::Deref for CiString<N> {
215    type Target = str;
216    fn deref(&self) -> &str {
217        &self.0
218    }
219}
220
221impl<const N: usize> FromStr for CiString<N> {
222    type Err = InvalidString;
223    fn from_str(s: &str) -> Result<Self, Self::Err> {
224        Self::new(s)
225    }
226}
227
228// The infallible conversions are **lenient**, matching `Deserialize`; see the type docs.
229impl<const N: usize> From<&str> for CiString<N> {
230    fn from(s: &str) -> Self {
231        Self::new_lenient(s)
232    }
233}
234
235impl<const N: usize> From<String> for CiString<N> {
236    fn from(s: String) -> Self {
237        Self::new_lenient(s)
238    }
239}
240
241impl<const N: usize> Serialize for CiString<N> {
242    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
243        serializer.serialize_str(&self.0)
244    }
245}
246
247impl<'de, const N: usize> Deserialize<'de> for CiString<N> {
248    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
249        String::deserialize(deserializer).map(Self)
250    }
251}
252
253#[cfg(feature = "schema")]
254impl<const N: usize> schemars::JsonSchema for CiString<N> {
255    fn schema_name() -> std::borrow::Cow<'static, str> {
256        format!("CiString{N}").into()
257    }
258    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
259        schemars::json_schema!({
260            "type": "string",
261            "maxLength": N,
262            "pattern": "^[\\u0020-\\u007E]*$",
263            "description": "OCPI CiString: case-insensitive, printable ASCII only",
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use std::collections::HashSet;
272
273    #[test]
274    fn rejects_non_printable_and_non_ascii() {
275        assert!(CiString::<36>::new("ok").is_ok());
276        assert!(CiString::<36>::new("no\nnewline").is_err());
277        assert!(CiString::<36>::new("no\ttab").is_err());
278        assert!(CiString::<36>::new("caf\u{e9}").is_err(), "CiString is ASCII-only");
279        assert!(CiString::<3>::new("abcd").is_err());
280    }
281
282    #[test]
283    fn equality_and_hashing_ignore_case() {
284        let a = CiString::<36>::new("NL*TNM*001").unwrap();
285        let b = CiString::<36>::new("nl*tnm*001").unwrap();
286        assert_eq!(a, b);
287        let mut set = HashSet::new();
288        set.insert(a);
289        assert!(set.contains(&b), "case-folded hash must agree with case-folded Eq");
290    }
291
292    #[test]
293    fn deserialize_is_permissive_but_validate_complains() {
294        let long: CiString<3> = serde_json::from_str("\"abcdef\"").unwrap();
295        assert_eq!(long.as_str(), "abcdef", "peer data is never dropped");
296        let err = long.validate().unwrap_err();
297        assert_eq!(err.as_slice()[0].code, ViolationCode::TooLong);
298        assert!(!long.is_conformant());
299    }
300
301    #[test]
302    fn serialize_preserves_original_case() {
303        let a = CiString::<36>::new("MiXeD").unwrap();
304        assert_eq!(serde_json::to_string(&a).unwrap(), "\"MiXeD\"");
305    }
306
307    #[test]
308    fn na_sentinel_is_recognised() {
309        assert!(CiString::<36>::new("#NA").unwrap().is_not_available());
310        assert!(!CiString::<36>::new("NA").unwrap().is_not_available());
311    }
312}