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}