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}