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}