Skip to main content

regit_identifiers/
detect.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Auto-detection — recognising which kind of identifier a raw string is.
5//!
6//! Reference data rarely arrives labelled. A spreadsheet cell, a CSV column,
7//! or a free-text field holds *an* identifier, and the consuming system must
8//! first decide *which* one before it can route, settle, or report against
9//! it. [`SecurityId::detect`] makes that decision: it takes a raw string and
10//! returns the single identifier kind it is — or `None` when nothing fits.
11//!
12//! ```text
13//!   "5493001KJTIIGC8Y1R12"  ──▶  SecurityId::Lei   (20 chars, MOD 97-10 ok)
14//!   "US0378331005"          ──▶  SecurityId::Isin  (12 chars, Luhn ok)
15//!   "BBG000BLNNH6"          ──▶  SecurityId::Figi  (12 chars, [2]=='G', ok)
16//!   "037833100"             ──▶  SecurityId::Cusip ( 9 chars, X9.6 ok)
17//!   "0263494"               ──▶  SecurityId::Sedol ( 7 chars, weighted ok)
18//!   "DEUTDEFF"              ──▶  SecurityId::Bic   ( 8 chars, ISO 9362)
19//!   "garbage"               ──▶  None
20//! ```
21//!
22//! # How detection decides
23//!
24//! Detection is **checksum-strength first**: a passing check digit is
25//! high-confidence evidence, so kinds that carry one are tried before kinds
26//! that do not. The order is fixed — LEI, ISIN, FIGI, CUSIP, SEDOL, BIC, MIC.
27//! Each candidate is the strict `parse` of the corresponding identifier type,
28//! so a kind is only reported when the input is fully, structurally valid for
29//! it, check digit included.
30//!
31//! Two ambiguities are resolved by that order:
32//!
33//! - **ISIN vs FIGI** — both are 12 characters. ISIN is tried first; a string
34//!   that is a valid ISIN is reported as one. FIGI additionally requires
35//!   character 3 to be the literal `G` and forbids the seven ISIN-colliding
36//!   provider prefixes, so a genuine FIGI is never a valid ISIN and falls
37//!   through to the FIGI branch.
38//! - **CUSIP vs BIC** — an 8-character string could be either. CUSIP carries
39//!   a check digit and is tried first; only a string that is *not* a valid
40//!   CUSIP reaches the BIC branch.
41//!
42//! # What is *not* detected
43//!
44//! Three kinds are deliberately excluded from auto-detection because they are
45//! structural-only and would collide:
46//!
47//! - **CFI** — 6 upper-case letters; would shadow many other 6-letter inputs.
48//! - **WKN** — 6 alphanumeric characters; no check digit to disambiguate.
49//! - **VALOR** — 1 to 9 digits; a short run of digits is far too ambiguous.
50//!
51//! These have no check digit and overlap heavily with one another and with
52//! other kinds, so detection would only guess. Parse them explicitly with
53//! [`Cfi::parse`](crate::Cfi::parse), [`Wkn::parse`](crate::Wkn::parse), or
54//! [`Valor::parse`](crate::Valor::parse) when the kind is already known.
55//!
56//! # MIC and the registry feature
57//!
58//! A MIC has no check digit, so a structurally valid MIC alone is weak
59//! evidence. Detection therefore reports [`IdentifierKind::Mic`] only when the
60//! `mic-registry` feature is enabled *and* the string is a *registered* MIC —
61//! one present in the embedded ISO 10383 snapshot, via
62//! [`Mic::parse_registered`](crate::Mic::parse_registered). With the feature
63//! disabled, MIC is never auto-detected.
64//!
65//! # References
66//!
67//! - ISO 6166 (ISIN), ISO 17442 (LEI), ISO 9362 (BIC), ISO 10383 (MIC),
68//!   ANSI X9.6 (CUSIP), ANSI X9.145 (FIGI) — the standards whose grammars and
69//!   check digits this module relies on to tell the kinds apart.
70
71use crate::bic::Bic;
72use crate::cfi::Cfi;
73use crate::cusip::Cusip;
74use crate::figi::Figi;
75use crate::isin::Isin;
76use crate::lei::Lei;
77use crate::mic::Mic;
78use crate::sedol::Sedol;
79use crate::valor::Valor;
80use crate::wkn::Wkn;
81
82/// The kind of a securities identifier — its type tag, with no payload.
83///
84/// This is the discriminant of [`SecurityId`]: [`SecurityId::kind`] returns
85/// it, and it is the natural value to switch on, store, or compare when the
86/// identifier's *type* matters but its bytes do not.
87///
88/// # Examples
89///
90/// ```
91/// use regit_identifiers::detect::{IdentifierKind, SecurityId};
92///
93/// let id = SecurityId::detect("US0378331005").unwrap();
94/// assert_eq!(id.kind(), IdentifierKind::Isin);
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum IdentifierKind {
98    /// An International Securities Identification Number (ISO 6166).
99    Isin,
100    /// A CUSIP / CINS number (ANSI X9.6).
101    Cusip,
102    /// A Stock Exchange Daily Official List number (SEDOL).
103    Sedol,
104    /// A Legal Entity Identifier (ISO 17442).
105    Lei,
106    /// A Financial Instrument Global Identifier (ANSI X9.145).
107    Figi,
108    /// A Business Identifier Code (ISO 9362).
109    Bic,
110    /// A Market Identifier Code (ISO 10383).
111    Mic,
112    /// A Classification of Financial Instruments code (ISO 10962).
113    Cfi,
114    /// A Wertpapierkennnummer (German national number).
115    Wkn,
116    /// A Valorennummer (Swiss national number).
117    Valor,
118}
119
120/// Any one validated securities identifier, tagged by its kind.
121///
122/// A `SecurityId` is the result of [`SecurityId::detect`]: a value that has
123/// already been parsed and fully validated as exactly one of the identifier
124/// types this crate supports. Each variant wraps the corresponding validated
125/// identifier, so unwrapping a `SecurityId` yields a value whose invariants
126/// are already proven. It is `Copy` and allocates nothing.
127///
128/// # Examples
129///
130/// ```
131/// use regit_identifiers::detect::SecurityId;
132///
133/// let id = SecurityId::detect("DEUTDEFF").unwrap();
134/// match id {
135///     SecurityId::Bic(bic) => assert_eq!(bic.country_code(), "DE"),
136///     _ => panic!("DEUTDEFF is a BIC"),
137/// }
138/// ```
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum SecurityId {
141    /// A validated ISIN.
142    Isin(Isin),
143    /// A validated CUSIP / CINS number.
144    Cusip(Cusip),
145    /// A validated SEDOL.
146    Sedol(Sedol),
147    /// A validated LEI.
148    Lei(Lei),
149    /// A validated FIGI.
150    Figi(Figi),
151    /// A validated BIC.
152    Bic(Bic),
153    /// A validated MIC.
154    Mic(Mic),
155    /// A validated CFI code.
156    Cfi(Cfi),
157    /// A validated WKN.
158    Wkn(Wkn),
159    /// A validated VALOR.
160    Valor(Valor),
161}
162
163impl SecurityId {
164    /// Auto-detects which kind of identifier a raw string is.
165    ///
166    /// Candidate kinds are tried in a fixed, checksum-strength-first order —
167    /// LEI, ISIN, FIGI, CUSIP, SEDOL, BIC, then MIC — and the first whose
168    /// strict `parse` accepts the input wins. Because each candidate is a full
169    /// `parse`, a kind is reported only when the input is structurally valid
170    /// for it *and*, where the standard defines one, carries a correct check
171    /// digit.
172    ///
173    /// The 12-character ISIN-versus-FIGI ambiguity is resolved by trying ISIN
174    /// first; a genuine FIGI is never a valid ISIN (it requires character 3 to
175    /// be `G` and forbids the ISIN-colliding provider prefixes) and so falls
176    /// through to the FIGI branch.
177    ///
178    /// MIC is detected only when the `mic-registry` feature is enabled and the
179    /// string is a *registered* MIC — a structurally valid but unregistered
180    /// code such as `ZZZZ` is never reported.
181    ///
182    /// The structural-only kinds CFI, WKN, and VALOR are **not**
183    /// auto-detected: they carry no check digit and overlap too heavily to
184    /// distinguish. `detect` returns `None` when no checksum-bearing kind (or
185    /// registered MIC) fits — parse those kinds explicitly instead.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use regit_identifiers::detect::{IdentifierKind, SecurityId};
191    ///
192    /// // Each kind is recognised from a real identifier.
193    /// assert_eq!(
194    ///     SecurityId::detect("5493001KJTIIGC8Y1R12").unwrap().kind(),
195    ///     IdentifierKind::Lei,
196    /// );
197    /// assert_eq!(
198    ///     SecurityId::detect("BBG000BLNNH6").unwrap().kind(),
199    ///     IdentifierKind::Figi,
200    /// );
201    ///
202    /// // Garbage, and the structural-only kinds, return `None`.
203    /// assert!(SecurityId::detect("not-an-identifier").is_none());
204    /// ```
205    #[must_use]
206    pub fn detect(s: &str) -> Option<Self> {
207        // Checksum-strength first: kinds that carry a check digit are tried
208        // before those that do not, and each candidate is a strict `parse`.
209        if let Ok(lei) = Lei::parse(s) {
210            return Some(Self::Lei(lei));
211        }
212        // ISIN before FIGI — both are 12 characters, but a genuine FIGI is
213        // never a valid ISIN, so trying ISIN first cannot mislabel a FIGI.
214        if let Ok(isin) = Isin::parse(s) {
215            return Some(Self::Isin(isin));
216        }
217        if let Ok(figi) = Figi::parse(s) {
218            return Some(Self::Figi(figi));
219        }
220        // CUSIP before BIC — an 8-character string could be either, and CUSIP
221        // carries a check digit, so it is the higher-confidence candidate.
222        if let Ok(cusip) = Cusip::parse(s) {
223            return Some(Self::Cusip(cusip));
224        }
225        if let Ok(sedol) = Sedol::parse(s) {
226            return Some(Self::Sedol(sedol));
227        }
228        if let Ok(bic) = Bic::parse(s) {
229            return Some(Self::Bic(bic));
230        }
231        // A MIC has no check digit; report it only when it is a registered
232        // market in the embedded ISO 10383 snapshot. With the `mic-registry`
233        // feature disabled, MIC is never auto-detected.
234        #[cfg(feature = "mic-registry")]
235        if let Ok(mic) = Mic::parse_registered(s) {
236            return Some(Self::Mic(mic));
237        }
238        None
239    }
240
241    /// Returns the [`IdentifierKind`] tag of this identifier.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use regit_identifiers::detect::{IdentifierKind, SecurityId};
247    ///
248    /// let id = SecurityId::detect("037833100").unwrap();
249    /// assert_eq!(id.kind(), IdentifierKind::Cusip);
250    /// ```
251    #[must_use]
252    pub fn kind(&self) -> IdentifierKind {
253        match self {
254            Self::Isin(_) => IdentifierKind::Isin,
255            Self::Cusip(_) => IdentifierKind::Cusip,
256            Self::Sedol(_) => IdentifierKind::Sedol,
257            Self::Lei(_) => IdentifierKind::Lei,
258            Self::Figi(_) => IdentifierKind::Figi,
259            Self::Bic(_) => IdentifierKind::Bic,
260            Self::Mic(_) => IdentifierKind::Mic,
261            Self::Cfi(_) => IdentifierKind::Cfi,
262            Self::Wkn(_) => IdentifierKind::Wkn,
263            Self::Valor(_) => IdentifierKind::Valor,
264        }
265    }
266
267    /// Returns the wrapped identifier as a string slice.
268    ///
269    /// The returned `&str` is the canonical text of the underlying validated
270    /// identifier, exactly as its own `as_str` would render it.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use regit_identifiers::detect::SecurityId;
276    ///
277    /// let id = SecurityId::detect("US0378331005").unwrap();
278    /// assert_eq!(id.as_str(), "US0378331005");
279    /// ```
280    #[must_use]
281    pub fn as_str(&self) -> &str {
282        match self {
283            Self::Isin(v) => v.as_str(),
284            Self::Cusip(v) => v.as_str(),
285            Self::Sedol(v) => v.as_str(),
286            Self::Lei(v) => v.as_str(),
287            Self::Figi(v) => v.as_str(),
288            Self::Bic(v) => v.as_str(),
289            Self::Mic(v) => v.as_str(),
290            Self::Cfi(v) => v.as_str(),
291            Self::Wkn(v) => v.as_str(),
292            Self::Valor(v) => v.as_str(),
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::test_support::debug;
301
302    #[test]
303    fn detects_lei() {
304        let id = SecurityId::detect("5493001KJTIIGC8Y1R12").unwrap();
305        assert_eq!(id.kind(), IdentifierKind::Lei);
306        assert_eq!(id.as_str(), "5493001KJTIIGC8Y1R12");
307        assert!(matches!(id, SecurityId::Lei(_)));
308    }
309
310    #[test]
311    fn detects_isin() {
312        let id = SecurityId::detect("US0378331005").unwrap();
313        assert_eq!(id.kind(), IdentifierKind::Isin);
314        assert_eq!(id.as_str(), "US0378331005");
315        assert!(matches!(id, SecurityId::Isin(_)));
316    }
317
318    #[test]
319    fn detects_figi() {
320        let id = SecurityId::detect("BBG000BLNNH6").unwrap();
321        assert_eq!(id.kind(), IdentifierKind::Figi);
322        assert_eq!(id.as_str(), "BBG000BLNNH6");
323        assert!(matches!(id, SecurityId::Figi(_)));
324    }
325
326    #[test]
327    fn detects_cusip() {
328        let id = SecurityId::detect("037833100").unwrap();
329        assert_eq!(id.kind(), IdentifierKind::Cusip);
330        assert_eq!(id.as_str(), "037833100");
331        assert!(matches!(id, SecurityId::Cusip(_)));
332    }
333
334    #[test]
335    fn detects_sedol() {
336        let id = SecurityId::detect("0263494").unwrap();
337        assert_eq!(id.kind(), IdentifierKind::Sedol);
338        assert_eq!(id.as_str(), "0263494");
339        assert!(matches!(id, SecurityId::Sedol(_)));
340    }
341
342    #[test]
343    fn detects_bic() {
344        let id = SecurityId::detect("DEUTDEFF").unwrap();
345        assert_eq!(id.kind(), IdentifierKind::Bic);
346        assert_eq!(id.as_str(), "DEUTDEFF");
347        assert!(matches!(id, SecurityId::Bic(_)));
348    }
349
350    #[test]
351    fn detects_eleven_character_bic() {
352        // An 11-character BIC is detected and never mistaken for an ISIN or a
353        // FIGI — both of those reject it on structure or check digit.
354        let id = SecurityId::detect("DEUTDEFF500").unwrap();
355        assert_eq!(id.kind(), IdentifierKind::Bic);
356        assert_eq!(id.as_str(), "DEUTDEFF500");
357    }
358
359    #[test]
360    fn isin_wins_over_figi_at_twelve_characters() {
361        // A valid ISIN is reported as an ISIN even though FIGI is also a
362        // 12-character kind — ISIN is tried first.
363        let id = SecurityId::detect("US0378331005").unwrap();
364        assert_eq!(id.kind(), IdentifierKind::Isin);
365    }
366
367    #[test]
368    fn figi_is_not_mislabelled_as_isin() {
369        // A genuine FIGI is never a valid ISIN, so it falls through to FIGI.
370        let id = SecurityId::detect("BBG000BLNNH6").unwrap();
371        assert_eq!(id.kind(), IdentifierKind::Figi);
372    }
373
374    #[test]
375    fn returns_none_for_garbage() {
376        for bad in [
377            "",
378            "garbage",
379            "not-an-identifier",
380            "1234",
381            "!!!!!!!!",
382            "lowercaseinput",
383        ] {
384            assert!(
385                SecurityId::detect(bad).is_none(),
386                "{bad} should not be detected"
387            );
388        }
389    }
390
391    #[test]
392    fn returns_none_for_bad_check_digit() {
393        // A 12-character string with the wrong ISIN check digit is not a valid
394        // ISIN, is not a FIGI (no leading 'G'), and matches nothing else.
395        assert!(SecurityId::detect("US0378331004").is_none());
396        // A 9-character string with the wrong CUSIP check digit, likewise.
397        assert!(SecurityId::detect("037833101").is_none());
398    }
399
400    #[test]
401    fn structural_only_kinds_are_not_detected() {
402        // CFI, WKN, and VALOR parse on their own but are never auto-detected.
403        assert!(Cfi::parse("ESVUFR").is_ok());
404        assert!(SecurityId::detect("ESVUFR").is_none());
405
406        assert!(Wkn::parse("A1EWWW").is_ok());
407        // A1EWWW is 6 alphanumeric chars — no checksum-bearing kind fits.
408        assert!(SecurityId::detect("A1EWWW").is_none());
409
410        assert!(Valor::parse("1213853").is_ok());
411        // 1213853 is 7 digits — a valid SEDOL body would need a check digit
412        // that makes the whole 7-char string valid; this one is not a SEDOL.
413        let valor_detect = SecurityId::detect("1213853");
414        assert!(valor_detect.is_none_or(|id| id.kind() != IdentifierKind::Valor));
415    }
416
417    #[test]
418    fn kind_for_every_variant() {
419        // Each `SecurityId` variant reports its matching `IdentifierKind`,
420        // including the three that `detect` never produces.
421        let isin = Isin::parse("US0378331005").unwrap();
422        let cusip = Cusip::parse("037833100").unwrap();
423        let sedol = Sedol::parse("0263494").unwrap();
424        let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
425        let figi = Figi::parse("BBG000BLNNH6").unwrap();
426        let bic = Bic::parse("DEUTDEFF").unwrap();
427        let mic = Mic::parse("XNAS").unwrap();
428        let cfi = Cfi::parse("ESVUFR").unwrap();
429        let wkn = Wkn::parse("A1EWWW").unwrap();
430        let valor = Valor::parse("1213853").unwrap();
431
432        assert_eq!(SecurityId::Isin(isin).kind(), IdentifierKind::Isin);
433        assert_eq!(SecurityId::Cusip(cusip).kind(), IdentifierKind::Cusip);
434        assert_eq!(SecurityId::Sedol(sedol).kind(), IdentifierKind::Sedol);
435        assert_eq!(SecurityId::Lei(lei).kind(), IdentifierKind::Lei);
436        assert_eq!(SecurityId::Figi(figi).kind(), IdentifierKind::Figi);
437        assert_eq!(SecurityId::Bic(bic).kind(), IdentifierKind::Bic);
438        assert_eq!(SecurityId::Mic(mic).kind(), IdentifierKind::Mic);
439        assert_eq!(SecurityId::Cfi(cfi).kind(), IdentifierKind::Cfi);
440        assert_eq!(SecurityId::Wkn(wkn).kind(), IdentifierKind::Wkn);
441        assert_eq!(SecurityId::Valor(valor).kind(), IdentifierKind::Valor);
442    }
443
444    #[test]
445    fn as_str_for_every_variant() {
446        // `as_str` returns the canonical text of every wrapped identifier.
447        let isin = Isin::parse("US0378331005").unwrap();
448        let cusip = Cusip::parse("037833100").unwrap();
449        let sedol = Sedol::parse("0263494").unwrap();
450        let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
451        let figi = Figi::parse("BBG000BLNNH6").unwrap();
452        let bic = Bic::parse("DEUTDEFF").unwrap();
453        let mic = Mic::parse("XNAS").unwrap();
454        let cfi = Cfi::parse("ESVUFR").unwrap();
455        let wkn = Wkn::parse("A1EWWW").unwrap();
456        let valor = Valor::parse("1213853").unwrap();
457
458        assert_eq!(SecurityId::Isin(isin).as_str(), "US0378331005");
459        assert_eq!(SecurityId::Cusip(cusip).as_str(), "037833100");
460        assert_eq!(SecurityId::Sedol(sedol).as_str(), "0263494");
461        assert_eq!(SecurityId::Lei(lei).as_str(), "5493001KJTIIGC8Y1R12");
462        assert_eq!(SecurityId::Figi(figi).as_str(), "BBG000BLNNH6");
463        assert_eq!(SecurityId::Bic(bic).as_str(), "DEUTDEFF");
464        assert_eq!(SecurityId::Mic(mic).as_str(), "XNAS");
465        assert_eq!(SecurityId::Cfi(cfi).as_str(), "ESVUFR");
466        assert_eq!(SecurityId::Wkn(wkn).as_str(), "A1EWWW");
467        assert_eq!(SecurityId::Valor(valor).as_str(), "1213853");
468    }
469
470    #[test]
471    fn detected_value_round_trips_through_as_str() {
472        // Every detectable input re-serialises identically through `as_str`.
473        for &s in &[
474            "5493001KJTIIGC8Y1R12",
475            "US0378331005",
476            "BBG000BLNNH6",
477            "037833100",
478            "0263494",
479            "DEUTDEFF",
480            "DEUTDEFF500",
481        ] {
482            let id = SecurityId::detect(s).unwrap_or_else(|| panic!("{s} should detect"));
483            assert_eq!(id.as_str(), s);
484        }
485    }
486
487    #[test]
488    fn identifier_kind_is_copy_and_eq() {
489        let a = IdentifierKind::Isin;
490        let b = a; // Copy
491        assert_eq!(a, b);
492        assert_ne!(IdentifierKind::Isin, IdentifierKind::Figi);
493    }
494
495    #[test]
496    fn security_id_is_copy_and_eq() {
497        let a = SecurityId::detect("US0378331005").unwrap();
498        let b = a; // Copy
499        assert_eq!(a, b);
500        assert_ne!(a, SecurityId::detect("037833100").unwrap());
501    }
502
503    #[test]
504    fn debug_renders_kind_and_variant() {
505        assert!(debug(IdentifierKind::Lei).as_str().contains("Lei"));
506        let id = SecurityId::detect("US0378331005").unwrap();
507        assert!(debug(id).as_str().contains("Isin"));
508    }
509
510    #[cfg(feature = "mic-registry")]
511    #[test]
512    fn detects_registered_mic() {
513        // With the registry feature on, a registered MIC is detected.
514        let id = SecurityId::detect("XNAS").unwrap();
515        assert_eq!(id.kind(), IdentifierKind::Mic);
516        assert_eq!(id.as_str(), "XNAS");
517        assert!(matches!(id, SecurityId::Mic(_)));
518    }
519
520    #[cfg(feature = "mic-registry")]
521    #[test]
522    fn unregistered_mic_is_not_detected() {
523        // ZZZZ is structurally a valid MIC but is in no registry, so it is
524        // not auto-detected even with the feature enabled.
525        assert!(Mic::parse("ZZZZ").is_ok());
526        assert!(SecurityId::detect("ZZZZ").is_none());
527    }
528}