Skip to main content

regit_identifiers/
cfi.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! CFI — Classification of Financial Instruments (ISO 10962).
5//!
6//! A CFI code classifies a financial instrument — what kind of thing it is,
7//! rather than which specific issue. It is exactly 6 characters, all
8//! upper-case letters, in three parts:
9//!
10//! ```text
11//!   E S V U F R
12//!   │ │ └──┬──┘
13//!   │ │    └──── attributes  [2..6]  four characters, instrument-specific
14//!   │ └───────── group       [1]     a category-dependent sub-class
15//!   └─────────── category    [0]     one of 14 ISO 10962 category letters
16//! ```
17//!
18//! - The **category** is the top-level class. It must be one of the 14
19//!   ISO 10962 category letters `E C D R O F S H I J K L T M`.
20//! - The **group** narrows the category into a sub-class; its meaning depends
21//!   on the category.
22//! - The **four attributes** further describe the instrument; an `X` in any
23//!   attribute position means "not applicable / not known".
24//!
25//! A CFI carries **no check digit**, so any 6 upper-case letters whose first
26//! character is a valid category letter form a structurally valid CFI.
27//!
28//! # Scope of validation
29//!
30//! [`Cfi::parse`] validates **structure and category only**: the exact
31//! length, the all-`[A-Z]` character set, and that character 1 is a
32//! recognised category letter. It deliberately does **not** check the group
33//! character or the four attribute characters against the per-category
34//! ISO 10962 tables. Those tables are large, category-specific, and revised
35//! with each edition of the standard; validating against an embedded snapshot
36//! would silently reject instruments classified under a newer revision. The
37//! group and attributes are therefore exposed verbatim through accessors but
38//! left semantically unvalidated.
39//!
40//! # References
41//!
42//! - ISO 10962, *Securities and related financial instruments —
43//!   Classification of financial instruments (CFI) code*.
44
45use crate::errors::ValidationError;
46
47/// A validated Classification of Financial Instruments code (ISO 10962).
48///
49/// A `Cfi` can only be created by [`Cfi::parse`] (or the explicitly unchecked
50/// [`Cfi::from_bytes_unchecked`]), so a value of this type is a proof that
51/// the 6 characters are all upper-case letters and that the first is a
52/// recognised ISO 10962 category letter. It stores the code inline as
53/// `[u8; 6]`, is `Copy`, and allocates nothing.
54///
55/// # Examples
56///
57/// ```
58/// use regit_identifiers::Cfi;
59///
60/// let cfi = Cfi::parse("ESVUFR").unwrap();
61/// assert_eq!(cfi.category(), 'E');
62/// assert_eq!(cfi.category_name(), "Equities");
63/// assert_eq!(cfi.group(), 'S');
64/// assert_eq!(cfi.attributes(), "VUFR");
65/// assert_eq!(cfi.as_str(), "ESVUFR");
66/// ```
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct Cfi {
69    /// The 6 validated ASCII bytes of the code.
70    bytes: [u8; Self::LENGTH],
71}
72
73impl Cfi {
74    /// The number of characters in a CFI code.
75    pub const LENGTH: usize = 6;
76
77    /// Parses and validates a CFI code.
78    ///
79    /// Validation is strict and, in order: the input must be exactly 6
80    /// characters; every character must be an ASCII upper-case letter; and
81    /// the first character must be one of the 14 ISO 10962 category letters
82    /// `E C D R O F S H I J K L T M`. A CFI has no check digit, so any
83    /// 6-letter string satisfying those rules parses.
84    ///
85    /// The group character and the four attribute characters are **not**
86    /// validated against the per-category ISO 10962 tables — see the module
87    /// documentation for why.
88    ///
89    /// # Errors
90    ///
91    /// - [`ValidationError::WrongLength`] if the input is not 6 characters.
92    /// - [`ValidationError::InvalidCharacter`] if a character is not an ASCII
93    ///   upper-case letter (this also rejects digits, lower-case input, and
94    ///   any non-ASCII character).
95    /// - [`ValidationError::Structure`] if the first character is not a
96    ///   recognised ISO 10962 category letter.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use regit_identifiers::Cfi;
102    /// use regit_identifiers::errors::ValidationError;
103    ///
104    /// assert!(Cfi::parse("ESVUFR").is_ok());
105    ///
106    /// // `Q` is not one of the 14 ISO 10962 category letters.
107    /// assert_eq!(
108    ///     Cfi::parse("QSVUFR"),
109    ///     Err(ValidationError::Structure {
110    ///         rule: "CFI category must be one of E C D R O F S H I J K L T M",
111    ///     }),
112    /// );
113    /// ```
114    pub fn parse(s: &str) -> Result<Self, ValidationError> {
115        // A CFI is exactly 6 characters.
116        let found = s.chars().count();
117        if found != Self::LENGTH {
118            return Err(ValidationError::WrongLength {
119                expected: Self::LENGTH,
120                found,
121            });
122        }
123        // Every character must be an ASCII upper-case letter. A non-ASCII
124        // character fails the predicate and is rejected here.
125        for (i, ch) in s.chars().enumerate() {
126            if !ch.is_ascii_uppercase() {
127                return Err(ValidationError::InvalidCharacter {
128                    position: i + 1,
129                    found: ch,
130                });
131            }
132        }
133        // Every character is ASCII, so the string is exactly 6 ASCII bytes.
134        let mut bytes = [0u8; Self::LENGTH];
135        bytes.copy_from_slice(s.as_bytes());
136
137        // The first character must be a recognised ISO 10962 category letter.
138        if category_name_of(bytes[0]).is_none() {
139            return Err(ValidationError::Structure {
140                rule: "CFI category must be one of E C D R O F S H I J K L T M",
141            });
142        }
143        Ok(Self { bytes })
144    }
145
146    /// Validates a CFI code without constructing one.
147    ///
148    /// Equivalent to `Cfi::parse(s).map(|_| ())`; use it when only the
149    /// verdict is needed.
150    ///
151    /// # Errors
152    ///
153    /// Returns the same [`ValidationError`] variants as [`Cfi::parse`].
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use regit_identifiers::Cfi;
159    ///
160    /// assert!(Cfi::validate("ESVUFR").is_ok());
161    /// assert!(Cfi::validate("QSVUFR").is_err());
162    /// ```
163    pub fn validate(s: &str) -> Result<(), ValidationError> {
164        Self::parse(s).map(|_| ())
165    }
166
167    /// Wraps 6 raw bytes as a `Cfi` without any validation.
168    ///
169    /// The caller asserts that `bytes` holds the 6 ASCII characters of a
170    /// valid CFI. This exists for reconstructing a `Cfi` from bytes that were
171    /// validated earlier; prefer [`Cfi::parse`] for any untrusted input.
172    ///
173    /// Bypassing validation has a consequence for [`Cfi::category_name`]: if
174    /// `bytes[0]` is not one of the 14 ISO 10962 category letters, the
175    /// accessor returns the empty string `""` (the safe fallback).
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use regit_identifiers::Cfi;
181    ///
182    /// let cfi = Cfi::from_bytes_unchecked(*b"ESVUFR");
183    /// assert_eq!(cfi.as_str(), "ESVUFR");
184    /// ```
185    #[must_use]
186    pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
187        Self { bytes }
188    }
189
190    /// Returns the CFI code as a string slice.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use regit_identifiers::Cfi;
196    ///
197    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().as_str(), "ESVUFR");
198    /// ```
199    #[must_use]
200    #[inline]
201    pub fn as_str(&self) -> &str {
202        core::str::from_utf8(&self.bytes).unwrap_or("")
203    }
204
205    /// Returns the CFI code as its 6 raw ASCII bytes.
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use regit_identifiers::Cfi;
211    ///
212    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().as_bytes(), b"ESVUFR");
213    /// ```
214    #[must_use]
215    #[inline]
216    pub fn as_bytes(&self) -> &[u8] {
217        &self.bytes
218    }
219
220    /// Returns the category letter, character 1.
221    ///
222    /// This is always one of the 14 ISO 10962 category letters.
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use regit_identifiers::Cfi;
228    ///
229    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().category(), 'E');
230    /// ```
231    #[must_use]
232    #[inline]
233    pub fn category(&self) -> char {
234        char::from(self.bytes[0])
235    }
236
237    /// Returns the ISO 10962 name of the category, character 1.
238    ///
239    /// The returned name is one of the 14 fixed category descriptions; it is
240    /// never empty for a value produced by [`Cfi::parse`].
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use regit_identifiers::Cfi;
246    ///
247    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().category_name(), "Equities");
248    /// assert_eq!(Cfi::parse("DBFUGR").unwrap().category_name(), "Debt instruments");
249    /// ```
250    #[must_use]
251    #[inline]
252    pub fn category_name(&self) -> &'static str {
253        category_name_of(self.bytes[0]).unwrap_or("")
254    }
255
256    /// Returns the group letter, character 2.
257    ///
258    /// The group narrows the category into a sub-class. Its meaning is
259    /// category-dependent and is **not** validated by [`Cfi::parse`].
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use regit_identifiers::Cfi;
265    ///
266    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().group(), 'S');
267    /// ```
268    #[must_use]
269    #[inline]
270    pub fn group(&self) -> char {
271        char::from(self.bytes[1])
272    }
273
274    /// Returns the four attribute characters, characters 3–6.
275    ///
276    /// The attributes further describe the instrument; an `X` in a position
277    /// means "not applicable". Their meaning is category-dependent and is
278    /// **not** validated by [`Cfi::parse`].
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use regit_identifiers::Cfi;
284    ///
285    /// assert_eq!(Cfi::parse("ESVUFR").unwrap().attributes(), "VUFR");
286    /// ```
287    #[must_use]
288    #[inline]
289    pub fn attributes(&self) -> &str {
290        core::str::from_utf8(&self.bytes[2..6]).unwrap_or("")
291    }
292}
293
294/// Maps an ISO 10962 category letter to its English name.
295///
296/// Returns `None` if `b` is not one of the 14 recognised category letters,
297/// which is exactly how [`Cfi::parse`] decides whether character 1 is valid.
298fn category_name_of(b: u8) -> Option<&'static str> {
299    match b {
300        b'E' => Some("Equities"),
301        b'C' => Some("Collective investment vehicles"),
302        b'D' => Some("Debt instruments"),
303        b'R' => Some("Entitlements (rights)"),
304        b'O' => Some("Listed options"),
305        b'F' => Some("Futures"),
306        b'S' => Some("Swaps"),
307        b'H' => Some("Non-listed and complex listed options"),
308        b'I' => Some("Spot"),
309        b'J' => Some("Forwards"),
310        b'K' => Some("Strategies"),
311        b'L' => Some("Financing"),
312        b'T' => Some("Referential instruments"),
313        b'M' => Some("Others (miscellaneous)"),
314        _ => None,
315    }
316}
317
318impl core::fmt::Display for Cfi {
319    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
320        f.write_str(self.as_str())
321    }
322}
323
324impl core::str::FromStr for Cfi {
325    type Err = ValidationError;
326
327    fn from_str(s: &str) -> Result<Self, Self::Err> {
328        Self::parse(s)
329    }
330}
331
332impl AsRef<str> for Cfi {
333    fn as_ref(&self) -> &str {
334        self.as_str()
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::test_support::display;
342    use core::str::FromStr;
343
344    /// Well-formed CFI codes used as regression anchors.
345    const GOLDEN: &[&str] = &[
346        "ESVUFR", // Equities
347        "DBFUGR", // Debt instruments
348        "OCASPS", // Listed options
349        "CIOIES", // Collective investment vehicles
350    ];
351
352    /// One representative CFI for every ISO 10962 category letter, paired
353    /// with the expected category name.
354    const CATEGORY_VECTORS: &[(&str, char, &str)] = &[
355        ("EXXXXX", 'E', "Equities"),
356        ("CXXXXX", 'C', "Collective investment vehicles"),
357        ("DXXXXX", 'D', "Debt instruments"),
358        ("RXXXXX", 'R', "Entitlements (rights)"),
359        ("OXXXXX", 'O', "Listed options"),
360        ("FXXXXX", 'F', "Futures"),
361        ("SXXXXX", 'S', "Swaps"),
362        ("HXXXXX", 'H', "Non-listed and complex listed options"),
363        ("IXXXXX", 'I', "Spot"),
364        ("JXXXXX", 'J', "Forwards"),
365        ("KXXXXX", 'K', "Strategies"),
366        ("LXXXXX", 'L', "Financing"),
367        ("TXXXXX", 'T', "Referential instruments"),
368        ("MXXXXX", 'M', "Others (miscellaneous)"),
369    ];
370
371    #[test]
372    fn parses_golden_cfis() {
373        for &s in GOLDEN {
374            let cfi = Cfi::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
375            assert_eq!(cfi.as_str(), s);
376        }
377    }
378
379    #[test]
380    fn segment_accessors() {
381        let cfi = Cfi::parse("ESVUFR").unwrap();
382        assert_eq!(cfi.category(), 'E');
383        assert_eq!(cfi.category_name(), "Equities");
384        assert_eq!(cfi.group(), 'S');
385        assert_eq!(cfi.attributes(), "VUFR");
386        assert_eq!(cfi.as_bytes(), b"ESVUFR");
387        assert_eq!(Cfi::LENGTH, 6);
388    }
389
390    #[test]
391    fn accepts_all_category_letters() {
392        for &(s, letter, name) in CATEGORY_VECTORS {
393            let cfi = Cfi::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
394            assert_eq!(cfi.category(), letter);
395            assert_eq!(cfi.category_name(), name);
396        }
397    }
398
399    #[test]
400    fn accepts_any_group_and_attributes() {
401        // A CFI has no check digit and group/attributes are unvalidated, so
402        // any 6-letter code with a valid category letter parses.
403        let cfi = Cfi::parse("EZZZZZ").unwrap();
404        assert_eq!(cfi.group(), 'Z');
405        assert_eq!(cfi.attributes(), "ZZZZ");
406    }
407
408    #[test]
409    fn rejects_wrong_length() {
410        assert_eq!(
411            Cfi::parse("ESVUF"),
412            Err(ValidationError::WrongLength {
413                expected: 6,
414                found: 5,
415            })
416        );
417        assert_eq!(
418            Cfi::parse("ESVUFRR"),
419            Err(ValidationError::WrongLength {
420                expected: 6,
421                found: 7,
422            })
423        );
424        assert_eq!(
425            Cfi::parse(""),
426            Err(ValidationError::WrongLength {
427                expected: 6,
428                found: 0,
429            })
430        );
431    }
432
433    #[test]
434    fn rejects_digit() {
435        assert!(matches!(
436            Cfi::parse("ESVUF1"),
437            Err(ValidationError::InvalidCharacter { position: 6, .. })
438        ));
439    }
440
441    #[test]
442    fn rejects_lower_case() {
443        assert!(matches!(
444            Cfi::parse("esvufr"),
445            Err(ValidationError::InvalidCharacter { position: 1, .. })
446        ));
447    }
448
449    #[test]
450    fn rejects_unknown_category() {
451        assert_eq!(
452            Cfi::parse("QSVUFR"),
453            Err(ValidationError::Structure {
454                rule: "CFI category must be one of E C D R O F S H I J K L T M",
455            })
456        );
457    }
458
459    #[test]
460    fn rejects_non_ascii_without_panic() {
461        // A multi-byte character must be rejected cleanly.
462        assert!(Cfi::parse("ESVUFÉ").is_err());
463        assert!(Cfi::parse("ÉSVUFR").is_err());
464    }
465
466    #[test]
467    fn round_trips_through_str() {
468        for &s in GOLDEN {
469            assert_eq!(Cfi::parse(s).unwrap().as_str(), s);
470        }
471    }
472
473    #[test]
474    fn from_str_matches_parse() {
475        assert_eq!(Cfi::from_str("ESVUFR"), Cfi::parse("ESVUFR"));
476        assert!(Cfi::from_str("nonsense").is_err());
477    }
478
479    #[test]
480    fn display_renders_identifier() {
481        let cfi = Cfi::parse("ESVUFR").unwrap();
482        assert_eq!(display(cfi).as_str(), "ESVUFR");
483    }
484
485    #[test]
486    fn as_ref_str() {
487        let cfi = Cfi::parse("ESVUFR").unwrap();
488        let s: &str = cfi.as_ref();
489        assert_eq!(s, "ESVUFR");
490    }
491
492    #[test]
493    fn from_bytes_unchecked_round_trip() {
494        let cfi = Cfi::from_bytes_unchecked(*b"ESVUFR");
495        assert_eq!(cfi, Cfi::parse("ESVUFR").unwrap());
496    }
497
498    #[test]
499    fn validate_matches_parse() {
500        assert!(Cfi::validate("ESVUFR").is_ok());
501        assert!(Cfi::validate("QSVUFR").is_err());
502        assert!(Cfi::validate("ESVUF").is_err());
503    }
504
505    #[test]
506    fn is_copy_and_eq_and_hashable() {
507        let a = Cfi::parse("ESVUFR").unwrap();
508        let b = a; // Copy
509        assert_eq!(a, b);
510        assert_ne!(a, Cfi::parse("DBFUGR").unwrap());
511        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
512        let keys = [a, b];
513        assert_eq!(keys[0], keys[1]);
514    }
515}