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}