regit_identifiers/wkn.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! WKN — Wertpapierkennnummer (WM Datenservice).
5//!
6//! A WKN is the German national securities identifying number. It is exactly
7//! 6 characters with no internal structure:
8//!
9//! ```text
10//! A 1 E W W W
11//! └─────┬─────┘
12//! └ identifier [0..6] six characters [0-9A-Z], excluding I and O
13//! ```
14//!
15//! - Each character is an ASCII digit or an upper-case letter, with the two
16//! letters `I` and `O` **excluded** — they are barred to avoid visual
17//! confusion with the digits `1` and `0`.
18//! - A WKN has **no segments and no check digit**: validation is purely a
19//! length and character-set check.
20//!
21//! [`Wkn::parse`] enforces every rule: the exact length and the per-character
22//! set, rejecting a literal `I` or `O` as an invalid character.
23//!
24//! # References
25//!
26//! - WM Datenservice, *Wertpapierkennnummer (WKN)* — the German national
27//! securities-numbering scheme.
28
29use crate::errors::ValidationError;
30
31/// A validated Wertpapierkennnummer (WKN).
32///
33/// A `Wkn` can only be created by [`Wkn::parse`] (or the explicitly unchecked
34/// [`Wkn::from_bytes_unchecked`]), so a value of this type is a proof that the
35/// six characters form a structurally valid WKN. It stores the identifier
36/// inline as `[u8; 6]`, is `Copy`, and allocates nothing.
37///
38/// # Examples
39///
40/// ```
41/// use regit_identifiers::Wkn;
42///
43/// let wkn = Wkn::parse("A1EWWW").unwrap();
44/// assert_eq!(wkn.as_str(), "A1EWWW");
45/// assert!(!wkn.is_numeric());
46/// ```
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub struct Wkn {
49 /// The 6 validated ASCII bytes of the identifier.
50 bytes: [u8; Self::LENGTH],
51}
52
53impl Wkn {
54 /// The number of characters in a WKN.
55 pub const LENGTH: usize = 6;
56
57 /// Parses and fully validates a WKN.
58 ///
59 /// Validation is strict and, in order: the input must be exactly 6
60 /// characters; each character must be an ASCII digit or an upper-case
61 /// letter, with `I` and `O` excluded. A WKN has no check digit, so a
62 /// structurally valid string is always accepted.
63 ///
64 /// # Errors
65 ///
66 /// - [`ValidationError::WrongLength`] if the input is not 6 characters.
67 /// - [`ValidationError::InvalidCharacter`] if a character is not an ASCII
68 /// digit or upper-case letter, or is a literal `I` or `O` (this also
69 /// rejects lower-case input and any non-ASCII character).
70 ///
71 /// # Examples
72 ///
73 /// ```
74 /// use regit_identifiers::Wkn;
75 /// use regit_identifiers::errors::ValidationError;
76 ///
77 /// assert!(Wkn::parse("766403").is_ok());
78 ///
79 /// // A literal `I` is rejected, not silently accepted.
80 /// assert_eq!(
81 /// Wkn::parse("A1IWWW"),
82 /// Err(ValidationError::InvalidCharacter { position: 3, found: 'I' }),
83 /// );
84 /// ```
85 pub fn parse(s: &str) -> Result<Self, ValidationError> {
86 // A WKN is exactly 6 characters.
87 let found = s.chars().count();
88 if found != Self::LENGTH {
89 return Err(ValidationError::WrongLength {
90 expected: Self::LENGTH,
91 found,
92 });
93 }
94 // Per-character set: an ASCII digit or upper-case letter, excluding the
95 // letters `I` and `O`. A non-ASCII character fails the predicate and is
96 // rejected here.
97 for (i, ch) in s.chars().enumerate() {
98 let legal = (ch.is_ascii_digit() || ch.is_ascii_uppercase()) && ch != 'I' && ch != 'O';
99 if !legal {
100 return Err(ValidationError::InvalidCharacter {
101 position: i + 1,
102 found: ch,
103 });
104 }
105 }
106 // Every character is ASCII, so the string is exactly 6 ASCII bytes.
107 let mut bytes = [0u8; Self::LENGTH];
108 bytes.copy_from_slice(s.as_bytes());
109 Ok(Self { bytes })
110 }
111
112 /// Validates a WKN without constructing one.
113 ///
114 /// Equivalent to `Wkn::parse(s).map(|_| ())`; use it when only the verdict
115 /// is needed.
116 ///
117 /// # Errors
118 ///
119 /// Returns the same [`ValidationError`] variants as [`Wkn::parse`].
120 ///
121 /// # Examples
122 ///
123 /// ```
124 /// use regit_identifiers::Wkn;
125 ///
126 /// assert!(Wkn::validate("519000").is_ok());
127 /// assert!(Wkn::validate("A1IWWW").is_err());
128 /// ```
129 pub fn validate(s: &str) -> Result<(), ValidationError> {
130 Self::parse(s).map(|_| ())
131 }
132
133 /// Wraps 6 raw bytes as a `Wkn` without any validation.
134 ///
135 /// The caller asserts that `bytes` holds the 6 ASCII characters of a valid
136 /// WKN. This exists for reconstructing a `Wkn` from bytes that were
137 /// validated earlier; prefer [`Wkn::parse`] for any untrusted input.
138 ///
139 /// # Examples
140 ///
141 /// ```
142 /// use regit_identifiers::Wkn;
143 ///
144 /// let wkn = Wkn::from_bytes_unchecked(*b"A1EWWW");
145 /// assert_eq!(wkn.as_str(), "A1EWWW");
146 /// ```
147 #[must_use]
148 pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
149 Self { bytes }
150 }
151
152 /// Returns the WKN as a string slice.
153 ///
154 /// # Examples
155 ///
156 /// ```
157 /// use regit_identifiers::Wkn;
158 ///
159 /// assert_eq!(Wkn::parse("766403").unwrap().as_str(), "766403");
160 /// ```
161 #[must_use]
162 #[inline]
163 pub fn as_str(&self) -> &str {
164 core::str::from_utf8(&self.bytes).unwrap_or("")
165 }
166
167 /// Returns the WKN as its 6 raw ASCII bytes.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// use regit_identifiers::Wkn;
173 ///
174 /// assert_eq!(Wkn::parse("766403").unwrap().as_bytes(), b"766403");
175 /// ```
176 #[must_use]
177 #[inline]
178 pub fn as_bytes(&self) -> &[u8] {
179 &self.bytes
180 }
181
182 /// Returns `true` if all six characters are ASCII digits.
183 ///
184 /// A purely numeric WKN is a legacy identifier; alphanumeric WKNs were
185 /// introduced once the numeric space began to run out.
186 ///
187 /// # Examples
188 ///
189 /// ```
190 /// use regit_identifiers::Wkn;
191 ///
192 /// assert!(Wkn::parse("766403").unwrap().is_numeric());
193 /// assert!(!Wkn::parse("A1EWWW").unwrap().is_numeric());
194 /// ```
195 #[must_use]
196 #[inline]
197 pub fn is_numeric(&self) -> bool {
198 self.bytes.iter().all(u8::is_ascii_digit)
199 }
200}
201
202impl core::fmt::Display for Wkn {
203 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
204 f.write_str(self.as_str())
205 }
206}
207
208impl core::str::FromStr for Wkn {
209 type Err = ValidationError;
210
211 fn from_str(s: &str) -> Result<Self, Self::Err> {
212 Self::parse(s)
213 }
214}
215
216impl AsRef<str> for Wkn {
217 fn as_ref(&self) -> &str {
218 self.as_str()
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::test_support::display;
226 use core::str::FromStr;
227
228 /// Real, well-known WKNs used as regression anchors.
229 const GOLDEN: &[&str] = &[
230 "766403", // Volkswagen AG
231 "519000", // Bayerische Motoren Werke AG
232 "A1EWWW", // Adidas AG
233 ];
234
235 #[test]
236 fn parses_golden_wkns() {
237 for &s in GOLDEN {
238 let wkn = Wkn::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
239 assert_eq!(wkn.as_str(), s);
240 }
241 }
242
243 #[test]
244 fn accessors() {
245 let wkn = Wkn::parse("A1EWWW").unwrap();
246 assert_eq!(wkn.as_str(), "A1EWWW");
247 assert_eq!(wkn.as_bytes(), b"A1EWWW");
248 assert_eq!(Wkn::LENGTH, 6);
249 }
250
251 #[test]
252 fn is_numeric_classifies() {
253 assert!(Wkn::parse("766403").unwrap().is_numeric());
254 assert!(Wkn::parse("519000").unwrap().is_numeric());
255 assert!(!Wkn::parse("A1EWWW").unwrap().is_numeric());
256 assert!(!Wkn::parse("ABCDEF").unwrap().is_numeric());
257 }
258
259 #[test]
260 fn rejects_letter_i() {
261 assert_eq!(
262 Wkn::parse("A1IWWW"),
263 Err(ValidationError::InvalidCharacter {
264 position: 3,
265 found: 'I',
266 })
267 );
268 }
269
270 #[test]
271 fn rejects_letter_o() {
272 assert_eq!(
273 Wkn::parse("A1OWWW"),
274 Err(ValidationError::InvalidCharacter {
275 position: 3,
276 found: 'O',
277 })
278 );
279 }
280
281 #[test]
282 fn rejects_wrong_length() {
283 assert_eq!(
284 Wkn::parse("76640"),
285 Err(ValidationError::WrongLength {
286 expected: 6,
287 found: 5,
288 })
289 );
290 assert_eq!(
291 Wkn::parse("7664033"),
292 Err(ValidationError::WrongLength {
293 expected: 6,
294 found: 7,
295 })
296 );
297 assert_eq!(
298 Wkn::parse(""),
299 Err(ValidationError::WrongLength {
300 expected: 6,
301 found: 0,
302 })
303 );
304 }
305
306 #[test]
307 fn rejects_lower_case() {
308 assert!(matches!(
309 Wkn::parse("a1ewww"),
310 Err(ValidationError::InvalidCharacter { position: 1, .. })
311 ));
312 }
313
314 #[test]
315 fn rejects_punctuation() {
316 assert!(matches!(
317 Wkn::parse("A1-WWW"),
318 Err(ValidationError::InvalidCharacter {
319 position: 3,
320 found: '-',
321 })
322 ));
323 }
324
325 #[test]
326 fn rejects_non_ascii_without_panic() {
327 // A multi-byte character must be rejected cleanly.
328 assert!(Wkn::parse("A1EWWé").is_err());
329 assert!(Wkn::parse("É1EWWW").is_err());
330 }
331
332 #[test]
333 fn round_trips_through_str() {
334 for &s in GOLDEN {
335 assert_eq!(Wkn::parse(s).unwrap().as_str(), s);
336 }
337 }
338
339 #[test]
340 fn from_str_matches_parse() {
341 assert_eq!(Wkn::from_str("A1EWWW"), Wkn::parse("A1EWWW"));
342 assert!(Wkn::from_str("nonsense").is_err());
343 }
344
345 #[test]
346 fn display_renders_identifier() {
347 let wkn = Wkn::parse("A1EWWW").unwrap();
348 assert_eq!(display(wkn).as_str(), "A1EWWW");
349 }
350
351 #[test]
352 fn as_ref_str() {
353 let wkn = Wkn::parse("766403").unwrap();
354 let s: &str = wkn.as_ref();
355 assert_eq!(s, "766403");
356 }
357
358 #[test]
359 fn validate_matches_parse() {
360 assert!(Wkn::validate("519000").is_ok());
361 assert!(Wkn::validate("A1IWWW").is_err());
362 }
363
364 #[test]
365 fn from_bytes_unchecked_round_trip() {
366 let wkn = Wkn::from_bytes_unchecked(*b"A1EWWW");
367 assert_eq!(wkn, Wkn::parse("A1EWWW").unwrap());
368 }
369
370 #[test]
371 fn is_copy_and_eq_and_hashable() {
372 let a = Wkn::parse("A1EWWW").unwrap();
373 let b = a; // Copy
374 assert_eq!(a, b);
375 assert_ne!(a, Wkn::parse("766403").unwrap());
376 // Usable as a map key (Eq + Hash) — checked by constructing a slice.
377 let keys = [a, b];
378 assert_eq!(keys[0], keys[1]);
379 }
380}