Skip to main content

ftracker_identifiers/
cfi.rs

1//! CFI (Classification of Financial Instruments) — the ISO 10962 six-letter code that classifies a
2//! financial instrument by category, group, and four attributes.
3//!
4//! This module provides the validated Rust representation ([`Cfi`]) and the parsing, validation,
5//! and error types that surround it. It accepts the canonical 6-character form (optionally
6//! surrounded by whitespace, in any ASCII case), normalizes it, and guarantees that any constructed
7//! [`Cfi`] describes a combination actually defined by ISO 10962. There is no partially validated
8//! state: if you hold a [`Cfi`], it is valid.
9//!
10//! # What this type represents
11//!
12//! A CFI has 6 characters, all uppercase letters, split into three parts:
13//!
14//! | Positions | Length | Segment    | Meaning                                                          |
15//! |-----------|--------|------------|------------------------------------------------------------------|
16//! | 1         | 1      | Category   | The broadest class of instrument (e.g. `E` = equities)           |
17//! | 2         | 1      | Group      | A subdivision within the category (meaning depends on the category) |
18//! | 3–6       | 4      | Attributes | Four attribute codes whose meaning depends on the category and group |
19//!
20//! ```text
21//! ┌────────────────────────────────────────┐
22//! │ Cat │ Grp │  Attribute 1..4 (4 chars)  │
23//! │  E  │  S  │   V     U     F     R      │
24//! └────────────────────────────────────────┘
25//! ```
26//!
27//! [`Cfi`] stores those 6 characters as normalized uppercase ASCII and exposes borrowed/`char`
28//! accessors for the category ([`Cfi::category`]), the group ([`Cfi::group`]), the four attributes
29//! ([`Cfi::attributes`]), and the whole value ([`Cfi::as_str`]).
30//!
31//! # Validation rules — taxonomy, not checksum
32//!
33//! Unlike [`Cnpj`](crate::Cnpj) (Módulo 11) or [`Isin`](crate::Isin) (Luhn), a CFI carries no check
34//! digit. Its validity is defined entirely by the ISO 10962 code taxonomy, which this crate embeds
35//! as a generated, `no_std` lookup table. Every fallible constructor runs the same rules, in order,
36//! and each maps to one [`CfiError`] variant:
37//!
38//! 1. **Length** — after surrounding whitespace is trimmed, the input must contain exactly 6
39//!    characters ([`CfiError::InvalidLength`]). [`Cfi::parse`] rejects empty input up front
40//!    ([`CfiError::Empty`]).
41//! 2. **Character class** — every position must be an uppercase ASCII letter
42//!    ([`CfiError::InvalidCharacter`]).
43//! 3. **Category** — position 1 must be a category defined by ISO 10962
44//!    ([`CfiError::UnknownCategory`]).
45//! 4. **Group** — position 2 must be a group defined for that category ([`CfiError::UnknownGroup`]).
46//! 5. **Attributes** — each of positions 3–6 must be a code the standard permits for the resolved
47//!    category and group at that attribute position ([`CfiError::InvalidAttribute`]).
48//!
49//! Only the classification *codes* are embedded — not ISO's descriptive text — so this crate can
50//! tell you a CFI is well-formed and which position is wrong, but it does not resolve the codes to
51//! their human-readable meanings.
52//!
53//! # Design notes
54//!
55//! - **No invalid state is representable.** [`Cfi`]'s only field is private; the only ways to
56//!   obtain one — [`Cfi::parse`], [`Cfi::new`], [`Cfi::from_bytes`], [`FromStr`], and
57//!   [`TryFrom<&str>`] — all run full validation. There is no unchecked constructor.
58//! - **Zero allocation, `Copy`, `no_std`-friendly.** [`Cfi`] is a 6-byte value type wrapping
59//!   `[u8; 6]`. Parsing, validating, and every accessor operate on the stack; the taxonomy lookup
60//!   is a couple of binary searches and bitmask tests over a `static` table.
61//! - **Ordering and hashing are byte-wise.** [`Cfi`] derives [`Ord`] and [`Hash`] directly over its
62//!   ASCII bytes, matching [`str`] ordering on [`Cfi::as_str`]. This is lexicographic string order,
63//!   with no taxonomic meaning.
64//! - **Safe to use as a map/set key.** [`Cfi`] implements [`Eq`] and [`Hash`] consistently with
65//!   [`PartialEq`], so it works as a `HashMap`/`HashSet` or `BTreeMap`/`BTreeSet` key out of the box.
66//!
67//! # Feature flags
68//!
69//! This module's optional integrations are off by default and purely additive — enabling one never
70//! changes the behavior of [`Cfi::parse`] or the validation rules above:
71//!
72//! - **`serde`** — (de)serializes [`Cfi`] as its 6-character string (e.g. `"ESVUFR"`).
73//!   Deserialization re-runs full validation, so an untrusted payload can never produce an invalid
74//!   [`Cfi`].
75//! - **`schemars`** — implements `JsonSchema` for [`Cfi`], describing it as a pattern-constrained
76//!   string (`^[A-Z]{6}$`). The pattern is structural only; it cannot express which combinations are
77//!   taxonomically valid. Implies `serde`.
78//! - **`arbitrary`** — implements `Arbitrary` for [`Cfi`], generating taxonomically valid values for
79//!   fuzz targets by walking the embedded table.
80//! - **`proptest`** — exposes reusable `proptest` strategies (`ftracker_identifiers::cfi::proptest`,
81//!   when this feature is enabled) for generating valid [`Cfi`] values.
82//!
83//! # Error handling
84//!
85//! Every fallible constructor returns [`CfiError`], which is `Clone + PartialEq + Eq` and implements
86//! [`core::error::Error`] and [`core::fmt::Display`], so it composes with `?` and with
87//! error-aggregation crates alike:
88//!
89//! ```
90//! use ftracker_identifiers::{Cfi, CfiError};
91//!
92//! match Cfi::parse("ESZUFR") {
93//!     Ok(cfi) => println!("valid: {cfi}"),
94//!     Err(CfiError::InvalidAttribute { index, code, .. }) => {
95//!         println!("attribute {index} rejected: {code}");
96//!     }
97//!     Err(other) => println!("rejected: {other}"),
98//! }
99//! ```
100//!
101//! # Examples
102//!
103//! ```
104//! use ftracker_identifiers::Cfi;
105//!
106//! let cfi = Cfi::parse("ESVUFR").unwrap();
107//! assert_eq!(cfi.category(), 'E');
108//! assert_eq!(cfi.group(), 'S');
109//! assert_eq!(cfi.attributes(), ['V', 'U', 'F', 'R']);
110//! assert_eq!(cfi.as_str(), "ESVUFR");
111//! ```
112//!
113//! Sorting and deduplicating a batch of CFIs, e.g. after importing them from a spreadsheet:
114//!
115//! ```
116//! use ftracker_identifiers::Cfi;
117//!
118//! let mut cfis: Vec<Cfi> = ["ESVUFR", "DBFTFB", "ESVUFR"]
119//!     .into_iter()
120//!     .map(|s| Cfi::parse(s).unwrap())
121//!     .collect();
122//! cfis.sort();
123//! cfis.dedup();
124//! assert_eq!(cfis.len(), 2);
125//! ```
126
127mod error;
128mod fmt;
129mod parser;
130mod table;
131mod validation;
132
133#[cfg(feature = "serde")]
134mod serde;
135
136#[cfg(feature = "schemars")]
137mod schema;
138
139#[cfg(feature = "arbitrary")]
140mod arbitrary;
141
142#[cfg(any(test, feature = "proptest"))]
143pub mod proptest;
144
145#[cfg(test)]
146mod tests;
147
148pub use error::CfiError;
149
150use core::convert::TryFrom;
151use core::str::{FromStr, from_utf8_unchecked};
152
153/// A validated CFI (Classification of Financial Instruments, ISO 10962).
154///
155/// `Cfi` is a 6-byte, `Copy`, allocation-free value object. Once constructed, it is guaranteed to
156/// describe a category, group, and four attribute codes defined by ISO 10962 — there is no way to
157/// get a `Cfi` that hasn't passed validation.
158///
159/// Internally, the identifier is stored as raw uppercase ASCII letters (`'A'...='Z'`).
160///
161/// # Constructing a `Cfi`
162///
163/// | Constructor                    | Accepts                                             |
164/// |---------------------------------|-----------------------------------------------------|
165/// | [`Cfi::parse`] / [`Cfi::new`]   | 6-character strings, any ASCII case, trimmed         |
166/// | [`Cfi::from_bytes`]             | Exactly 6 pre-normalized uppercase ASCII bytes       |
167/// | [`FromStr`] / [`TryFrom<&str>`] | Same as `parse`, for use in generic code            |
168///
169/// All of them run the same validation and return [`CfiError`] on failure.
170/// See the [module-level documentation](self) for the segment layout and design rationale.
171#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
172#[must_use = "a parsed Cfi should be used; discarding it wastes the validation work"]
173pub struct Cfi {
174    bytes: [u8; 6],
175}
176
177impl Cfi {
178    /// Parses a CFI from a string.
179    ///
180    /// The parser trims surrounding whitespace and folds ASCII letters to uppercase before
181    /// validation. This is the primary constructor; [`Cfi::new`], [`FromStr`], and
182    /// [`TryFrom<&str>`] all delegate to it.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`CfiError`] if the input is empty, does not contain exactly 6 characters after
187    /// trimming, contains a non-letter character, or names a category, group, or attribute code
188    /// that ISO 10962 does not define.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use ftracker_identifiers::Cfi;
194    ///
195    /// assert!(Cfi::parse("ESVUFR").is_ok());
196    /// assert!(Cfi::parse("esvufr").is_ok()); // lowercase is folded automatically
197    /// assert!(Cfi::parse(" ESVUFR ").is_ok()); // surrounding whitespace is trimmed
198    /// assert!(Cfi::parse("EZVUFR").is_err()); // 'Z' is not a group of category 'E'
199    /// ```
200    pub fn parse(input: &str) -> Result<Self, CfiError> {
201        let candidate = parser::normalize(input)?;
202        Self::from_bytes(candidate)
203    }
204
205    /// Alias for [`Cfi::parse`].
206    ///
207    /// # Errors
208    ///
209    /// See [`Cfi::parse`].
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use ftracker_identifiers::Cfi;
215    ///
216    /// assert_eq!(Cfi::new("ESVUFR"), Cfi::parse("ESVUFR"));
217    /// ```
218    #[inline]
219    pub fn new(input: &str) -> Result<Self, CfiError> {
220        Self::parse(input)
221    }
222
223    /// Constructs a `Cfi` directly from 6 raw ASCII bytes.
224    ///
225    /// Each byte must already be an uppercase letter valid for its position. Use [`Cfi::parse`] if
226    /// the input might contain surrounding whitespace or lowercase letters.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`CfiError`] under the same conditions as [`Cfi::parse`], except that length is
231    /// guaranteed by the `[u8; 6]` type itself: [`CfiError::InvalidLength`] cannot occur here.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use ftracker_identifiers::Cfi;
237    ///
238    /// let cfi = Cfi::from_bytes(*b"ESVUFR").unwrap();
239    /// assert_eq!(cfi.as_str(), "ESVUFR");
240    ///
241    /// // An undefined attribute code is rejected just like it would be through `parse`.
242    /// assert!(Cfi::from_bytes(*b"ESZUFR").is_err());
243    /// ```
244    pub fn from_bytes(bytes: [u8; 6]) -> Result<Self, CfiError> {
245        validation::validate(&bytes)?;
246        Ok(Cfi { bytes })
247    }
248
249    /// Returns the 6 raw ASCII bytes backing this CFI (for example, `b"ESVUFR"`).
250    ///
251    /// # Examples
252    ///
253    /// ```
254    /// use ftracker_identifiers::Cfi;
255    ///
256    /// let cfi = Cfi::parse("ESVUFR").unwrap();
257    /// assert_eq!(cfi.as_bytes(), b"ESVUFR");
258    /// ```
259    #[inline]
260    #[must_use]
261    pub fn as_bytes(&self) -> &[u8; 6] {
262        &self.bytes
263    }
264
265    /// Returns the full 6-character CFI as a `&str`.
266    ///
267    /// This never allocates: the bytes are guaranteed to be valid ASCII by construction.
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use ftracker_identifiers::Cfi;
273    ///
274    /// let cfi = Cfi::parse("ESVUFR").unwrap();
275    /// assert_eq!(cfi.as_str(), "ESVUFR");
276    /// ```
277    #[inline]
278    #[must_use]
279    pub fn as_str(&self) -> &str {
280        // SAFETY: `Cfi::from_bytes` guarantees every byte is an uppercase ASCII letter.
281        unsafe { from_utf8_unchecked(&self.bytes) }
282    }
283
284    /// Returns the category code (position 1).
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use ftracker_identifiers::Cfi;
290    ///
291    /// let cfi = Cfi::parse("ESVUFR").unwrap();
292    /// assert_eq!(cfi.category(), 'E');
293    /// ```
294    #[inline]
295    #[must_use]
296    pub fn category(&self) -> char {
297        self.bytes[0] as char
298    }
299
300    /// Returns the group code (position 2).
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use ftracker_identifiers::Cfi;
306    ///
307    /// let cfi = Cfi::parse("ESVUFR").unwrap();
308    /// assert_eq!(cfi.group(), 'S');
309    /// ```
310    #[inline]
311    #[must_use]
312    pub fn group(&self) -> char {
313        self.bytes[1] as char
314    }
315
316    /// Returns the four attribute codes (positions 3–6), in order.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use ftracker_identifiers::Cfi;
322    ///
323    /// let cfi = Cfi::parse("ESVUFR").unwrap();
324    /// assert_eq!(cfi.attributes(), ['V', 'U', 'F', 'R']);
325    /// ```
326    #[inline]
327    #[must_use]
328    pub fn attributes(&self) -> [char; 4] {
329        [
330            self.bytes[2] as char,
331            self.bytes[3] as char,
332            self.bytes[4] as char,
333            self.bytes[5] as char,
334        ]
335    }
336}
337
338impl FromStr for Cfi {
339    type Err = CfiError;
340
341    /// Delegates to [`Cfi::parse`], enabling `input.parse::<Cfi>()` and use in generic code bounded
342    /// by [`FromStr`].
343    fn from_str(s: &str) -> Result<Self, Self::Err> {
344        Self::parse(s)
345    }
346}
347
348impl TryFrom<&str> for Cfi {
349    type Error = CfiError;
350
351    /// Delegates to [`Cfi::parse`], enabling `Cfi::try_from(input)` and use in generic code bounded
352    /// by [`TryFrom<&str>`].
353    fn try_from(value: &str) -> Result<Self, Self::Error> {
354        Self::parse(value)
355    }
356}
357
358impl TryFrom<[u8; 6]> for Cfi {
359    type Error = CfiError;
360
361    /// Delegates to [`Cfi::from_bytes`]. The bytes must already be pre normalized uppercase ASCII
362    /// letters.
363    fn try_from(value: [u8; 6]) -> Result<Self, Self::Error> {
364        Self::from_bytes(value)
365    }
366}
367
368impl TryFrom<&[u8]> for Cfi {
369    type Error = CfiError;
370
371    /// Validates a byte slice as a CFI. The slice must be exactly 6 pre normalized uppercase ASCII
372    /// bytes; any other length yields [`CfiError::InvalidLength`]. Once the length is confirmed,
373    /// this behaves like [`Cfi::from_bytes`].
374    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
375        let bytes: [u8; 6] = value
376            .try_into()
377            .map_err(|_| CfiError::InvalidLength { found: value.len() })?;
378        Self::from_bytes(bytes)
379    }
380}
381
382impl PartialEq<str> for Cfi {
383    /// Compares against a string slice by its canonical 6 character representation.
384    fn eq(&self, other: &str) -> bool {
385        self.as_str() == other
386    }
387}
388
389impl PartialEq<&str> for Cfi {
390    /// Compares against a string slice by its canonical 6 character representation.
391    fn eq(&self, other: &&str) -> bool {
392        self.as_str() == *other
393    }
394}
395
396impl PartialEq<Cfi> for str {
397    fn eq(&self, other: &Cfi) -> bool {
398        self == other.as_str()
399    }
400}
401
402impl PartialEq<Cfi> for &str {
403    fn eq(&self, other: &Cfi) -> bool {
404        *self == other.as_str()
405    }
406}
407
408impl AsRef<[u8]> for Cfi {
409    /// Equivalent to [`Cfi::as_bytes`], borrowed as a slice.
410    fn as_ref(&self) -> &[u8] {
411        &self.bytes
412    }
413}
414
415impl AsRef<str> for Cfi {
416    /// Equivalent to [`Cfi::as_str`].
417    fn as_ref(&self) -> &str {
418        self.as_str()
419    }
420}