Skip to main content

regit_identifiers/
isin.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! ISIN — International Securities Identification Number (ISO 6166).
5//!
6//! An ISIN is the globally recognised primary key of a security. It is
7//! exactly 12 characters in three segments:
8//!
9//! ```text
10//!   U S 0 3 7 8 3 3 1 0 0 5
11//!   └┬┘ └────┬────┘ │
12//!    │       │       └ check digit  [11]      one digit [0-9]
13//!    │       └───────── NSIN         [2..11]   nine characters [A-Z0-9]
14//!    └───────────────── country      [0..2]    ISO 3166-1 or ISIN prefix
15//! ```
16//!
17//! - The **country prefix** is an ISO 3166-1 alpha-2 code or one of the ISIN
18//!   substitute prefixes (`XS`, `EU`, ...) — see [`crate::country`].
19//! - The **NSIN** (National Securities Identifying Number) is the local
20//!   identifier of the security, left-padded into nine characters.
21//! - The **check digit** is the Luhn mod-10 over the expansion of the
22//!   11-character body — see [`crate::checkdigit::isin_check_digit`].
23//!
24//! [`Isin::parse`] enforces every rule: exact length, the per-segment
25//! character set, a recognised country prefix, and a check digit that is
26//! recomputed and verified — never trusted.
27//!
28//! # References
29//!
30//! - ISO 6166, *Securities and related financial instruments —
31//!   International securities identification number (ISIN)*.
32
33use crate::checkdigit;
34use crate::country;
35use crate::errors::ValidationError;
36
37/// A validated International Securities Identification Number (ISO 6166).
38///
39/// An `Isin` can only be created by [`Isin::parse`] (or the explicitly
40/// unchecked [`Isin::from_bytes_unchecked`]), so a value of this type is a
41/// proof that the 12 characters form a structurally valid ISIN with a
42/// correct check digit. It stores the identifier inline as `[u8; 12]`, is
43/// `Copy`, and allocates nothing.
44///
45/// # Examples
46///
47/// ```
48/// use regit_identifiers::Isin;
49///
50/// let isin = Isin::parse("US0378331005").unwrap();
51/// assert_eq!(isin.country_code(), "US");
52/// assert_eq!(isin.nsin(), "037833100");
53/// assert_eq!(isin.check_digit(), '5');
54/// assert_eq!(isin.as_str(), "US0378331005");
55/// ```
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub struct Isin {
58    /// The 12 validated ASCII bytes of the identifier.
59    bytes: [u8; Self::LENGTH],
60}
61
62impl Isin {
63    /// The number of characters in an ISIN.
64    pub const LENGTH: usize = 12;
65
66    /// Parses and fully validates an ISIN.
67    ///
68    /// Validation is strict and, in order: the input must be exactly 12
69    /// characters; characters 1–11 must each be an ASCII digit or upper-case
70    /// letter and character 12 an ASCII digit; the first two characters must
71    /// be a recognised ISIN country prefix; and the check digit must equal
72    /// the value recomputed from the 11-character body.
73    ///
74    /// # Errors
75    ///
76    /// - [`ValidationError::WrongLength`] if the input is not 12 characters.
77    /// - [`ValidationError::InvalidCharacter`] if a character falls outside
78    ///   the set its position allows (this also rejects lower-case input and
79    ///   any non-ASCII character).
80    /// - [`ValidationError::InvalidCountryCode`] if the first two characters
81    ///   are not a recognised ISIN country prefix.
82    /// - [`ValidationError::BadCheckDigit`] if the supplied check digit does
83    ///   not match the recomputed one.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use regit_identifiers::Isin;
89    /// use regit_identifiers::errors::ValidationError;
90    ///
91    /// assert!(Isin::parse("US0378331005").is_ok());
92    ///
93    /// // A single wrong digit is caught, not silently accepted.
94    /// assert_eq!(
95    ///     Isin::parse("US0378331004"),
96    ///     Err(ValidationError::BadCheckDigit { expected: '5', found: '4' }),
97    /// );
98    /// ```
99    pub fn parse(s: &str) -> Result<Self, ValidationError> {
100        // An ISIN is exactly 12 characters.
101        let found = s.chars().count();
102        if found != Self::LENGTH {
103            return Err(ValidationError::WrongLength {
104                expected: Self::LENGTH,
105                found,
106            });
107        }
108        // Per-position character set: [0..11] are [A-Z0-9], [11] is a digit.
109        // A non-ASCII character fails both predicates and is rejected here.
110        for (i, ch) in s.chars().enumerate() {
111            let legal = if i == Self::LENGTH - 1 {
112                ch.is_ascii_digit()
113            } else {
114                ch.is_ascii_digit() || ch.is_ascii_uppercase()
115            };
116            if !legal {
117                return Err(ValidationError::InvalidCharacter {
118                    position: i + 1,
119                    found: ch,
120                });
121            }
122        }
123        // Every character is ASCII, so the string is exactly 12 ASCII bytes.
124        let mut bytes = [0u8; Self::LENGTH];
125        bytes.copy_from_slice(s.as_bytes());
126
127        // The first two characters must be a recognised ISIN country prefix.
128        let prefix = core::str::from_utf8(&bytes[0..2]).unwrap_or("");
129        if !country::is_isin_prefix(prefix) {
130            return Err(ValidationError::InvalidCountryCode);
131        }
132        // Recompute the check digit from the 11-character body and compare.
133        let body = core::str::from_utf8(&bytes[0..11]).unwrap_or("");
134        let expected = checkdigit::isin_check_digit(body)?;
135        let supplied = char::from(bytes[11]);
136        if expected != supplied {
137            return Err(ValidationError::BadCheckDigit {
138                expected,
139                found: supplied,
140            });
141        }
142        Ok(Self { bytes })
143    }
144
145    /// Validates an ISIN without constructing one.
146    ///
147    /// Equivalent to `Isin::parse(s).map(|_| ())`; use it when only the
148    /// verdict is needed.
149    ///
150    /// # Errors
151    ///
152    /// Returns the same [`ValidationError`] variants as [`Isin::parse`].
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use regit_identifiers::Isin;
158    ///
159    /// assert!(Isin::validate("US0378331005").is_ok());
160    /// assert!(Isin::validate("US0378331004").is_err());
161    /// ```
162    pub fn validate(s: &str) -> Result<(), ValidationError> {
163        Self::parse(s).map(|_| ())
164    }
165
166    /// Wraps 12 raw bytes as an `Isin` without any validation.
167    ///
168    /// The caller asserts that `bytes` holds the 12 ASCII characters of a
169    /// valid ISIN. This exists for reconstructing an `Isin` from bytes that
170    /// were validated earlier; prefer [`Isin::parse`] for any untrusted
171    /// input.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use regit_identifiers::Isin;
177    ///
178    /// let isin = Isin::from_bytes_unchecked(*b"US0378331005");
179    /// assert_eq!(isin.as_str(), "US0378331005");
180    /// ```
181    #[must_use]
182    pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
183        Self { bytes }
184    }
185
186    /// Returns the ISIN as a string slice.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use regit_identifiers::Isin;
192    ///
193    /// assert_eq!(Isin::parse("US0378331005").unwrap().as_str(), "US0378331005");
194    /// ```
195    #[must_use]
196    #[inline]
197    pub fn as_str(&self) -> &str {
198        core::str::from_utf8(&self.bytes).unwrap_or("")
199    }
200
201    /// Returns the ISIN as its 12 raw ASCII bytes.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use regit_identifiers::Isin;
207    ///
208    /// assert_eq!(Isin::parse("US0378331005").unwrap().as_bytes(), b"US0378331005");
209    /// ```
210    #[must_use]
211    #[inline]
212    pub fn as_bytes(&self) -> &[u8] {
213        &self.bytes
214    }
215
216    /// Returns the two-character country prefix, characters 1–2.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use regit_identifiers::Isin;
222    ///
223    /// assert_eq!(Isin::parse("US0378331005").unwrap().country_code(), "US");
224    /// ```
225    #[must_use]
226    #[inline]
227    pub fn country_code(&self) -> &str {
228        core::str::from_utf8(&self.bytes[0..2]).unwrap_or("")
229    }
230
231    /// Returns the nine-character NSIN, characters 3–11.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use regit_identifiers::Isin;
237    ///
238    /// assert_eq!(Isin::parse("US0378331005").unwrap().nsin(), "037833100");
239    /// ```
240    #[must_use]
241    #[inline]
242    pub fn nsin(&self) -> &str {
243        core::str::from_utf8(&self.bytes[2..11]).unwrap_or("")
244    }
245
246    /// Returns the check digit, character 12.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use regit_identifiers::Isin;
252    ///
253    /// assert_eq!(Isin::parse("US0378331005").unwrap().check_digit(), '5');
254    /// ```
255    #[must_use]
256    #[inline]
257    pub fn check_digit(&self) -> char {
258        char::from(self.bytes[11])
259    }
260}
261
262impl core::fmt::Display for Isin {
263    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
264        f.write_str(self.as_str())
265    }
266}
267
268impl core::str::FromStr for Isin {
269    type Err = ValidationError;
270
271    fn from_str(s: &str) -> Result<Self, Self::Err> {
272        Self::parse(s)
273    }
274}
275
276impl AsRef<str> for Isin {
277    fn as_ref(&self) -> &str {
278        self.as_str()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::test_support::display;
286    use core::str::FromStr;
287
288    /// Real, well-known ISINs used as regression anchors.
289    const GOLDEN: &[&str] = &[
290        "US0378331005", // Apple Inc.
291        "US5949181045", // Microsoft Corp.
292        "GB0002634946", // BAE Systems plc
293        "DE000BAY0017", // Bayer AG
294        "FR0000131104", // BNP Paribas
295        "NL0011794037", // ABN AMRO
296    ];
297
298    #[test]
299    fn parses_golden_isins() {
300        for &s in GOLDEN {
301            let isin = Isin::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
302            assert_eq!(isin.as_str(), s);
303        }
304    }
305
306    #[test]
307    fn segment_accessors() {
308        let isin = Isin::parse("US0378331005").unwrap();
309        assert_eq!(isin.country_code(), "US");
310        assert_eq!(isin.nsin(), "037833100");
311        assert_eq!(isin.check_digit(), '5');
312        assert_eq!(isin.as_bytes(), b"US0378331005");
313        assert_eq!(Isin::LENGTH, 12);
314    }
315
316    #[test]
317    fn accepts_substitute_prefix() {
318        // XS is an ISIN substitute prefix, not an ISO country code.
319        let isin = Isin::parse("XS0000000009").unwrap();
320        assert_eq!(isin.country_code(), "XS");
321    }
322
323    #[test]
324    fn rejects_bad_check_digit() {
325        assert_eq!(
326            Isin::parse("US0378331004"),
327            Err(ValidationError::BadCheckDigit {
328                expected: '5',
329                found: '4',
330            })
331        );
332    }
333
334    #[test]
335    fn rejects_wrong_length() {
336        assert_eq!(
337            Isin::parse("US037833100"),
338            Err(ValidationError::WrongLength {
339                expected: 12,
340                found: 11,
341            })
342        );
343        assert_eq!(
344            Isin::parse(""),
345            Err(ValidationError::WrongLength {
346                expected: 12,
347                found: 0,
348            })
349        );
350    }
351
352    #[test]
353    fn rejects_lower_case() {
354        assert!(matches!(
355            Isin::parse("us0378331005"),
356            Err(ValidationError::InvalidCharacter { position: 1, .. })
357        ));
358    }
359
360    #[test]
361    fn rejects_non_digit_check_position() {
362        // Character 12 must be a digit.
363        assert!(matches!(
364            Isin::parse("US037833100X"),
365            Err(ValidationError::InvalidCharacter { position: 12, .. })
366        ));
367    }
368
369    #[test]
370    fn rejects_unknown_country_code() {
371        assert_eq!(
372            Isin::parse("ZZ0378331005"),
373            Err(ValidationError::InvalidCountryCode)
374        );
375    }
376
377    #[test]
378    fn rejects_non_ascii_without_panic() {
379        // A multi-byte character must be rejected cleanly.
380        assert!(Isin::parse("US037833100é").is_err());
381        assert!(Isin::parse("ÉS0378331005").is_err());
382    }
383
384    #[test]
385    fn round_trips_through_str() {
386        for &s in GOLDEN {
387            assert_eq!(Isin::parse(s).unwrap().as_str(), s);
388        }
389    }
390
391    #[test]
392    fn from_str_matches_parse() {
393        assert_eq!(Isin::from_str("US0378331005"), Isin::parse("US0378331005"));
394        assert!(Isin::from_str("nonsense").is_err());
395    }
396
397    #[test]
398    fn display_renders_identifier() {
399        let isin = Isin::parse("US0378331005").unwrap();
400        assert_eq!(display(isin).as_str(), "US0378331005");
401    }
402
403    #[test]
404    fn as_ref_str() {
405        let isin = Isin::parse("US0378331005").unwrap();
406        let s: &str = isin.as_ref();
407        assert_eq!(s, "US0378331005");
408    }
409
410    #[test]
411    fn from_bytes_unchecked_round_trip() {
412        let isin = Isin::from_bytes_unchecked(*b"US0378331005");
413        assert_eq!(isin, Isin::parse("US0378331005").unwrap());
414    }
415
416    #[test]
417    fn is_copy_and_eq_and_hashable() {
418        let a = Isin::parse("US0378331005").unwrap();
419        let b = a; // Copy
420        assert_eq!(a, b);
421        assert_ne!(a, Isin::parse("US5949181045").unwrap());
422        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
423        let keys = [a, b];
424        assert_eq!(keys[0], keys[1]);
425    }
426}