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}