1use std::collections::HashMap;
4use std::sync::Arc;
5
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum CharSetError {
10 #[error("CharSet requires at least 2 characters, got {0}")]
11 TooFew(usize),
12
13 #[error("duplicate character '{0}' in CharSet")]
14 Duplicate(char),
15}
16
17#[derive(Debug, Clone)]
23pub struct CharSet {
24 chars: Arc<[char]>,
25 index_of: HashMap<char, usize>,
26}
27
28const DEFAULT_CHARS: &[char] = &[
31 ' ', 'A', 'B', 'C', 'D', 'Ð', 'E', 'F', 'G', 'H', 'I', 'Ɨ', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
32 'Ᵽ', 'Q', 'R', 'Ɍ', 'S', 'T', 'Ŧ', 'U', 'Ʉ', 'V', 'W', 'X', 'Y', 'Ɏ', 'Z', 'Ƶ', '0', '1', '2',
33 '3', '4', '5', '6', '7', '8', '9', '.', ',', '!', '?', '&', '#', '@', '-', '\'', ':', '/',
34];
35
36impl CharSet {
37 pub fn new(chars: impl Into<Arc<[char]>>) -> Result<Self, CharSetError> {
38 let chars: Arc<[char]> = chars.into();
39
40 if chars.len() < 2 {
41 return Err(CharSetError::TooFew(chars.len()));
42 }
43
44 let mut index_of = HashMap::with_capacity(chars.len());
45
46 for (i, &ch) in chars.iter().enumerate() {
47 if index_of.insert(ch, i).is_some() {
48 return Err(CharSetError::Duplicate(ch));
49 }
50 }
51
52 Ok(Self { chars, index_of })
53 }
54
55 #[must_use]
56 pub fn len(&self) -> usize {
57 self.chars.len()
58 }
59
60 #[must_use]
61 pub fn is_empty(&self) -> bool {
62 self.chars.is_empty()
63 }
64
65 pub fn get(&self, index: usize) -> Option<char> {
67 self.chars.get(index).copied()
68 }
69
70 pub fn index_of(&self, ch: char) -> Option<usize> {
72 self.index_of.get(&ch).copied()
73 }
74
75 pub fn contains(&self, ch: char) -> bool {
76 self.index_of.contains_key(&ch)
77 }
78
79 pub fn distance(&self, from: char, to: char) -> usize {
81 let a = self.resolve_index(from);
82 let b = self.resolve_index(to);
83
84 if a == b {
85 return 0;
86 }
87
88 let len = self.chars.len();
89 (b + len - a) % len
90 }
91
92 fn resolve_index(&self, ch: char) -> usize {
93 self.index_of.get(&ch).copied().unwrap_or(0)
94 }
95
96 pub(crate) fn step_forward(&self, from: char, steps: usize) -> char {
97 let start = self.resolve_index(from);
98 let target = (start + steps) % self.chars.len();
99 self.chars[target]
100 }
101
102 pub(crate) fn sanitize(&self, ch: char) -> char {
103 self.chars[self.resolve_index(ch)]
104 }
105}
106
107impl Default for CharSet {
108 fn default() -> Self {
109 Self::new(DEFAULT_CHARS).expect("default CharSet is valid")
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn default_charset_starts_with_space() {
119 let cs = CharSet::default();
120 assert_eq!(cs.get(0), Some(' '));
121 }
122
123 #[test]
124 fn default_charset_contains_all_expected_characters() {
125 let cs = CharSet::default();
126
127 assert!(cs.contains(' '));
128
129 for ch in 'A'..='Z' {
130 assert!(cs.contains(ch), "missing letter {ch}");
131 }
132
133 for ch in '0'..='9' {
134 assert!(cs.contains(ch), "missing digit {ch}");
135 }
136
137 for ch in ['.', ',', '!', '?', '&', '#', '@', '-', '\'', ':', '/'] {
138 assert!(cs.contains(ch), "missing punctuation {ch}");
139 }
140 }
141
142 #[test]
143 fn default_charset_order() {
144 let cs = CharSet::default();
145 assert_eq!(cs.index_of('A'), Some(1));
146 assert_eq!(cs.index_of('Z'), Some(33));
147 assert_eq!(cs.index_of('0'), Some(35));
148 assert_eq!(cs.index_of('9'), Some(44));
149 }
150
151 #[test]
152 fn default_charset_length() {
153 let cs = CharSet::default();
154 assert_eq!(cs.len(), 56);
155 }
156
157 #[test]
158 fn default_charset_contains_variant_glyphs() {
159 let cs = CharSet::default();
160
161 for (base, variant) in [
162 ('D', 'Ð'),
163 ('I', 'Ɨ'),
164 ('P', 'Ᵽ'),
165 ('R', 'Ɍ'),
166 ('T', 'Ŧ'),
167 ('U', 'Ʉ'),
168 ('Y', 'Ɏ'),
169 ('Z', 'Ƶ'),
170 ] {
171 let base_idx = cs
172 .index_of(base)
173 .expect("base char should be in default charset");
174 let variant_idx = cs
175 .index_of(variant)
176 .expect("variant char should be in default charset");
177 assert_eq!(variant_idx, base_idx + 1, "{variant} should follow {base}");
178 }
179 }
180
181 #[test]
182 fn distance_forward_same_region() {
183 let cs = CharSet::default();
184 assert_eq!(cs.distance('A', 'F'), 6);
186 }
187
188 #[test]
189 fn distance_wraps_forward() {
190 let cs = CharSet::default();
191 let d = cs.distance('Z', 'A');
192
193 assert_eq!(d, 24);
195 assert!(d > 0, "must wrap forward, not backward");
196 }
197
198 #[test]
199 fn distance_identical_is_zero() {
200 let cs = CharSet::default();
201 assert_eq!(cs.distance('A', 'A'), 0);
202 }
203
204 #[test]
205 fn distance_unknown_char_treated_as_space() {
206 let cs = CharSet::default();
207
208 assert_eq!(cs.distance('~', 'A'), 1);
211 }
212
213 #[test]
214 fn step_forward_basic() {
215 let cs = CharSet::default();
216 assert_eq!(cs.step_forward('A', 6), 'F');
218 }
219
220 #[test]
221 fn step_forward_wraps() {
222 let cs = CharSet::default();
223 let result = cs.step_forward('/', 1);
224 assert_eq!(result, ' ', "wrapping past last char returns to space");
225 }
226
227 #[test]
228 fn sanitize_known_char_unchanged() {
229 let cs = CharSet::default();
230 assert_eq!(cs.sanitize('A'), 'A');
231 }
232
233 #[test]
234 fn sanitize_unknown_char_becomes_space() {
235 let cs = CharSet::default();
236 assert_eq!(cs.sanitize('~'), ' ');
237 }
238
239 #[test]
240 fn custom_charset_accepted() {
241 static CUSTOM: &[char] = &['X', 'Y', 'Z'];
242 let cs = CharSet::new(CUSTOM).expect("CUSTOM should be a valid charset");
243 assert_eq!(cs.len(), 3);
244 assert_eq!(cs.distance('X', 'Z'), 2);
245 }
246
247 #[test]
248 fn custom_charset_too_few_rejected() {
249 static ONE: &[char] = &['A'];
250 let err = CharSet::new(ONE).expect_err("single-char charset should be rejected");
251 assert!(matches!(err, CharSetError::TooFew(1)));
252 }
253
254 #[test]
255 fn custom_charset_empty_rejected() {
256 static EMPTY: &[char] = &[];
257 let err = CharSet::new(EMPTY).expect_err("empty charset should be rejected");
258 assert!(matches!(err, CharSetError::TooFew(0)));
259 }
260
261 #[test]
262 fn custom_charset_duplicates_rejected() {
263 static DUPE: &[char] = &['A', 'B', 'A'];
264 let err = CharSet::new(DUPE).expect_err("charset with duplicates should be rejected");
265 assert!(matches!(err, CharSetError::Duplicate('A')));
266 }
267
268 #[test]
269 fn index_lookup_is_consistent() {
270 let cs = CharSet::default();
271
272 for i in 0..cs.len() {
273 let ch = cs.get(i).expect("index should be within charset bounds");
274 assert_eq!(cs.index_of(ch), Some(i));
275 }
276 }
277}