Skip to main content

ftracker_identifiers/
isin.rs

1//! ISIN (International Securities Identification Number) — the ISO 6166 identifier for a fungible
2//! financial security.
3//!
4//! This module provides the validated Rust representation ([`Isin`]) and the parsing, validation,
5//! and error types that surround it. It accepts the canonical 12-character form (optionally
6//! surrounded by whitespace, in any ASCII case), normalizes it, and guarantees that any constructed
7//! [`Isin`] satisfies the structural rules and the Luhn check digit described below. There is no
8//! partially-validated state: if you hold an [`Isin`], it is valid.
9//!
10//! # What this type represents
11//!
12//! An ISIN has 12 characters, split into three segments:
13//!
14//! | Positions | Length | Segment       | Meaning                                                           |
15//! |-----------|--------|---------------|-------------------------------------------------------------------|
16//! | 1–2       | 2      | Country code  | ISO 3166-1 alpha-2 prefix of the issuing national numbering agency |
17//! | 3–11      | 9      | NSIN          | National Securities Identifying Number, alphanumeric              |
18//! | 12        | 1      | Check digit   | Luhn (modulus 10) digit computed over the first 11 characters     |
19//!
20//! ```text
21//! ┌──────────────────────────────────────────────────────┐
22//! │  CC    │           NSIN (9 chars)          │ Check (1) │
23//! │  A  A  │       N  N  N  N  N  N  N  N  N    │     D     │
24//! └──────────────────────────────────────────────────────┘
25//! ```
26//!
27//! [`Isin`] stores those 12 characters as normalized uppercase ASCII and exposes borrowed accessors
28//! for the country code ([`Isin::country_code`]), the NSIN ([`Isin::nsin`]), the check digit
29//! ([`Isin::check_digit`]), and the whole value ([`Isin::as_str`]).
30//!
31//! # Validation rules
32//!
33//! Every fallible constructor runs the same rules, in order, and each maps to one [`IsinError`]
34//! variant:
35//!
36//! 1. **Length** — after surrounding whitespace is trimmed, the input must contain exactly 12
37//!    characters ([`IsinError::InvalidLength`]). [`Isin::parse`] rejects empty input up front
38//!    ([`IsinError::Empty`]).
39//! 2. **Character class** — positions 1–2 accept an uppercase letter, positions 3–11 accept a digit
40//!    or an uppercase letter, and position 12 accepts only a digit ([`IsinError::InvalidCharacter`]).
41//! 3. **Check digit** — position 12 must match the ISO 6166 Luhn digit computed from the first 11
42//!    characters ([`IsinError::InvalidCheckDigit`]).
43//!
44//! The country code is validated *structurally* (two uppercase letters); this crate deliberately
45//! does not check it against the live ISO 3166-1 country registry, which changes over time and is
46//! out of scope for a checksum-oriented value type.
47//!
48//! # Design notes
49//!
50//! - **No invalid state is representable.** [`Isin`]'s only field is private; the only ways to
51//!   obtain one — [`Isin::parse`], [`Isin::new`], [`Isin::from_bytes`], [`FromStr`], and
52//!   [`TryFrom<&str>`] — all run full validation. There is no unchecked constructor.
53//! - **Zero allocation, `Copy`, `no_std`-friendly.** [`Isin`] is a 12-byte value type wrapping
54//!   `[u8; 12]`. Parsing, validating, and every accessor operate on the stack.
55//! - **Ordering and hashing are byte-wise.** [`Isin`] derives [`Ord`] and [`Hash`] directly over
56//!   its ASCII bytes, which matches [`str`] ordering on [`Isin::as_str`]. This is lexicographic
57//!   string order, not any notion of issuance order.
58//! - **Safe to use as a map/set key.** [`Isin`] implements [`Eq`] and [`Hash`] consistently with
59//!   [`PartialEq`], so it works as a `HashMap`/`HashSet` or `BTreeMap`/`BTreeSet` key out of the box.
60//!
61//! # Feature flags
62//!
63//! This module's optional integrations are off by default and purely additive — enabling one never
64//! changes the behavior of [`Isin::parse`] or the validation rules above:
65//!
66//! - **`serde`** — (de)serializes [`Isin`] as its 12-character string (e.g. `"US0378331005"`).
67//!   Deserialization re-runs full validation, so an untrusted payload can never produce an invalid
68//!   [`Isin`].
69//! - **`schemars`** — implements `JsonSchema` for [`Isin`], describing it as a pattern-constrained
70//!   string (`^[A-Z]{2}[A-Z0-9]{9}[0-9]$`). Implies `serde`.
71//! - **`arbitrary`** — implements `Arbitrary` for [`Isin`], generating structurally valid,
72//!   checksum-correct values for fuzz targets.
73//! - **`proptest`** — exposes reusable `proptest` strategies (`ftracker_identifiers::isin::proptest`,
74//!   when this feature is enabled) for generating checksum-valid [`Isin`] values.
75//!
76//! # Error handling
77//!
78//! Every fallible constructor returns [`IsinError`], which is `Clone + PartialEq + Eq` and
79//! implements [`core::error::Error`] and [`core::fmt::Display`], so it composes with `?` and with
80//! error-aggregation crates alike:
81//!
82//! ```
83//! use ftracker_identifiers::{Isin, IsinError};
84//!
85//! match Isin::parse("US0378331006") {
86//!     Ok(isin) => println!("valid: {isin}"),
87//!     Err(IsinError::InvalidCheckDigit { expected, found }) => {
88//!         println!("checksum mismatch: expected {expected}, found {found}");
89//!     }
90//!     Err(other) => println!("rejected: {other}"),
91//! }
92//! ```
93//!
94//! # Examples
95//!
96//! ```
97//! use ftracker_identifiers::Isin;
98//!
99//! let apple = Isin::parse("US0378331005").unwrap();
100//! assert_eq!(apple.country_code(), "US");
101//! assert_eq!(apple.nsin(), "037833100");
102//! assert_eq!(apple.check_digit(), 5);
103//! assert_eq!(apple.as_str(), "US0378331005");
104//! ```
105//!
106//! Sorting and deduplicating a batch of ISINs, e.g. after importing them from a spreadsheet:
107//!
108//! ```
109//! use ftracker_identifiers::Isin;
110//!
111//! let mut isins: Vec<Isin> = ["US0231351067", "US0378331005", "US0378331005"]
112//!     .into_iter()
113//!     .map(|s| Isin::parse(s).unwrap())
114//!     .collect();
115//! isins.sort();
116//! isins.dedup();
117//! assert_eq!(isins.len(), 2);
118//! ```
119
120mod error;
121mod fmt;
122mod parser;
123mod validation;
124
125#[cfg(feature = "serde")]
126mod serde;
127
128#[cfg(feature = "schemars")]
129mod schema;
130
131#[cfg(feature = "arbitrary")]
132mod arbitrary;
133
134#[cfg(any(test, feature = "proptest"))]
135pub mod proptest;
136
137#[cfg(test)]
138mod tests;
139
140pub use error::IsinError;
141
142use core::convert::TryFrom;
143use core::str::{FromStr, from_utf8_unchecked};
144
145/// A validated ISIN (International Securities Identification Number, ISO 6166).
146///
147/// `Isin` is a 12-byte, `Copy`, allocation-free value object. Once constructed, it is guaranteed to
148/// satisfy the structural rules and Luhn check digit required by ISO 6166 — there is no way to
149/// obtain an `Isin` that hasn't passed validation.
150///
151/// Internally, the identifier is stored as raw uppercase ASCII bytes (`'0'..='9'` or `'A'..='Z'`).
152///
153/// # Constructing an `Isin`
154///
155/// | Constructor                     | Accepts                                             |
156/// |----------------------------------|-----------------------------------------------------|
157/// | [`Isin::parse`] / [`Isin::new`]  | 12-character strings, any ASCII case, trimmed        |
158/// | [`Isin::from_bytes`]             | Exactly 12 pre-normalized uppercase ASCII bytes      |
159/// | [`FromStr`] / [`TryFrom<&str>`]  | Same as `parse`, for use in generic code            |
160///
161/// All of them run the same validation and return [`IsinError`] on failure. See the [module-level
162/// documentation](self) for the segment layout and design rationale.
163#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
164#[must_use = "a parsed Isin should be used; discarding it wastes the validation work"]
165pub struct Isin {
166    bytes: [u8; 12],
167}
168
169impl Isin {
170    /// Parses an ISIN from a string.
171    ///
172    /// The parser trims surrounding whitespace and folds ASCII letters to uppercase before
173    /// validation. This is the primary constructor; [`Isin::new`], [`FromStr`], and
174    /// [`TryFrom<&str>`] all delegate to it.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`IsinError`] if the input is empty, does not contain exactly 12 characters after
179    /// trimming, contains a character invalid for its position, or fails the Luhn check digit.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use ftracker_identifiers::Isin;
185    ///
186    /// assert!(Isin::parse("US0378331005").is_ok());
187    /// assert!(Isin::parse("us0378331005").is_ok()); // lowercase is folded automatically
188    /// assert!(Isin::parse(" US0378331005 ").is_ok()); // surrounding whitespace is trimmed
189    /// assert!(Isin::parse("US0378331006").is_err()); // wrong check digit
190    /// ```
191    pub fn parse(input: &str) -> Result<Self, IsinError> {
192        let candidate = parser::normalize(input)?;
193        Self::from_bytes(candidate)
194    }
195
196    /// Alias for [`Isin::parse`].
197    ///
198    /// # Errors
199    ///
200    /// See [`Isin::parse`].
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use ftracker_identifiers::Isin;
206    ///
207    /// assert_eq!(Isin::new("US0378331005"), Isin::parse("US0378331005"));
208    /// ```
209    #[inline]
210    pub fn new(input: &str) -> Result<Self, IsinError> {
211        Self::parse(input)
212    }
213
214    /// Constructs an `Isin` directly from 12 raw ASCII bytes.
215    ///
216    /// Each byte must already be uppercase and valid for its position (two letters, nine
217    /// alphanumerics, one digit). Use [`Isin::parse`] if the input might contain surrounding
218    /// whitespace or lowercase letters.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`IsinError`] under the same conditions as [`Isin::parse`], except that length is
223    /// guaranteed by the `[u8; 12]` type itself: [`IsinError::InvalidLength`] cannot occur here.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use ftracker_identifiers::Isin;
229    ///
230    /// let isin = Isin::from_bytes(*b"US0378331005").unwrap();
231    /// assert_eq!(isin.as_str(), "US0378331005");
232    ///
233    /// // A malformed checksum is rejected just like it would be through `parse`.
234    /// assert!(Isin::from_bytes(*b"US0378331006").is_err());
235    /// ```
236    pub fn from_bytes(bytes: [u8; 12]) -> Result<Self, IsinError> {
237        validation::validate(&bytes)?;
238        Ok(Isin { bytes })
239    }
240
241    /// Returns the 12 raw ASCII bytes backing this ISIN (for example, `b"US0378331005"`).
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use ftracker_identifiers::Isin;
247    ///
248    /// let isin = Isin::parse("US0378331005").unwrap();
249    /// assert_eq!(isin.as_bytes(), b"US0378331005");
250    /// ```
251    #[inline]
252    #[must_use]
253    pub fn as_bytes(&self) -> &[u8; 12] {
254        &self.bytes
255    }
256
257    /// Returns the full 12-character ISIN as a `&str`.
258    ///
259    /// This never allocates: the bytes are guaranteed to be valid ASCII by construction.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use ftracker_identifiers::Isin;
265    ///
266    /// let isin = Isin::parse("US0378331005").unwrap();
267    /// assert_eq!(isin.as_str(), "US0378331005");
268    /// ```
269    #[inline]
270    #[must_use]
271    pub fn as_str(&self) -> &str {
272        // SAFETY: `Isin::from_bytes` guarantees the bytes are ASCII letters and digits only.
273        unsafe { from_utf8_unchecked(&self.bytes) }
274    }
275
276    /// Returns the two-character ISO 3166-1 alpha-2 country code (positions 1–2).
277    ///
278    /// # Examples
279    ///
280    /// ```
281    /// use ftracker_identifiers::Isin;
282    ///
283    /// let isin = Isin::parse("US0378331005").unwrap();
284    /// assert_eq!(isin.country_code(), "US");
285    /// ```
286    #[inline]
287    #[must_use]
288    pub fn country_code(&self) -> &str {
289        &self.as_str()[0..2]
290    }
291
292    /// Returns the prefix (positions 1-2) as a validated [`CountryCode`](crate::CountryCode), or
293    /// `None` when it is not an officially assigned ISO 3166-1 alpha-2 code.
294    ///
295    /// An [`Isin`] only validates its prefix structurally (two uppercase letters), so it can carry
296    /// prefixes that ISO 6166 reserves but ISO 3166-1 does not assign. The most common are `XS`
297    /// (used by international clearing systems such as Euroclear and Clearstream), `EU` (European
298    /// Union supranational issues), and `QS`. For those, this returns `None` even though the
299    /// [`Isin`] itself is valid. Use [`Isin::country_code`] when you want the raw two letter prefix
300    /// regardless of assignment.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use ftracker_identifiers::{Isin, CountryCode};
306    ///
307    /// let apple = Isin::parse("US0378331005").unwrap();
308    /// assert_eq!(apple.country(), Some(CountryCode::parse("US").unwrap()));
309    /// ```
310    #[inline]
311    #[must_use]
312    pub fn country(&self) -> Option<crate::CountryCode> {
313        crate::CountryCode::from_bytes([self.bytes[0], self.bytes[1]]).ok()
314    }
315
316    /// Returns the nine-character National Securities Identifying Number (positions 3–11).
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use ftracker_identifiers::Isin;
322    ///
323    /// let isin = Isin::parse("US0378331005").unwrap();
324    /// assert_eq!(isin.nsin(), "037833100");
325    /// ```
326    #[inline]
327    #[must_use]
328    pub fn nsin(&self) -> &str {
329        &self.as_str()[2..11]
330    }
331
332    /// Returns the Luhn check digit (position 12) as a numeric value.
333    ///
334    /// For a valid `Isin`, this always equals [`Isin::computed_check_digit`].
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use ftracker_identifiers::Isin;
340    ///
341    /// let isin = Isin::parse("US0378331005").unwrap();
342    /// assert_eq!(isin.check_digit(), 5);
343    /// ```
344    #[inline]
345    #[must_use]
346    pub fn check_digit(&self) -> u8 {
347        self.bytes[11] - b'0'
348    }
349
350    /// Recomputes the check digit that the ISO 6166 Luhn algorithm produces from the first 11
351    /// characters of this value.
352    ///
353    /// For a valid `Isin` this always matches [`Isin::check_digit`]; the method exists so callers
354    /// can reproduce the algorithm's output without a separate crate.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use ftracker_identifiers::Isin;
360    ///
361    /// let isin = Isin::parse("US0378331005").unwrap();
362    /// assert_eq!(isin.computed_check_digit(), isin.check_digit());
363    /// ```
364    #[inline]
365    #[must_use]
366    pub fn computed_check_digit(&self) -> u8 {
367        validation::compute_check_digit(&self.bytes[..11])
368    }
369}
370
371impl FromStr for Isin {
372    type Err = IsinError;
373
374    /// Delegates to [`Isin::parse`], enabling `input.parse::<Isin>()` and use in generic code
375    /// bounded by [`FromStr`].
376    fn from_str(s: &str) -> Result<Self, Self::Err> {
377        Self::parse(s)
378    }
379}
380
381impl TryFrom<&str> for Isin {
382    type Error = IsinError;
383
384    /// Delegates to [`Isin::parse`], enabling `Isin::try_from(input)` and use in generic code
385    /// bounded by [`TryFrom<&str>`].
386    fn try_from(value: &str) -> Result<Self, Self::Error> {
387        Self::parse(value)
388    }
389}
390
391impl TryFrom<[u8; 12]> for Isin {
392    type Error = IsinError;
393
394    /// Delegates to [`Isin::from_bytes`]. The bytes must already be pre normalized uppercase ASCII.
395    fn try_from(value: [u8; 12]) -> Result<Self, Self::Error> {
396        Self::from_bytes(value)
397    }
398}
399
400impl TryFrom<&[u8]> for Isin {
401    type Error = IsinError;
402
403    /// Validates a byte slice as an ISIN. The slice must be exactly 12 pre normalized uppercase
404    /// ASCII bytes; any other length yields [`IsinError::InvalidLength`]. Once the length is
405    /// confirmed, this behaves like [`Isin::from_bytes`].
406    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
407        let bytes: [u8; 12] = value
408            .try_into()
409            .map_err(|_| IsinError::InvalidLength { found: value.len() })?;
410        Self::from_bytes(bytes)
411    }
412}
413
414impl PartialEq<str> for Isin {
415    /// Compares against a string slice by its canonical 12 character representation.
416    fn eq(&self, other: &str) -> bool {
417        self.as_str() == other
418    }
419}
420
421impl PartialEq<&str> for Isin {
422    /// Compares against a string slice by its canonical 12 character representation.
423    fn eq(&self, other: &&str) -> bool {
424        self.as_str() == *other
425    }
426}
427
428impl PartialEq<Isin> for str {
429    fn eq(&self, other: &Isin) -> bool {
430        self == other.as_str()
431    }
432}
433
434impl PartialEq<Isin> for &str {
435    fn eq(&self, other: &Isin) -> bool {
436        *self == other.as_str()
437    }
438}
439
440impl AsRef<[u8]> for Isin {
441    /// Equivalent to [`Isin::as_bytes`], borrowed as a slice.
442    fn as_ref(&self) -> &[u8] {
443        &self.bytes
444    }
445}
446
447impl AsRef<str> for Isin {
448    /// Equivalent to [`Isin::as_str`].
449    fn as_ref(&self) -> &str {
450        self.as_str()
451    }
452}