1use core::fmt;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7pub enum StringKind {
8 Ci,
12 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#[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 pub(crate) const fn wrong_length(len: usize, expected: usize, kind: StringKind) -> Self {
51 Self { kind, reason: Reason::WrongLength { len, expected } }
52 }
53
54 #[must_use]
56 pub const fn is_too_long(&self) -> bool {
57 matches!(self.reason, Reason::TooLong { .. })
58 }
59
60 #[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
91pub(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
107pub(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}