Skip to main content

ocpi_kit/types/
text.rs

1//! Shared character-set rules for the two OCPI string types.
2
3use core::fmt;
4
5/// Which of the two OCPI string types a rule applies to.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7pub enum StringKind {
8    /// `CiString`: case-insensitive, printable ASCII only.
9    ///
10    /// Spec: 2.3.0 §types_cistring_type
11    Ci,
12    /// `string`: case-sensitive, printable UTF-8.
13    ///
14    /// Spec: 2.3.0 §types_string_type
15    Utf8,
16}
17
18impl StringKind {
19    const fn name(self) -> &'static str {
20        match self {
21            Self::Ci => "CiString",
22            Self::Utf8 => "string",
23        }
24    }
25}
26
27/// Why a string could not be accepted by a strict constructor.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct InvalidString {
30    kind: StringKind,
31    reason: Reason,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35enum Reason {
36    TooLong { len: usize, max: usize },
37    WrongLength { len: usize, expected: usize },
38    NonPrintable { at: usize, ch: char },
39    NonAscii { at: usize, ch: char },
40}
41
42impl InvalidString {
43    pub(crate) const fn too_long(len: usize, max: usize, kind: StringKind) -> Self {
44        Self { kind, reason: Reason::TooLong { len, max } }
45    }
46
47    /// For a field the specification fixes at one exact length, such as the five-character
48    /// `hub_party_id`. Reporting a short value as "too long" is not a smaller mistake than
49    /// reporting nothing.
50    pub(crate) const fn wrong_length(len: usize, expected: usize, kind: StringKind) -> Self {
51        Self { kind, reason: Reason::WrongLength { len, expected } }
52    }
53
54    /// Whether the string was rejected only because it was too long.
55    #[must_use]
56    pub const fn is_too_long(&self) -> bool {
57        matches!(self.reason, Reason::TooLong { .. })
58    }
59
60    /// The string type whose rules were broken.
61    #[must_use]
62    pub const fn kind(&self) -> StringKind {
63        self.kind
64    }
65}
66
67impl fmt::Display for InvalidString {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        let name = self.kind.name();
70        match self.reason {
71            Reason::TooLong { len, max } => {
72                write!(f, "{name}({max}) cannot hold {len} characters")
73            }
74            Reason::WrongLength { len, expected } => {
75                write!(f, "{name} must be exactly {expected} characters, not {len}")
76            }
77            Reason::NonPrintable { at, ch } => write!(
78                f,
79                "{name} must contain only printable characters, found U+{:04X} at index {at}",
80                ch as u32
81            ),
82            Reason::NonAscii { at, ch } => {
83                write!(f, "{name} must contain only ASCII, found U+{:04X} at index {at}", ch as u32)
84            }
85        }
86    }
87}
88
89impl std::error::Error for InvalidString {}
90
91/// Enforces the `CiString` character set: U+0020..=U+007E.
92///
93/// Spec: 2.3.0 §types_cistring_type — *"Only printable ASCII allowed. (Non-printable characters
94/// like: Carriage returns, Tabs, Line breaks, etc are not allowed)"*
95pub(crate) fn check_printable_ascii(value: &str, kind: StringKind) -> Result<(), InvalidString> {
96    for (at, ch) in value.char_indices() {
97        if !ch.is_ascii() {
98            return Err(InvalidString { kind, reason: Reason::NonAscii { at, ch } });
99        }
100        if !is_printable_ascii(ch) {
101            return Err(InvalidString { kind, reason: Reason::NonPrintable { at, ch } });
102        }
103    }
104    Ok(())
105}
106
107/// Enforces the `string` character set: printable UTF-8, no control characters.
108///
109/// Spec: 2.3.0 §types_string_type — *"Case Sensitive String. Only printable UTF-8 allowed."*
110///
111/// "Printable" is read as "not a Unicode control character": C0 (U+0000..=U+001F), DEL (U+007F)
112/// and C1 (U+0080..=U+009F) are rejected, everything else — including emoji and all scripts —
113/// is accepted. The spec names carriage returns, tabs and line breaks as the motivating cases.
114pub(crate) fn check_printable_utf8(value: &str, kind: StringKind) -> Result<(), InvalidString> {
115    for (at, ch) in value.char_indices() {
116        if ch.is_control() {
117            return Err(InvalidString { kind, reason: Reason::NonPrintable { at, ch } });
118        }
119    }
120    Ok(())
121}
122
123const fn is_printable_ascii(ch: char) -> bool {
124    matches!(ch, ' '..='~')
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn ascii_rules() {
133        assert!(check_printable_ascii("Hello, World! ~", StringKind::Ci).is_ok());
134        assert!(check_printable_ascii("tab\there", StringKind::Ci).is_err());
135        assert!(check_printable_ascii("\u{7f}", StringKind::Ci).is_err(), "DEL is not printable");
136        assert!(check_printable_ascii("é", StringKind::Ci).is_err());
137    }
138
139    #[test]
140    fn utf8_rules() {
141        assert!(check_printable_utf8("Straße — 日本語 🚗", StringKind::Utf8).is_ok());
142        assert!(check_printable_utf8("line\nbreak", StringKind::Utf8).is_err());
143        assert!(check_printable_utf8("\u{85}", StringKind::Utf8).is_err(), "C1 NEL is a control");
144    }
145
146    #[test]
147    fn error_messages_name_the_offending_index() {
148        let e = check_printable_ascii("ab\tcd", StringKind::Ci).unwrap_err();
149        assert!(e.to_string().contains("index 2"), "{e}");
150        assert!(!e.is_too_long());
151    }
152}