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}