Skip to main content

regit_identifiers/
cusip.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! CUSIP — Committee on Uniform Securities Identification Procedures (ANSI X9.6).
5//!
6//! A CUSIP is the national securities identifier of the United States and
7//! Canada. It is exactly 9 characters in three segments:
8//!
9//! ```text
10//!   0 3 7 8 3 3 1 0 0
11//!   └────┬────┘ └┬┘ │
12//!        │       │   └ check digit  [8]      one digit [0-9]
13//!        │       └───── issue        [6..8]   two characters [A-Z0-9*@#]
14//!        └───────────── issuer       [0..6]   six characters [A-Z0-9*@#]
15//! ```
16//!
17//! - The **issuer** segment identifies the issuing entity; the **issue**
18//!   segment identifies a specific security of that issuer. Both draw from the
19//!   body alphabet `[A-Z0-9*@#]` — the digits, the upper-case letters, and the
20//!   three special characters `*`, `@`, `#`.
21//! - The **check digit** is the ANSI X9.6 "modulus 10 double add double" of
22//!   the eight-character body — see [`crate::checkdigit::cusip_check_digit`].
23//!
24//! A **CINS** (CUSIP International Numbering System) number is structurally a
25//! CUSIP, computed with the identical check-digit algorithm; it is
26//! distinguished only by its first character being a letter, where a domestic
27//! CUSIP starts with a digit. [`Cusip::is_cins`] reports this, and
28//! [`Cusip::cins_region`] maps the leading letter to its issuing region.
29//!
30//! [`Cusip::parse`] enforces every rule: exact length, the body character
31//! set, a digit in the check position, and a check digit that is recomputed
32//! and verified — never trusted.
33//!
34//! # References
35//!
36//! - ANSI X9.6, *Financial Services — CUSIP Numbering System*, CUSIP Global
37//!   Services.
38
39use crate::checkdigit;
40use crate::errors::ValidationError;
41
42/// A validated CUSIP (or CINS) number (ANSI X9.6).
43///
44/// A `Cusip` can only be created by [`Cusip::parse`] (or the explicitly
45/// unchecked [`Cusip::from_bytes_unchecked`]), so a value of this type is a
46/// proof that the 9 characters form a structurally valid CUSIP with a correct
47/// check digit. It stores the identifier inline as `[u8; 9]`, is `Copy`, and
48/// allocates nothing.
49///
50/// # Examples
51///
52/// ```
53/// use regit_identifiers::Cusip;
54///
55/// let cusip = Cusip::parse("037833100").unwrap();
56/// assert_eq!(cusip.issuer(), "037833");
57/// assert_eq!(cusip.issue(), "10");
58/// assert_eq!(cusip.check_digit(), '0');
59/// assert_eq!(cusip.as_str(), "037833100");
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct Cusip {
63    /// The 9 validated ASCII bytes of the identifier.
64    bytes: [u8; Self::LENGTH],
65}
66
67impl Cusip {
68    /// The number of characters in a CUSIP.
69    pub const LENGTH: usize = 9;
70
71    /// Parses and fully validates a CUSIP.
72    ///
73    /// Validation is strict and, in order: the input must be exactly 9
74    /// characters; characters 1–8 must each be drawn from the body alphabet
75    /// `[A-Z0-9*@#]` and character 9 must be an ASCII digit; and the check
76    /// digit must equal the value recomputed from the eight-character body.
77    ///
78    /// # Errors
79    ///
80    /// - [`ValidationError::WrongLength`] if the input is not 9 characters.
81    /// - [`ValidationError::InvalidCharacter`] if a character falls outside
82    ///   the set its position allows (this also rejects lower-case input and
83    ///   any non-ASCII character).
84    /// - [`ValidationError::BadCheckDigit`] if the supplied check digit does
85    ///   not match the recomputed one.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use regit_identifiers::Cusip;
91    /// use regit_identifiers::errors::ValidationError;
92    ///
93    /// assert!(Cusip::parse("037833100").is_ok());
94    ///
95    /// // A single wrong digit is caught, not silently accepted.
96    /// assert_eq!(
97    ///     Cusip::parse("037833101"),
98    ///     Err(ValidationError::BadCheckDigit { expected: '0', found: '1' }),
99    /// );
100    /// ```
101    pub fn parse(s: &str) -> Result<Self, ValidationError> {
102        // A CUSIP is exactly 9 characters.
103        let found = s.chars().count();
104        if found != Self::LENGTH {
105            return Err(ValidationError::WrongLength {
106                expected: Self::LENGTH,
107                found,
108            });
109        }
110        // Per-position character set: [0..8] are [A-Z0-9*@#], [8] is a digit.
111        // A non-ASCII character fails both predicates and is rejected here.
112        for (i, ch) in s.chars().enumerate() {
113            let legal = if i == Self::LENGTH - 1 {
114                ch.is_ascii_digit()
115            } else {
116                ch.is_ascii_digit() || ch.is_ascii_uppercase() || matches!(ch, '*' | '@' | '#')
117            };
118            if !legal {
119                return Err(ValidationError::InvalidCharacter {
120                    position: i + 1,
121                    found: ch,
122                });
123            }
124        }
125        // Every character is ASCII, so the string is exactly 9 ASCII bytes.
126        let mut bytes = [0u8; Self::LENGTH];
127        bytes.copy_from_slice(s.as_bytes());
128
129        // Recompute the check digit from the 8-character body and compare.
130        let body = core::str::from_utf8(&bytes[0..8]).unwrap_or("");
131        let expected = checkdigit::cusip_check_digit(body)?;
132        let supplied = char::from(bytes[8]);
133        if expected != supplied {
134            return Err(ValidationError::BadCheckDigit {
135                expected,
136                found: supplied,
137            });
138        }
139        Ok(Self { bytes })
140    }
141
142    /// Validates a CUSIP without constructing one.
143    ///
144    /// Equivalent to `Cusip::parse(s).map(|_| ())`; use it when only the
145    /// verdict is needed.
146    ///
147    /// # Errors
148    ///
149    /// Returns the same [`ValidationError`] variants as [`Cusip::parse`].
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use regit_identifiers::Cusip;
155    ///
156    /// assert!(Cusip::validate("037833100").is_ok());
157    /// assert!(Cusip::validate("037833101").is_err());
158    /// ```
159    pub fn validate(s: &str) -> Result<(), ValidationError> {
160        Self::parse(s).map(|_| ())
161    }
162
163    /// Wraps 9 raw bytes as a `Cusip` without any validation.
164    ///
165    /// The caller asserts that `bytes` holds the 9 ASCII characters of a valid
166    /// CUSIP. This exists for reconstructing a `Cusip` from bytes that were
167    /// validated earlier; prefer [`Cusip::parse`] for any untrusted input.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use regit_identifiers::Cusip;
173    ///
174    /// let cusip = Cusip::from_bytes_unchecked(*b"037833100");
175    /// assert_eq!(cusip.as_str(), "037833100");
176    /// ```
177    #[must_use]
178    pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
179        Self { bytes }
180    }
181
182    /// Returns the CUSIP as a string slice.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use regit_identifiers::Cusip;
188    ///
189    /// assert_eq!(Cusip::parse("037833100").unwrap().as_str(), "037833100");
190    /// ```
191    #[must_use]
192    #[inline]
193    pub fn as_str(&self) -> &str {
194        core::str::from_utf8(&self.bytes).unwrap_or("")
195    }
196
197    /// Returns the CUSIP as its 9 raw ASCII bytes.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use regit_identifiers::Cusip;
203    ///
204    /// assert_eq!(Cusip::parse("037833100").unwrap().as_bytes(), b"037833100");
205    /// ```
206    #[must_use]
207    #[inline]
208    pub fn as_bytes(&self) -> &[u8] {
209        &self.bytes
210    }
211
212    /// Returns the six-character issuer segment, characters 1–6.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use regit_identifiers::Cusip;
218    ///
219    /// assert_eq!(Cusip::parse("037833100").unwrap().issuer(), "037833");
220    /// ```
221    #[must_use]
222    #[inline]
223    pub fn issuer(&self) -> &str {
224        core::str::from_utf8(&self.bytes[0..6]).unwrap_or("")
225    }
226
227    /// Returns the two-character issue segment, characters 7–8.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use regit_identifiers::Cusip;
233    ///
234    /// assert_eq!(Cusip::parse("037833100").unwrap().issue(), "10");
235    /// ```
236    #[must_use]
237    #[inline]
238    pub fn issue(&self) -> &str {
239        core::str::from_utf8(&self.bytes[6..8]).unwrap_or("")
240    }
241
242    /// Returns the check digit, character 9.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use regit_identifiers::Cusip;
248    ///
249    /// assert_eq!(Cusip::parse("037833100").unwrap().check_digit(), '0');
250    /// ```
251    #[must_use]
252    #[inline]
253    pub fn check_digit(&self) -> char {
254        char::from(self.bytes[8])
255    }
256
257    /// Returns `true` if this identifier is a CINS number.
258    ///
259    /// A CINS (CUSIP International Numbering System) number is structurally a
260    /// CUSIP — same length, same alphabet, same check-digit algorithm — and is
261    /// distinguished solely by its first character being a letter, where a
262    /// domestic CUSIP always starts with a digit.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use regit_identifiers::Cusip;
268    ///
269    /// // A domestic CUSIP starts with a digit.
270    /// assert!(!Cusip::parse("037833100").unwrap().is_cins());
271    /// ```
272    #[must_use]
273    #[inline]
274    pub fn is_cins(&self) -> bool {
275        self.bytes[0].is_ascii_uppercase()
276    }
277
278    /// Returns `true` if this is a domestic (US/Canada) CUSIP — the
279    /// complement of [`Cusip::is_cins`].
280    ///
281    /// The discrimination rule is the leading character: a domestic CUSIP
282    /// starts with a digit, a CINS with a letter.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use regit_identifiers::Cusip;
288    ///
289    /// assert!(Cusip::parse("037833100").unwrap().is_domestic());
290    /// ```
291    #[must_use]
292    #[inline]
293    pub fn is_domestic(&self) -> bool {
294        !self.is_cins()
295    }
296
297    /// Returns the CINS issuing region of this identifier, if it is a CINS.
298    ///
299    /// The leading letter of a CINS number designates its issuing region per
300    /// the CINS table; for a domestic CUSIP (which starts with a digit) this
301    /// returns `None`. A leading letter outside the assigned table likewise
302    /// returns `None`.
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// use regit_identifiers::Cusip;
308    ///
309    /// // A domestic CUSIP has no CINS region.
310    /// assert_eq!(Cusip::parse("037833100").unwrap().cins_region(), None);
311    /// ```
312    #[must_use]
313    pub fn cins_region(&self) -> Option<&'static str> {
314        if !self.is_cins() {
315            return None;
316        }
317        match self.bytes[0] {
318            b'A' => Some("Austria"),
319            b'B' => Some("Belgium"),
320            b'C' => Some("Canada"),
321            b'D' => Some("Germany"),
322            b'E' => Some("Spain"),
323            b'F' => Some("France"),
324            b'G' => Some("United Kingdom"),
325            b'H' => Some("Switzerland"),
326            b'J' => Some("Japan"),
327            b'K' => Some("Denmark"),
328            b'L' => Some("Luxembourg"),
329            b'M' => Some("Middle East"),
330            b'N' => Some("Netherlands"),
331            b'P' => Some("South America"),
332            b'Q' => Some("Australia"),
333            b'R' => Some("Norway"),
334            b'S' => Some("South Africa"),
335            b'T' => Some("Italy"),
336            b'U' => Some("United States"),
337            b'V' => Some("Africa-Other"),
338            b'W' => Some("Sweden"),
339            b'X' => Some("Europe-Other"),
340            b'Y' => Some("Asia"),
341            _ => None,
342        }
343    }
344}
345
346impl core::fmt::Display for Cusip {
347    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
348        f.write_str(self.as_str())
349    }
350}
351
352impl core::str::FromStr for Cusip {
353    type Err = ValidationError;
354
355    fn from_str(s: &str) -> Result<Self, Self::Err> {
356        Self::parse(s)
357    }
358}
359
360impl AsRef<str> for Cusip {
361    fn as_ref(&self) -> &str {
362        self.as_str()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::test_support::display;
370    use core::str::FromStr;
371
372    /// Real, well-known CUSIPs used as regression anchors.
373    const GOLDEN: &[&str] = &[
374        "037833100", // Apple Inc.
375        "594918104", // Microsoft Corp.
376        "38259P508", // Alphabet Inc.
377    ];
378
379    #[test]
380    fn parses_golden_cusips() {
381        for &s in GOLDEN {
382            let cusip = Cusip::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
383            assert_eq!(cusip.as_str(), s);
384        }
385    }
386
387    #[test]
388    fn segment_accessors() {
389        let cusip = Cusip::parse("037833100").unwrap();
390        assert_eq!(cusip.issuer(), "037833");
391        assert_eq!(cusip.issue(), "10");
392        assert_eq!(cusip.check_digit(), '0');
393        assert_eq!(cusip.as_bytes(), b"037833100");
394        assert_eq!(Cusip::LENGTH, 9);
395    }
396
397    #[test]
398    fn rejects_bad_check_digit() {
399        assert_eq!(
400            Cusip::parse("037833101"),
401            Err(ValidationError::BadCheckDigit {
402                expected: '0',
403                found: '1',
404            })
405        );
406    }
407
408    #[test]
409    fn rejects_wrong_length() {
410        assert_eq!(
411            Cusip::parse("03783310"),
412            Err(ValidationError::WrongLength {
413                expected: 9,
414                found: 8,
415            })
416        );
417        assert_eq!(
418            Cusip::parse(""),
419            Err(ValidationError::WrongLength {
420                expected: 9,
421                found: 0,
422            })
423        );
424    }
425
426    #[test]
427    fn rejects_lower_case() {
428        assert!(matches!(
429            Cusip::parse("a37833100"),
430            Err(ValidationError::InvalidCharacter { position: 1, .. })
431        ));
432    }
433
434    #[test]
435    fn rejects_non_digit_check_position() {
436        // Character 9 must be a digit.
437        assert!(matches!(
438            Cusip::parse("03783310X"),
439            Err(ValidationError::InvalidCharacter { position: 9, .. })
440        ));
441    }
442
443    #[test]
444    fn rejects_illegal_body_character() {
445        // A slash is outside the body alphabet [A-Z0-9*@#].
446        assert!(matches!(
447            Cusip::parse("0378/3100"),
448            Err(ValidationError::InvalidCharacter { position: 5, .. })
449        ));
450    }
451
452    #[test]
453    fn accepts_special_body_characters() {
454        // The body alphabet includes *, @, and #; a valid check digit follows.
455        let body = "12345*@#";
456        let check = checkdigit::cusip_check_digit(body).unwrap();
457        let mut raw = [0u8; 9];
458        raw[0..8].copy_from_slice(body.as_bytes());
459        raw[8] = check as u8;
460        let s = core::str::from_utf8(&raw).unwrap();
461        let cusip = Cusip::parse(s).unwrap();
462        assert_eq!(cusip.issuer(), "12345*");
463        assert_eq!(cusip.issue(), "@#");
464    }
465
466    #[test]
467    fn rejects_non_ascii_without_panic() {
468        // A multi-byte character must be rejected cleanly.
469        assert!(Cusip::parse("03783310é").is_err());
470        assert!(Cusip::parse("é37833100").is_err());
471    }
472
473    #[test]
474    fn is_cins_detects_leading_letter() {
475        // A domestic CUSIP starts with a digit and is not a CINS.
476        assert!(!Cusip::parse("037833100").unwrap().is_cins());
477        // A CINS starts with a letter — build a valid one over a lettered body.
478        let body = "U3783310";
479        let check = checkdigit::cusip_check_digit(body).unwrap();
480        let mut raw = [0u8; 9];
481        raw[0..8].copy_from_slice(body.as_bytes());
482        raw[8] = check as u8;
483        let s = core::str::from_utf8(&raw).unwrap();
484        assert!(Cusip::parse(s).unwrap().is_cins());
485    }
486
487    #[test]
488    fn is_domestic_complements_is_cins() {
489        // Every domestic CUSIP starts with a digit.
490        for s in ["037833100", "594918104", "38259P508"] {
491            let c = Cusip::parse(s).unwrap();
492            assert!(c.is_domestic());
493            assert!(!c.is_cins());
494        }
495        // A CINS is not domestic — assemble one and check the inverse.
496        let body = "U3783310";
497        let check = checkdigit::cusip_check_digit(body).unwrap();
498        let mut raw = [0u8; 9];
499        raw[0..8].copy_from_slice(body.as_bytes());
500        raw[8] = check as u8;
501        let cins = Cusip::parse(core::str::from_utf8(&raw).unwrap()).unwrap();
502        assert!(!cins.is_domestic());
503        assert!(cins.is_cins());
504    }
505
506    #[test]
507    fn cins_region_maps_leading_letter() {
508        // A domestic CUSIP has no CINS region.
509        assert_eq!(Cusip::parse("037833100").unwrap().cins_region(), None);
510        // 'U' designates the United States in the CINS table.
511        let body = "U3783310";
512        let check = checkdigit::cusip_check_digit(body).unwrap();
513        let mut raw = [0u8; 9];
514        raw[0..8].copy_from_slice(body.as_bytes());
515        raw[8] = check as u8;
516        let s = core::str::from_utf8(&raw).unwrap();
517        assert_eq!(
518            Cusip::parse(s).unwrap().cins_region(),
519            Some("United States")
520        );
521    }
522
523    #[test]
524    fn cins_region_covers_every_assigned_letter() {
525        // Each of the 23 assigned CINS letters maps to a region; I, O, and Z
526        // are unassigned. Verify every assigned letter explicitly.
527        let assigned = [
528            (b'A', "Austria"),
529            (b'B', "Belgium"),
530            (b'C', "Canada"),
531            (b'D', "Germany"),
532            (b'E', "Spain"),
533            (b'F', "France"),
534            (b'G', "United Kingdom"),
535            (b'H', "Switzerland"),
536            (b'J', "Japan"),
537            (b'K', "Denmark"),
538            (b'L', "Luxembourg"),
539            (b'M', "Middle East"),
540            (b'N', "Netherlands"),
541            (b'P', "South America"),
542            (b'Q', "Australia"),
543            (b'R', "Norway"),
544            (b'S', "South Africa"),
545            (b'T', "Italy"),
546            (b'U', "United States"),
547            (b'V', "Africa-Other"),
548            (b'W', "Sweden"),
549            (b'X', "Europe-Other"),
550            (b'Y', "Asia"),
551        ];
552        for (letter, region) in assigned {
553            let cusip = Cusip::from_bytes_unchecked([
554                letter, b'1', b'1', b'1', b'1', b'1', b'1', b'1', b'1',
555            ]);
556            assert!(cusip.is_cins());
557            assert_eq!(cusip.cins_region(), Some(region));
558        }
559    }
560
561    #[test]
562    fn cins_region_none_for_unassigned_letter() {
563        // 'I', 'O', and 'Z' are not assigned regions in the CINS table.
564        for letter in [b'I', b'O', b'Z'] {
565            let cusip = Cusip::from_bytes_unchecked([
566                letter, b'1', b'1', b'1', b'1', b'1', b'1', b'1', b'1',
567            ]);
568            assert!(cusip.is_cins());
569            assert_eq!(cusip.cins_region(), None);
570        }
571    }
572
573    #[test]
574    fn round_trips_through_str() {
575        for &s in GOLDEN {
576            assert_eq!(Cusip::parse(s).unwrap().as_str(), s);
577        }
578    }
579
580    #[test]
581    fn from_str_matches_parse() {
582        assert_eq!(Cusip::from_str("037833100"), Cusip::parse("037833100"));
583        assert!(Cusip::from_str("nonsense").is_err());
584    }
585
586    #[test]
587    fn display_renders_identifier() {
588        let cusip = Cusip::parse("037833100").unwrap();
589        assert_eq!(display(cusip).as_str(), "037833100");
590    }
591
592    #[test]
593    fn as_ref_str() {
594        let cusip = Cusip::parse("037833100").unwrap();
595        let s: &str = cusip.as_ref();
596        assert_eq!(s, "037833100");
597    }
598
599    #[test]
600    fn validate_agrees_with_parse() {
601        assert!(Cusip::validate("037833100").is_ok());
602        assert!(Cusip::validate("037833101").is_err());
603    }
604
605    #[test]
606    fn from_bytes_unchecked_round_trip() {
607        let cusip = Cusip::from_bytes_unchecked(*b"037833100");
608        assert_eq!(cusip, Cusip::parse("037833100").unwrap());
609    }
610
611    #[test]
612    fn is_copy_and_eq_and_hashable() {
613        let a = Cusip::parse("037833100").unwrap();
614        let b = a; // Copy
615        assert_eq!(a, b);
616        assert_ne!(a, Cusip::parse("594918104").unwrap());
617        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
618        let keys = [a, b];
619        assert_eq!(keys[0], keys[1]);
620    }
621}