Skip to main content

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}