Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

FTracker Identifiers

ftracker-identifiers is a Rust crate of small, validated value types for the identifiers used across financial and regulatory data.

Every type in this crate follows the same philosophy:

  • Parse, don’t validate. If you’re holding a value of the type, it is guaranteed correct. There is no “unchecked” variant floating around your codebase that might secretly be malformed.
  • Zero-cost. Types are small, Copy, and allocation-free. Validating and formatting an identifier should not require a heap allocation.
  • no_std-friendly. The crate builds without std by default consumers who need it, falling back to alloc only where unavoidable (see each identifier’s own chapter for specifics).
  • Additive feature flags. Integrations with serde, schemars, arbitrary, and proptest are opt-in and never change an identifier’s validation rules. Enabling a feature only adds capabilities, it never loosens or tightens what counts as “valid.”

What’s covered today

  • CNPJ — Brazil’s national registry identifier for legal entities, supporting both the legacy numeric-only format and the 2026 alphanumeric format.
  • ISIN — the ISO 6166 identifier for a fungible financial security, validated with the ISO 6166 Luhn check digit.

What’s planned

The Identifiers section of this book is organized so each identifier gets its own chapter, following the same shape: structure, parsing & validation, formatting, error handling, feature flags, and examples. CFI is next on the roadmap; see Adding a New Identifier if you’d like to help build it out.

Installation

Add the crate to your Cargo.toml, enabling only the feature flags you need:

[dependencies]
ftracker-identifiers = "0.0.1"

# Optional integrations — see each identifier's "Feature Flags" chapter for details.
# ftracker-identifiers = { version = "0.0.1", features = ["serde", "schemars"] }

Minimum supported Rust version

This crate targets the Rust version pinned in rust-toolchain.toml at the repository root. Check that file for the exact version this documentation was written against.

CNPJ

CNPJ (Cadastro Nacional da Pessoa Jurídica) is Brazil’s national registry identifier for legal entities, issued by the Receita Federal (Brazil’s federal revenue service). This crate’s Cnpj type is a validated, allocation-free representation of it.

use ftracker_identifiers::Cnpj;

let cnpj = Cnpj::parse("00.000.000/0001-91").unwrap();
assert!(cnpj.is_root());
assert_eq!(cnpj.as_str(), "00000000000191");
assert_eq!(cnpj.formatted().as_str(), "00.000.000/0001-91");

If you hold a Cnpj, it is guaranteed to satisfy every structural rule and the Módulo 11 checksum described in this chapter — there is no partially validated or “trust me” state.

In this chapter

  • Structure & Formats — the three segments of a CNPJ, and the 2026 move to alphanumeric identifiers.
  • Parsing & Validation — what Cnpj::parse accepts, and the rules every constructor enforces.
  • Formatting & Display — rendering the compact and punctuated forms without allocating.
  • Error Handling — the CnpjError variants and how to match on them.
  • Feature Flags — optional serde, schemars, arbitrary, and proptest integrations.
  • Examples — end-to-end usage, including sorting, deduplication, and use as a map/set key.

API reference

This book explains how and why to use Cnpj. For the full, generated API reference — every method signature, trait implementation, and doc-tested example — run:

cargo doc --open --all-features

Structure & Formats

A CNPJ always has 14 meaningful characters, split into three segments:

PositionsLengthSegmentMeaning
1–88Root (raiz)Identifies the entity itself; shared by the head office and every branch
9–124Branch/order (ordem)"0001" conventionally denotes the head office (matriz)
13–142Verification digitsComputed from the first 12 characters via the Módulo 11 algorithm

Cnpj exposes each segment as a borrowed accessor:

  • Cnpj::root() — the 8-character root.
  • Cnpj::branch_code() — the 4-character branch/order segment.
  • Cnpj::is_root()true when the branch/order segment is "0001".
  • Cnpj::branch_number() — the branch/order segment as a u16, when it’s purely numeric.
  • Cnpj::check_digits() — the two verification digits, as (u8, u8).

The conventional punctuated rendering is AA.AAA.AAA/AAAA-DD; the compact rendering drops all punctuation. Both refer to the same 14 characters — see Formatting & Display.

Numeric vs. alphanumeric CNPJs

The public CNPJ format changed in 2026. Historically, all 14 characters were digits. As of the 2026 change, the first 12 positions (root + branch/order) may also contain uppercase letters; the final two verification digits remain numeric either way.

This crate follows Nota Técnica Conjunta COCAD/SUARA/RFB nº 49/2024, which defines the checksum so that the legacy numeric-only calculation is unchanged — it’s simply the special case where every character happens to be a digit. Each character contributes its ASCII code minus '0' to the Módulo 11 sum:

  • Digits contribute their own value ('0' → 0, …, '9' → 9).
  • Uppercase letters contribute 17 through 42 ('A' → 17, …, 'Z' → 42).

Because of this, there is no separate “legacy” type in this crate. Cnpj represents both numeric-only and alphanumeric CNPJs uniformly, and there’s a single code path (and a single test suite) validating both.

use ftracker_identifiers::Cnpj;

// A numeric-only CNPJ (the historical format).
let numeric = Cnpj::parse("00.000.000/0001-91").unwrap();

// An alphanumeric CNPJ (the 2026 format) — same type, same validation.
let alphanumeric = Cnpj::parse("12ABC34501DE35").unwrap();
assert_eq!(alphanumeric.branch_code(), "01DE");

A note on ordering

Cnpj derives Ord directly over its underlying ASCII bytes, which matches str ordering on Cnpj::as_str(). Because ASCII digits ('0'..='9') sort before uppercase letters ('A'..='Z'), a numeric-format CNPJ always sorts before any alphanumeric CNPJ sharing the same leading digits. This is lexicographic string order, not a numeric or chronological order — don’t read a sorted list of Cnpj values as meaning “issued earlier” or “smaller root number.”

Parsing & Validation

Constructors

ConstructorAccepts
Cnpj::parse / Cnpj::newPunctuated or compact strings, any ASCII case
Cnpj::from_bytesExactly 14 pre-normalized ASCII bytes, no punctuation
FromStr / TryFrom<&str>Same as parse, for use in generic code

Cnpj::new is a plain alias for Cnpj::parse; FromStr and TryFrom<&str> both delegate to it too, so "...".parse::<Cnpj>() and Cnpj::try_from("...") behave identically to calling Cnpj::parse directly.

Cnpj::from_bytes is the lower-level constructor: it skips punctuation-stripping and case-folding and expects an already-normalized [u8; 14]. It still runs every validation rule below — it just assumes the caller has already dealt with formatting. Prefer Cnpj::parse unless you’re constructing bytes programmatically (for example, in a generator or migration script).

What Cnpj::parse accepts

  • The conventional punctuated form: AA.AAA.AAA/AAAA-DD.
  • The compact 14-character form: AAAAAAAAAAAADD.
  • Lowercase letters in the alphanumeric portion — they’re folded to uppercase automatically.
  • Extra ASCII spaces, anywhere in the string (leading, trailing, or embedded).
use ftracker_identifiers::Cnpj;

assert!(Cnpj::parse("00.000.000/0001-91").is_ok());
assert!(Cnpj::parse("00000000000191").is_ok());
assert!(Cnpj::parse(" 00.000.000/0001-91 ").is_ok());
assert!(Cnpj::parse("12abc34501de35").is_ok()); // lowercase is folded

Validation rules

The string-based constructors run the following rules, in this order:

  1. Length — after formatting is stripped, the input must contain exactly 14 meaningful characters.
  2. Character class — positions 1–12 accept a digit or an uppercase letter; positions 13–14 accept only a digit.
  3. Not degenerate — the 14 characters cannot all be identical (e.g. "00000000000000"). Such inputs are structurally well-formed, and can even satisfy the checksum for certain repeated digits, but the Receita Federal never issues them — they’re reliably placeholder or data-entry artifacts.
  4. Checksum — both verification digits must match the Módulo 11 algorithm applied to the preceding characters.

Each rule maps to exactly one CnpjError variant — see Error Handling for the full list and how to match on it.

What it doesn’t do

Cnpj::parse validates shape and checksum, not existence. It cannot tell you whether a particular CNPJ has actually been issued, is active, or belongs to the company you think it does — that requires a lookup against the Receita Federal’s own systems (or a data provider), which is out of scope for this crate.

Formatting & Display

Cnpj can render itself two ways, and neither one allocates on the heap.

Compact form

Cnpj::as_str() returns the 14-character compact form with no punctuation, e.g. "00000000000191". Internally this is a zero-cost borrow of the identifier’s own byte buffer — it never allocates and never panics, because the bytes are guaranteed to be valid ASCII by construction.

use ftracker_identifiers::Cnpj;

let cnpj = Cnpj::parse("00.000.000/0001-91").unwrap();
assert_eq!(cnpj.as_str(), "00000000000191");

If you need raw bytes instead of a &str, Cnpj::as_bytes() returns &[u8; 14] directly.

Punctuated form

Cnpj::formatted() returns a FormattedCnpj — a small, stack-allocated, Copy value that renders the conventional AA.AAA.AAA/AAAA-DD layout. It implements Display, Deref<Target = str>, and AsRef<str>, so you can pass it almost anywhere a &str is expected without an explicit conversion:

use ftracker_identifiers::Cnpj;

let cnpj = Cnpj::parse("00000000000191").unwrap();
let formatted = cnpj.formatted();

assert_eq!(formatted.as_str(), "00.000.000/0001-91");
assert_eq!(&*formatted, "00.000.000/0001-91"); // via Deref
println!("{formatted}");                        // via Display

Display and Debug on Cnpj itself

Cnpj implements Display by delegating to formatted(), so cnpj.to_string() always produces the punctuated form:

use ftracker_identifiers::Cnpj;

let cnpj = Cnpj::parse("00000000000191").unwrap();
assert_eq!(cnpj.to_string(), "00.000.000/0001-91");

Debug wraps the same punctuated form in a readable tuple-struct style, which is what you’ll see in assert_eq! failure messages, logs, and {:?} output:

Cnpj("00.000.000/0001-91")

This makes a mismatched or unexpected Cnpj easy to spot at a glance in test output or logs, without needing to manually reformat raw bytes.

Error Handling

Every fallible constructor returns CnpjError on failure. It’s Clone + PartialEq + Eq, and it implements both core::error::Error and core::fmt::Display, so it composes cleanly with ? and with error-aggregation crates (anyhow, thiserror, eyre, and friends).

Variants

VariantWhen it occurs
EmptyThe input string was empty.
InvalidLengthAfter stripping punctuation (., /, -, whitespace), the input didn’t contain exactly 14 characters.
InvalidCharacterA character outside the allowed set appeared at a given position (carries the character, 1-indexed position, and the expected character class).
InvalidCheckDigitsThe Módulo 11 checksum didn’t match one of the two verification digits (carries the position, expected digit, and found digit).
RepeatedDigitsAll 14 characters were identical (e.g. "00000000000000") — structurally valid but never actually issued.

Matching on specific failures

Reach for a match when you need to react differently to different failure modes — for example, turning a specific error into a targeted, field-level message for a form, rather than just surfacing the generic Display text:

use ftracker_identifiers::{Cnpj, CnpjError};

match Cnpj::parse(user_input) {
    Ok(cnpj) => save(cnpj),
    Err(CnpjError::Empty) => reject("CNPJ is required"),
    Err(CnpjError::InvalidLength { found }) => {
        reject(&format!("expected 14 characters, found {found}"))
    }
    Err(CnpjError::InvalidCharacter { character, position, .. }) => {
        reject(&format!("unexpected '{character}' at position {position}"))
    }
    Err(CnpjError::InvalidCheckDigits { .. }) => {
        reject("that CNPJ doesn't look right — check for typos")
    }
    Err(CnpjError::RepeatedDigits) => reject("that doesn't look like a real CNPJ"),
}

Just want a message?

If you don’t need to distinguish between failure modes, CnpjError’s Display implementation already produces a human-readable message, so ? and .to_string() work as expected:

use ftracker_identifiers::Cnpj;

fn parse_cnpj(input: &str) -> Result<Cnpj, String> {
    Cnpj::parse(input).map_err(|e| e.to_string())
}

Untrusted input is always re-validated

This matters most when the serde feature is enabled: deserializing a Cnpj from JSON, YAML, or any other serde format re-runs the exact same validation as Cnpj::parse. There is no serialization shortcut that could let an invalid value slip through — see Feature Flags.

Feature Flags

All of Cnpj’s optional integrations are off by default and purely additive — enabling one never changes what counts as a valid CNPJ, only what you can do with a Cnpj once you have one.

[dependencies]
ftracker-identifiers = { version = "0.0.1", features = ["serde", "schemars", "arbitrary", "proptest"] }

serde

(De)serializes Cnpj as its compact 14-character string (e.g. "12ABC34501DE35") — never the punctuated form, so it round-trips as a plain identifier in JSON, YAML, or config files.

Deserialization always re-runs full validation: an untrusted payload (a malformed API request, a hand-edited config file) can never produce an invalid Cnpj. A bad value fails to deserialize with a descriptive error instead of silently producing garbage.

use ftracker_identifiers::Cnpj;

let cnpj = Cnpj::parse("00.000.000/0001-91").unwrap();
let json = serde_json::to_string(&cnpj).unwrap();
assert_eq!(json, "\"00000000000191\"");

let back: Cnpj = serde_json::from_str(&json).unwrap();
assert_eq!(cnpj, back);

// Invalid input is rejected at deserialization time, not silently accepted.
assert!(serde_json::from_str::<Cnpj>("\"not-a-cnpj\"").is_err());

schemars

Implements JsonSchema for Cnpj, so it can appear in a generated OpenAPI/JSON Schema document as a pattern-constrained string rather than an opaque string type. This feature implies serde.

The generated schema:

{
  "type": "string",
  "format": "cnpj",
  "minLength": 14,
  "maxLength": 14,
  "pattern": "^[A-Z0-9]{12}[0-9]{2}$",
  "description": "Brazilian CNPJ (Cadastro Nacional da Pessoa Jurídica), unformatted, Módulo 11 checksum-valid."
}

Note that the pattern alone doesn’t capture the Módulo 11 checksum constraint — schema validators outside this crate (API gateways, other-language clients) can reject malformed shapes, but true checksum validation still requires this crate (or a reimplementation of the same algorithm; see Structure & Formats).

arbitrary

Implements Arbitrary for Cnpj, so fuzz targets (e.g. via cargo fuzz) can generate structurally valid, checksum-correct Cnpj values directly, instead of generating raw strings that mostly fail validation before reaching the code under test.

proptest

Exposes reusable proptest strategies at ftracker_identifiers::cnpj::proptest:

  • cnpj::proptest::valid_cnpj() — a Strategy<Value = Cnpj> producing checksum-correct values spanning both the numeric and alphanumeric formats.
  • cnpj::proptest::valid_cnpj_formatted_string() — the same, rendered as a punctuated String, useful for round-trip-through-formatting property tests.

This is the recommended way to property-test your own code that accepts a Cnpj, without hand-rolling a checksum-valid generator:

use ftracker_identifiers::{cnpj::proptest::valid_cnpj, Cnpj};
use proptest::proptest;

proptest! {
    #[test]
    fn my_function_accepts_any_valid_cnpj(cnpj in valid_cnpj()) {
        // exercise your own code with `cnpj` here
    }
}

Examples

Quick start

use ftracker_identifiers::Cnpj;

let numeric = Cnpj::parse("00.000.000/0001-91").unwrap();
assert!(numeric.is_root());
assert_eq!(numeric.as_str(), "00000000000191");
assert_eq!(numeric.formatted().as_str(), "00.000.000/0001-91");

let alpha = Cnpj::parse("12ABC34501DE35").unwrap();
assert_eq!(alpha.branch_code(), "01DE");
assert_eq!(alpha.branch_number(), None);

Validating untrusted input

Use Cnpj::parse right at the boundary where data enters your system — an HTTP handler, a CSV import, a CLI argument — so that everything downstream can assume a Cnpj is already valid:

use ftracker_identifiers::Cnpj;

fn handle_signup(raw_cnpj: &str) -> Result<(), String> {
    let cnpj = Cnpj::parse(raw_cnpj).map_err(|e| e.to_string())?;
    // From here on, `cnpj` is guaranteed valid — no need to re-check it.
    save_company(cnpj);
    Ok(())
}
fn save_company(_: Cnpj) {}

Sorting and deduplicating a batch

A common data-cleaning task: importing a spreadsheet or CSV export that may contain the same CNPJ written multiple ways (with or without punctuation, mixed case), and needing a deduplicated, sorted list:

use ftracker_identifiers::Cnpj;

let mut cnpjs: Vec<Cnpj> = [
    "11.222.333/0002-62",
    "00.000.000/0001-91",
    "00000000000191", // same CNPJ as above, written without punctuation
]
.into_iter()
.map(|s| Cnpj::parse(s).unwrap())
.collect();

cnpjs.sort();
cnpjs.dedup();
assert_eq!(cnpjs.len(), 2);

Using Cnpj as a map or set key

Because Cnpj implements Eq and Hash consistently with PartialEq, it works directly as a HashMap/HashSet key (or BTreeMap/BTreeSet, via Ord) — useful for deduplicating records or indexing data by company:

use ftracker_identifiers::Cnpj;
use std::collections::HashMap;

let mut companies: HashMap<Cnpj, &str> = HashMap::new();
companies.insert(Cnpj::parse("00.000.000/0001-91").unwrap(), "Banco do Brasil");

let lookup = Cnpj::parse("00000000000191").unwrap();
assert_eq!(companies.get(&lookup), Some(&"Banco do Brasil"));

Grouping branches by root

Since Cnpj::root() identifies the entity regardless of branch, it’s a natural grouping key when you have several branch records for the same company:

use ftracker_identifiers::Cnpj;
use std::collections::HashMap;

fn group_by_company(cnpjs: &[Cnpj]) -> HashMap<&str, Vec<Cnpj>> {
    let mut groups: HashMap<&str, Vec<Cnpj>> = HashMap::new();
    for &cnpj in cnpjs {
        groups.entry(cnpj.root()).or_default().push(cnpj);
    }
    groups
}

For a config-file or API round-trip example using serde, see Feature Flags.

ISIN

ISIN (International Securities Identification Number) is the ISO 6166 identifier for a fungible financial security (a stock, bond, or fund share). This crate’s Isin type is a validated, allocation-free representation of it.

use ftracker_identifiers::Isin;

let isin = Isin::parse("US0378331005").unwrap();
assert_eq!(isin.country_code(), "US");
assert_eq!(isin.nsin(), "037833100");
assert_eq!(isin.check_digit(), 5);
assert_eq!(isin.as_str(), "US0378331005");

If you hold an Isin, it is guaranteed to satisfy every structural rule and the ISO 6166 Luhn check digit described in this chapter. There is no partially validated or “trust me” state.

In this chapter

  • Structure & Formats — the three segments of an ISIN and how the check digit is derived.
  • Parsing & Validation — what Isin::parse accepts, and the rules every constructor enforces.
  • Formatting & Display — rendering the canonical form without allocating.
  • Error Handling — the IsinError variants and how to match on them.
  • Feature Flags — optional serde, schemars, arbitrary, and proptest integrations.
  • Examples — end-to-end usage, including sorting, deduplication, and use as a map/set key.

API reference

This book explains how and why to use Isin. For the full, generated API reference run:

cargo doc --open --all-features

Structure & Formats

An ISIN always has 12 characters, split into three segments:

PositionsLengthSegmentMeaning
1–22Country codeISO 3166-1 alpha-2 prefix of the issuing national numbering agency
3–119NSINNational Securities Identifying Number, alphanumeric
121Check digitLuhn (modulus 10) digit computed over the first 11 characters

Isin exposes each segment as a borrowed accessor:

  • Isin::country_code() — the 2-character country code.
  • Isin::nsin() — the 9-character National Securities Identifying Number.
  • Isin::check_digit() — the check digit, as a u8 (0..=9).
  • Isin::computed_check_digit() — recomputes the check digit from the first 11 characters (equal to check_digit() for any valid Isin).

ISIN has no conventional punctuated form. Its canonical rendering is simply the 12-character string, so there is no separate “compact vs. formatted” distinction. See Formatting & Display.

The check digit

The final character is a checksum computed with the modulus-10 “double-add-double” (Luhn) algorithm described in ISO 6166 Annex C. Each of the first 11 characters is first expanded to decimal digits:

  • Digits contribute their own value ('0' → 0, …, '9' → 9).
  • Letters contribute their two-digit ordinal value ('A' → 10, …, 'Z' → 35), i.e. two digits each.

The Luhn doubling rule is then applied to the expanded digit sequence so that the check digit, once appended, lands in the units position. Because letters expand to two digits, the country code and an alphanumeric NSIN both participate in the checksum.

use ftracker_identifiers::Isin;

// An all-numeric NSIN.
let amazon = Isin::parse("US0231351067").unwrap();

// An NSIN containing letters — same type, same validation.
let petrobras = Isin::parse("BRPETRACNOR9").unwrap();
assert_eq!(petrobras.nsin(), "PETRACNOR");
assert_eq!(petrobras.check_digit(), 9);

What the country code is (and isn’t)

The first two characters are validated structurally. They must be two uppercase ASCII letters.

This crate deliberately does not check them against the live ISO 3166-1 country registry (which changes over time and includes special allocations such as XS for international securities). Validating “is this a real, currently-assigned country code?” is out of scope for a checksum-oriented value type.

A note on ordering

Isin derives Ord directly over its underlying ASCII bytes, which matches str ordering on Isin::as_str(). This is lexicographic string order. Sorting a list of Isin values groups them by country-code prefix, but says nothing about issuance date or any numeric interpretation of the NSIN.

Parsing & Validation

Constructors

ConstructorAccepts
Isin::parse / Isin::new12-character strings, any ASCII case, trimmed
Isin::from_bytesExactly 12 pre-normalized uppercase ASCII bytes
FromStr / TryFrom<&str>Same as parse, for use in generic code

Isin::new is a plain alias for Isin::parse; FromStr and TryFrom<&str> both delegate to it too, so "...".parse::<Isin>() and Isin::try_from("...") behave identically to callingIsin::parse directly.

Isin::from_bytes is the lower-level constructor: it skips whitespace-trimming and case-folding and expects an already-normalized [u8; 12]. It still runs every validation rule below. It just assumes the caller has already dealt with formatting. Prefer Isin::parse unless you’re constructing bytes programmatically (for example, in a generator or migration script).

What Isin::parse accepts

  • The canonical 12-character form: CCNNNNNNNNND.
  • Lowercase letters — they’re folded to uppercase automatically.
  • Leading and trailing whitespace — it’s trimmed before validation.

Interior separators are not stripped: an ISIN has no conventional internal punctuation, so a character in the middle of the string that isn’t a letter or digit is reported as invalid rather than silently removed.

use ftracker_identifiers::Isin;

assert!(Isin::parse("US0378331005").is_ok());
assert!(Isin::parse("us0378331005").is_ok());   // lowercase is folded
assert!(Isin::parse("  US0378331005 ").is_ok()); // surrounding whitespace is trimmed
assert!(Isin::parse("US0378331006").is_err());   // wrong check digit

Validation rules

The string-based constructors run the following rules, in this order:

  1. Length — after surrounding whitespace is trimmed, the input must contain exactly 12 characters. (Isin::parse rejects an empty string up front.)
  2. Character class — positions 1–2 accept an uppercase letter; positions 3–11 accept a digit or an uppercase letter; position 12 accepts only a digit.
  3. Check digit — position 12 must match the ISO 6166 Luhn digit computed from the first 11 characters.

Each rule maps to exactly one IsinError variant; see Error Handling for the full list and how to match on it.

What it doesn’t do

Isin::parse validates shape and checksum, not existence. It cannot tell you whether a particular ISIN has actually been allocated, identifies a tradable instrument, or refers to the security you think it does. That requires a lookup against an issuing agency or market-data provider, which is out of scope for this crate.

It also does not validate the country code against the live ISO 3166-1 list (see Structure & Formats).

Formatting & Display

An ISIN has a single canonical rendering, the 12-character string. Rendering never allocates on the heap.

Canonical form

Isin::as_str() returns the 12-character string, e.g. "US0378331005". Internally this is a zero-cost borrow of the identifier’s own byte buffer. It never allocates and never panics, because the bytes are guaranteed to be valid ASCII by construction.

use ftracker_identifiers::Isin;

let isin = Isin::parse("us0378331005").unwrap();
assert_eq!(isin.as_str(), "US0378331005"); // normalized to uppercase

If you need raw bytes instead of a &str, Isin::as_bytes() returns &[u8; 12] directly.

Display and Debug

Isin implements Display by writing its canonical string, so isin.to_string() and {} formatting both produce the 12-character form:

use ftracker_identifiers::Isin;

let isin = Isin::parse("US0378331005").unwrap();
assert_eq!(isin.to_string(), "US0378331005");

Debug wraps the same string in a readable tuple-struct style, which is what you’ll see in assert_eq! failure messages, logs, and {:?} output:

Isin("US0378331005")

This makes a mismatched or unexpected Isin easy to spot at a glance in test output or logs, without needing to manually reformat raw bytes.

Error Handling

Every fallible constructor returns IsinError on failure. It’s Clone + PartialEq + Eq, and it implements both core::error::Error and core::fmt::Display, so it composes cleanly with ? and with error-aggregation crates (anyhow, thiserror, eyre, and friends).

Variants

VariantWhen it occurs
EmptyThe input string was empty.
InvalidLengthAfter trimming surrounding whitespace, the input didn’t contain exactly 12 characters (carries the count found).
InvalidCharacterA character outside the allowed set appeared at a given position (carries the character, 1-indexed position, and the expected character class).
InvalidCheckDigitThe ISO 6166 Luhn check digit at position 12 didn’t match (carries the expected and found digits).

The expected character class is one of Letter (positions 1–2), Alphanumeric (positions 3–11), or Digit (position 12).

Matching on specific failures

Reach for a match when you need to react differently to different failure modes. For example, turning a specific error into a targeted field-level message for a form, rather than just surfacing the generic Display text:

use ftracker_identifiers::{Isin, IsinError};

match Isin::parse(user_input) {
    Ok(isin) => save(isin),
    Err(IsinError::Empty) => reject("ISIN is required"),
    Err(IsinError::InvalidLength { found }) => {
        reject(&format!("expected 12 characters, found {found}"))
    }
    Err(IsinError::InvalidCharacter { character, position, .. }) => {
        reject(&format!("unexpected '{character}' at position {position}"))
    }
    Err(IsinError::InvalidCheckDigit { expected, found }) => {
        reject(&format!("check digit looks wrong: expected {expected}, found {found}"))
    }
}

Just want a message?

If you don’t need to distinguish between failure modes, IsinError’s Display implementation already produces a human-readable message, so ? and .to_string() work as expected:

use ftracker_identifiers::Isin;

fn parse_isin(input: &str) -> Result<Isin, String> {
    Isin::parse(input).map_err(|e| e.to_string())
}

Untrusted input is always re-validated

This matters most when the serde feature is enabled: deserializing an Isin from JSON, YAML, or any other serde format re-runs the exact same validation as Isin::parse. There is no serialization shortcut that could let an invalid value slip through; see Feature Flags.

Feature Flags

All of Isin’s optional integrations are off by default and purely additive. Enabling one never changes what counts as a valid ISIN, only what you can do with an Isin once you have one.

[dependencies]
ftracker-identifiers = { version = "0.0.1", features = ["serde", "schemars", "arbitrary", "proptest"] }

serde

(De)serializes Isin as its canonical 12-character string (e.g. "US0378331005"), so it round-trips as a plain identifier in JSON, YAML, or config files.

Deserialization always re-runs full validation: an untrusted payload (a malformed API request, a hand-edited config file) can never produce an invalid Isin. A bad value fails to deserialize with a descriptive error instead of silently producing garbage.

use ftracker_identifiers::Isin;

let isin = Isin::parse("US0378331005").unwrap();
let json = serde_json::to_string(&isin).unwrap();
assert_eq!(json, "\"US0378331005\"");

let back: Isin = serde_json::from_str(&json).unwrap();
assert_eq!(isin, back);

// Invalid input is rejected at deserialization time, not silently accepted.
assert!(serde_json::from_str::<Isin>("\"not-an-isin\"").is_err());

schemars

Implements JsonSchema for Isin, so it can appear in a generated OpenAPI/JSON Schema document as a pattern-constrained string rather than an opaque string type. This feature implies serde.

The generated schema:

{
  "type": "string",
  "format": "isin",
  "minLength": 12,
  "maxLength": 12,
  "pattern": "^[A-Z]{2}[A-Z0-9]{9}[0-9]$",
  "description": "ISIN (International Securities Identification Number, ISO 6166), Luhn checksum-valid."
}

Note that the pattern alone doesn’t capture the Luhn checksum constraint. Schema validators outside this crate (API gateways, other-language clients) can reject malformed shapes, but true checksum validation still requires this crate (or a reimplementation of the same algorithm; see Structure & Formats).

arbitrary

Implements Arbitrary for Isin, so fuzz targets (e.g. via cargo fuzz) can generate structurally valid, checksum-correct Isin values directly, instead of generating raw strings that mostly fail validation before reaching the code under test.

proptest

Exposes reusable proptest strategies at ftracker_identifiers::isin::proptest:

  • isin::proptest::valid_isin() — a Strategy<Value = Isin> producing checksum-correct values with a two-letter country code and an alphanumeric NSIN.
  • isin::proptest::valid_isin_string() — the same, rendered as a canonical String, useful for round-trip-through-parsing property tests.

This is the recommended way to property-test your own code that accepts an Isin, without hand-rolling a checksum-valid generator:

use ftracker_identifiers::{isin::proptest::valid_isin, Isin};
use proptest::proptest;

proptest! {
    #[test]
    fn my_function_accepts_any_valid_isin(isin in valid_isin()) {
        // exercise your own code with `isin` here
    }
}

Examples

Quick start

use ftracker_identifiers::Isin;

let apple = Isin::parse("US0378331005").unwrap();
assert_eq!(apple.country_code(), "US");
assert_eq!(apple.nsin(), "037833100");
assert_eq!(apple.check_digit(), 5);

let petrobras = Isin::parse("BRPETRACNOR9").unwrap();
assert_eq!(petrobras.nsin(), "PETRACNOR"); // an alphanumeric NSIN

Validating untrusted input

Use Isin::parse right at the boundary where data enters your system (HTTP handler, a CSV import, a CLI argument) so that everything downstream can assume an Isin is already valid:

use ftracker_identifiers::Isin;

fn handle_order(raw_isin: &str) -> Result<(), String> {
    let isin = Isin::parse(raw_isin).map_err(|e| e.to_string())?;
    // From here on, `isin` is guaranteed valid — no need to re-check it.
    place_order(isin);
    Ok(())
}
fn place_order(_: Isin) {}

Sorting and deduplicating a batch

A common data-cleaning task: importing a spreadsheet or CSV export that may contain the same ISIN written multiple ways (mixed case, surrounded by whitespace), and needing a deduplicated, sorted list:

use ftracker_identifiers::Isin;

let mut isins: Vec<Isin> = [
    "US0231351067",
    "US0378331005",
    " us0378331005 ", // same ISIN as above, lower-cased and padded
]
.into_iter()
.map(|s| Isin::parse(s).unwrap())
.collect();

isins.sort();
isins.dedup();
assert_eq!(isins.len(), 2);

Using Isin as a map or set key

Because Isin implements Eq and Hash consistently with PartialEq, it works directly as a HashMap/HashSet key (or BTreeMap/BTreeSet, via Ord). Useful for deduplicating records or indexing data by security:

use ftracker_identifiers::Isin;
use std::collections::HashMap;

let mut names: HashMap<Isin, &str> = HashMap::new();
names.insert(Isin::parse("US0378331005").unwrap(), "Apple Inc.");

let lookup = Isin::parse("us0378331005").unwrap();
assert_eq!(names.get(&lookup), Some(&"Apple Inc."));

Grouping securities by issuing country

Since Isin::country_code() identifies the issuing national numbering agency, it’s a natural grouping key when you have a mixed list of securities:

use ftracker_identifiers::Isin;
use std::collections::HashMap;

fn group_by_country(isins: &[Isin]) -> HashMap<&str, Vec<Isin>> {
    let mut groups: HashMap<&str, Vec<Isin>> = HashMap::new();
    for &isin in isins {
        groups.entry(isin.country_code()).or_default().push(isin);
    }
    groups
}

For a config-file or API round-trip example using serde, see Feature Flags.

CFI

CFI (Classification of Financial Instruments) is the ISO 10962 six-letter code that classifies a financial instrument by category, group, and four attributes. This crate’s Cfi type is a validated, allocation-free representation of it.

use ftracker_identifiers::Cfi;

let cfi = Cfi::parse("ESVUFR").unwrap();
assert_eq!(cfi.category(), 'E');
assert_eq!(cfi.group(), 'S');
assert_eq!(cfi.attributes(), ['V', 'U', 'F', 'R']);
assert_eq!(cfi.as_str(), "ESVUFR");

If you hold a Cfi, it is guaranteed to describe a category, group, and attribute combination that ISO 10962 actually defines. There is no partially validated or “trust me” state.

Taxonomy, not checksum

A CFI has no check digit. A CFI is valid exactly when its letters name a combination the standard defines, so this crate embeds the ISO 10962 code taxonomy as a generated, no_std lookup table and validates against it. Only the classification codes are embedded — not ISO’s descriptive text — so Cfitells you whether a code is well-formed, not what each letter means.

In this chapter

  • Structure & Formats — the six positions of a CFI and how they nest.
  • Parsing & Validation — what Cfi::parse accepts, and the rules every constructor enforces.
  • Formatting & Display — rendering the canonical form without allocating.
  • Error Handling — the CfiError variants and how to match on them.
  • Feature Flags — optional serde, schemars, arbitrary, andproptest integrations.
  • Examples — end-to-end usage, including sorting, deduplication, and use as a map/set key.

API reference

This book explains how and why to use Cfi. For the full, generated API reference run:

cargo doc --open --all-features

Structure & Formats

A CFI always has 6 characters, all uppercase letters, split into three parts:

PositionsLengthSegmentMeaning
11CategoryThe broadest class of instrument (e.g. E = equities)
21GroupA subdivision within the category (meaning depends on the category)
3–64AttributesFour attribute codes whose meaning depends on the category and group

Cfi exposes each part as an accessor:

  • Cfi::category() — the category code, as a char.
  • Cfi::group() — the group code, as a char.
  • Cfi::attributes() — the four attribute codes, as a [char; 4].
  • Cfi::as_str() — the whole 6-character code.

CFI has no conventional punctuated form. Its canonical rendering is simply the 6-character string, so there is no separate “compact vs. formatted” distinction. See Formatting & Display.

How the positions nest

The six positions are not independent: each narrows the meaning of the ones after it.

  • Position 1 (category) must be one of the categories ISO 10962 defines (E, C, D, R, O, F, S, H, I, J, K, L, T, M).
  • Position 2 (group) must be a group defined for that category. The same letter can be a valid group under one category and meaningless under another.
  • Positions 3–6 (attributes) must each be a code the standard permits for that specific category and group at that attribute position. The letter X conventionally marks an attribute that is “not applicable” — but only where the standard actually lists it.
use ftracker_identifiers::Cfi;

// Equity (E) / common share (S), with four attribute codes.
let equity = Cfi::parse("ESVUFR").unwrap();
assert_eq!(equity.category(), 'E');
assert_eq!(equity.group(), 'S');

// Debt (D) / bond (B) — a different category with different valid attributes.
let bond = Cfi::parse("DBFTFB").unwrap();
assert_eq!(bond.category(), 'D');
assert_eq!(bond.attributes(), ['F', 'T', 'F', 'B']);

Codes, not meanings

This crate embeds only the ISO 10962 code structure — which category, group, and attribute letters are valid, and in which combinations. It does not embed ISO’s descriptive text (the prose names and definitions of each code), which is the copyrighted part of the standard.

As a result, Cfi can confirm that ESVUFR is a well-formed equity classification and pinpoint exactly which position is wrong in a bad code, but it will not resolve V to “voting” or S to“common/ordinary shares“. If you need the human-readable meanings, consult the ISO 10962 standard itself.

A note on ordering

Cfi derives Ord directly over its underlying ASCII bytes, which matches str ordering on Cfi::as_str(). This is lexicographic string order: sorting groups CFIs by category letter, then group, and so on, but carries no taxonomic meaning beyond that.

Parsing & Validation

Constructors

ConstructorAccepts
Cfi::parse / Cfi::new6-character strings, any ASCII case, trimmed
Cfi::from_bytesExactly 6 pre-normalized uppercase ASCII bytes
FromStr / TryFrom<&str>Same as parse, for use in generic code

Cfi::new is a plain alias for Cfi::parse; FromStr and TryFrom<&str> both delegate to it too, so "...".parse::<Cfi>() and Cfi::try_from("...") behave identically to calling Cfi::parse directly.

Cfi::from_bytes is the lower-level constructor: it skips whitespace-trimming and case-folding and expects an already-normalized [u8; 6]. It still runs every validation rule below. PreferCfi::parse unless you’re constructing bytes programmatically (for example, in a generator or migration script).

What Cfi::parse accepts

  • The canonical 6-character form.
  • Lowercase letters — they’re folded to uppercase automatically.
  • Leading and trailing whitespace — it’s trimmed before validation.

Interior separators are not stripped: a CFI has no conventional internal punctuation, so a character in the middle of the string that isn’t a letter is reported as invalid rather than silently removed.

use ftracker_identifiers::Cfi;

assert!(Cfi::parse("ESVUFR").is_ok());
assert!(Cfi::parse("esvufr").is_ok());    // lowercase is folded
assert!(Cfi::parse("  ESVUFR ").is_ok()); // surrounding whitespace is trimmed
assert!(Cfi::parse("EZVUFR").is_err());   // 'Z' is not a group of category 'E'

Validation rules

The string-based constructors run the following rules, in this order:

  1. Length — after surrounding whitespace is trimmed, the input must contain exactly 6 characters. (Cfi::parse rejects an empty string up front.)
  2. Character class — every position must be an uppercase ASCII letter (AZ).
  3. Category — position 1 must be a category defined by ISO 10962.
  4. Group — position 2 must be a group defined for that category.
  5. Attributes — each of positions 3–6 must be a code the standard permits for the resolved category and group at that attribute position.

Rules 3–5 are checked against the embedded taxonomy table with a couple of allocation-free binary searches and bitmask tests — no heap, no runtime initialization. Each rule maps to exactly one CfiError variant; see Error Handling for the full list and how to match on it.

What it doesn’t do

Cfi::parse validates shape and taxonomy, not meaning or existence. It cannot tell you what a code classifies in human-readable terms (the descriptive names are not embedded; see Structure & Formats), nor whether a particular instrument has been assigned that classification by an issuer or numbering agency.

Formatting & Display

A CFI has a single canonical rendering, the 6-character string. Rendering never allocates on the heap.

Canonical form

Cfi::as_str() returns the 6-character string, e.g. "ESVUFR". Internally this is a zero-cost borrow of the identifier’s own byte buffer. It never allocates and never panics, because the bytes are guaranteed to be valid ASCII by construction.

use ftracker_identifiers::Cfi;

let cfi = Cfi::parse("esvufr").unwrap();
assert_eq!(cfi.as_str(), "ESVUFR"); // normalized to uppercase

If you need raw bytes instead of a &str, Cfi::as_bytes() returns &[u8; 6] directly.

Display and Debug

Cfi implements Display by writing its canonical string, so cfi.to_string() and {} formatting both produce the 6-character form:

use ftracker_identifiers::Cfi;

let cfi = Cfi::parse("ESVUFR").unwrap();
assert_eq!(cfi.to_string(), "ESVUFR");

Debug wraps the same string in a readable tuple-struct style, which is what you’ll see in assert_eq! failure messages, logs, and {:?} output:

Cfi("ESVUFR")

This makes a mismatched or unexpected Cfi easy to spot at a glance in test output or logs, without needing to manually reformat raw bytes.

Error Handling

Every fallible constructor returns CfiError on failure. It’s Clone + PartialEq + Eq, and it implements both core::error::Error and core::fmt::Display, so it composes cleanly with ? and with error-aggregation crates (anyhow, thiserror, eyre, and friends).

Variants

VariantWhen it occurs
EmptyThe input string was empty.
InvalidLengthAfter trimming surrounding whitespace, the input didn’t contain exactly 6 characters (carries the count found).
InvalidCharacterA non-letter appeared at a given position (carries the character and 1-indexed position).
UnknownCategoryPosition 1 is not a category defined by ISO 10962 (carries the offending code).
UnknownGroupPosition 2 is not a group of the resolved category (carries the category and offending group code).
InvalidAttributeAn attribute (positions 3–6) is not permitted for the resolved category and group (carries category, group, index, code).

The three taxonomic variants — UnknownCategory, UnknownGroup, InvalidAttribute — are what set CFI apart from the checksum-based identifiers: they tell you which level of the ISO 10962 hierarchy the code fell off, and at InvalidAttribute the index field (1–4) says which of the four attributes was wrong.

Matching on specific failures

Reach for a match when you need to react differently to different failure modes — for example, turning a specific error into a targeted field-level message for a form:

use ftracker_identifiers::{Cfi, CfiError};

match Cfi::parse(user_input) {
    Ok(cfi) => save(cfi),
    Err(CfiError::Empty) => reject("CFI is required"),
    Err(CfiError::InvalidLength { found }) => {
        reject(&format!("expected 6 characters, found {found}"))
    }
    Err(CfiError::UnknownCategory { code }) => {
        reject(&format!("'{code}' is not a known CFI category"))
    }
    Err(CfiError::UnknownGroup { category, code }) => {
        reject(&format!("'{code}' is not a group of category '{category}'"))
    }
    Err(CfiError::InvalidAttribute { index, code, .. }) => {
        reject(&format!("attribute {index} ('{code}') is not valid here"))
    }
    Err(CfiError::InvalidCharacter { character, position }) => {
        reject(&format!("unexpected '{character}' at position {position}"))
    }
}

Just want a message?

If you don’t need to distinguish between failure modes, CfiError’s Display implementation already produces a human-readable message, so ? and .to_string() work as expected:

use ftracker_identifiers::Cfi;

fn parse_cfi(input: &str) -> Result<Cfi, String> {
    Cfi::parse(input).map_err(|e| e.to_string())
}

Untrusted input is always re-validated

This matters most when the serde feature is enabled: deserializing a Cfi from JSON, YAML, or any other serde format re-runs the exact same validation — including the taxonomy checks — as Cfi::parse. There is no serialization shortcut that could let an invalid value slip through; see Feature Flags.

Feature Flags

All of Cfi’s optional integrations are off by default and purely additive. Enabling one never changes what counts as a valid CFI, only what you can do with a Cfi once you have one.

[dependencies]
ftracker-identifiers = { version = "0.0.1", features = ["serde", "schemars", "arbitrary", "proptest"] }

serde

(De)serializes Cfi as its canonical 6-character string (e.g. "ESVUFR"), so it round-trips as a plain identifier in JSON, YAML, or config files.

Deserialization always re-runs full validation, including the taxonomy checks: an untrusted payload (a malformed API request, a hand-edited config file) can never produce an invalid Cfi. A bad value fails to deserialize with a descriptive error instead of silently producing garbage.

use ftracker_identifiers::Cfi;

let cfi = Cfi::parse("ESVUFR").unwrap();
let json = serde_json::to_string(&cfi).unwrap();
assert_eq!(json, "\"ESVUFR\"");

let back: Cfi = serde_json::from_str(&json).unwrap();
assert_eq!(cfi, back);

// Invalid input is rejected at deserialization time, not silently accepted.
assert!(serde_json::from_str::<Cfi>("\"not-a-cfi\"").is_err());

schemars

Implements JsonSchema for Cfi, so it can appear in a generated OpenAPI/JSON Schema document as a pattern-constrained string rather than an opaque string type. This feature implies serde.

The generated schema:

{
  "type": "string",
  "format": "cfi",
  "minLength": 6,
  "maxLength": 6,
  "pattern": "^[A-Z]{6}$",
  "description": "CFI (Classification of Financial Instruments, ISO 10962). The pattern is structural; taxonomic validity is enforced on deserialization."
}

The pattern only captures the six-uppercase-letter shape; a regex cannot express which category/group/attribute combinations ISO 10962 defines. Schema validators outside this crate can reject malformed shapes, but true taxonomic validation still requires this crate.

arbitrary

Implements Arbitrary for Cfi, so fuzz targets (e.g. via cargo fuzz) can generate taxonomically valid Cfi values directly — by walking the embedded ISO 10962 table — instead of generating raw strings that mostly fail validation before reaching the code under test.

proptest

Exposes reusable proptest strategies at ftracker_identifiers::cfi::proptest:

  • cfi::proptest::valid_cfi() — a Strategy<Value = Cfi> producing valid values by choosing a category, a group within it, and a permitted letter for each attribute.
  • cfi::proptest::valid_cfi_string() — the same, rendered as a canonical String, useful for round-trip-through-parsing property tests.

This is the recommended way to property-test your own code that accepts a Cfi, without hand-rolling a taxonomy-aware generator:

use ftracker_identifiers::{cfi::proptest::valid_cfi, Cfi};
use proptest::proptest;

proptest! {
    #[test]
    fn my_function_accepts_any_valid_cfi(cfi in valid_cfi()) {
        // exercise your own code with `cfi` here
    }
}

Examples

Quick start

use ftracker_identifiers::Cfi;

let equity = Cfi::parse("ESVUFR").unwrap();
assert_eq!(equity.category(), 'E');
assert_eq!(equity.group(), 'S');
assert_eq!(equity.attributes(), ['V', 'U', 'F', 'R']);

let bond = Cfi::parse("DBFTFB").unwrap();
assert_eq!(bond.category(), 'D'); // debt instrument

Validating untrusted input

Use Cfi::parse right at the boundary where data enters your system (HTTP handler, a CSV import, a CLI argument) so that everything downstream can assume a Cfi is already valid:

use ftracker_identifiers::Cfi;

fn classify(raw_cfi: &str) -> Result<(), String> {
    let cfi = Cfi::parse(raw_cfi).map_err(|e| e.to_string())?;
    // From here on, `cfi` is guaranteed to be a valid ISO 10962 classification.
    record(cfi);
    Ok(())
}
fn record(_: Cfi) {}

Sorting and deduplicating a batch

A common data-cleaning task: importing a spreadsheet or CSV export that may contain the same CFI written multiple ways ( mixed case, surrounded by whitespace), and needing a deduplicated, sorted list:

use ftracker_identifiers::Cfi;

let mut cfis: Vec<Cfi> = [
    "DBFTFB",
    "ESVUFR",
    " esvufr ", // same CFI as above, lower-cased and padded
]
.into_iter()
.map(|s| Cfi::parse(s).unwrap())
.collect();

cfis.sort();
cfis.dedup();
assert_eq!(cfis.len(), 2);

Using Cfi as a map or set key

Because Cfi implements Eq and Hash consistently with PartialEq, it works directly as a HashMap/HashSet key (or BTreeMap/BTreeSet, via Ord). Useful for counting instruments by classification:

use ftracker_identifiers::Cfi;
use std::collections::HashMap;

fn count_by_classification(cfis: &[Cfi]) -> HashMap<Cfi, usize> {
    let mut counts: HashMap<Cfi, usize> = HashMap::new();
    for &cfi in cfis {
        *counts.entry(cfi).or_default() += 1;
    }
    counts
}

Grouping instruments by category

Since Cfi::category() is the broadest classification level, it’s a natural grouping key when you have a mixed list of instruments:

use ftracker_identifiers::Cfi;
use std::collections::HashMap;

fn group_by_category(cfis: &[Cfi]) -> HashMap<char, Vec<Cfi>> {
    let mut groups: HashMap<char, Vec<Cfi>> = HashMap::new();
    for &cfi in cfis {
        groups.entry(cfi.category()).or_default().push(cfi);
    }
    groups
}

For a config-file or API round-trip example using serde, see Feature Flags.

Country Code

Country Code is the ISO 3166-1 alpha-2 two letter code that identifies a country, dependent territory, or special area of geographical interest. This crate’s CountryCode type is a validated, allocation free representation of it.

use ftracker_identifiers::CountryCode;

let code = CountryCode::parse("US").unwrap();
assert_eq!(code.as_str(), "US");
assert_eq!(code.as_bytes(), b"US");

If you hold a CountryCode, it is guaranteed to be a code that ISO 3166-1 officially assigns. There is no partially validated or “trust me” state.

Membership, not checksum

A country code has no check digit. It is valid exactly when its two letters name a code that the standard officially assigns, so this crate embeds that set as a compile time bitmap and tests membership against it. Only the codes are embedded. The country name, the alpha-3 code, and the numeric code are all out of scope, so CountryCode tells you whether a code is assigned, not what country it names.

In this chapter

  • Structure & Formats: the two letters of a country code and what is (and is not) modeled.
  • Parsing & Validation: what CountryCode::parse accepts, and the rules every constructor enforces.
  • Formatting & Display: rendering the canonical form without allocating.
  • Error Handling: the CountryCodeError variants and how to match on them.
  • Feature Flags: optional serde, schemars, arbitrary, and proptest integrations.
  • Examples: end to end usage, including sorting, deduplication, and use as a map or set key.

API reference

This book explains how and why to use CountryCode. For the full, generated API reference run:

cargo doc --open --all-features

Structure & Formats

A country code always has two characters, both uppercase letters.

CountryCode exposes the value through two accessors:

  • CountryCode::as_str(): the two character code, as a &str.
  • CountryCode::as_bytes(): the two raw bytes, as a &[u8; 2].

A country code has no punctuated form. Its canonical rendering is simply the two character string, so there is no separate “compact vs. formatted” distinction. See Formatting & Display.

What is modeled

This crate models exactly one thing: the officially assigned ISO 3166-1 alpha-2 code.

  • Both letters together must name a code that the standard officially assigns. The set of assigned codes is embedded as a compile time bitmap.
  • Reserved codes such as EU and UK are not assigned, so they are rejected.
  • User assigned ranges (for example AA, QM to QZ, XA to XZ, and ZZ) are not assigned, so they are rejected.
  • Codes that were once assigned and later withdrawn are rejected.
use ftracker_identifiers::CountryCode;

let usa = CountryCode::parse("US").unwrap();
assert_eq!(usa.as_str(), "US");

let brazil = CountryCode::parse("BR").unwrap();
assert_eq!(brazil.as_bytes(), b"BR");

Codes, not names

This crate embeds only the set of assigned codes. It does not embed country names, the alpha-3 code, or the numeric code.

As a result, CountryCode can confirm that US is an assigned code and reject ZZ as unassigned, but it will not resolve US to “United States of America”. If you need the name or the other code forms, consult the ISO 3166-1 standard itself.

A note on ordering

CountryCode derives Ord directly over its underlying ASCII bytes, which matches str ordering on CountryCode::as_str(). This is lexicographic string order. Sorting arranges codes alphabetically and carries no geographic meaning beyond that.

Parsing & Validation

Constructors

  • CountryCode::parse and CountryCode::new accept two character strings, in any ASCII case, trimmed of surrounding whitespace.
  • CountryCode::from_bytes accepts exactly two pre normalized uppercase ASCII bytes.
  • FromStr and TryFrom<&str> behave like parse, for use in generic code.

CountryCode::new is a plain alias for CountryCode::parse. FromStr and TryFrom<&str> both delegate to it too, so "...".parse::<CountryCode>() and CountryCode::try_from("...") behave identically to calling CountryCode::parse directly.

CountryCode::from_bytes is the lower level constructor. It skips whitespace trimming and case folding and expects an already normalized [u8; 2]. It still runs every validation rule below. Prefer CountryCode::parse unless you are constructing bytes programmatically (for example, in a generator or a migration script).

What CountryCode::parse accepts

  • The canonical two character form.
  • Lowercase letters. They are folded to uppercase automatically.
  • Leading and trailing whitespace. It is trimmed before validation.

Interior separators are not stripped. A country code has no internal punctuation, so a character that is not a letter is reported as invalid rather than silently removed.

use ftracker_identifiers::CountryCode;

assert!(CountryCode::parse("US").is_ok());
assert!(CountryCode::parse("us").is_ok());    // lowercase is folded
assert!(CountryCode::parse("  BR ").is_ok()); // surrounding whitespace is trimmed
assert!(CountryCode::parse("ZZ").is_err());   // well formed but not assigned

Validation rules

The string based constructors run the following rules, in this order:

  1. Length: after surrounding whitespace is trimmed, the input must contain exactly two characters. (CountryCode::parse rejects an empty string first.)
  2. Character class: both positions must be an uppercase ASCII letter (A to Z).
  3. Assignment: the two letters together must be a code that ISO 3166-1 officially assigns.

Rule 3 is checked against the embedded bitmap with a single array index and a single bit test. There is no heap allocation and no runtime initialization. Each rule maps to exactly one CountryCodeError variant. See Error Handling for the full list and how to match on it.

What it doesn’t do

CountryCode::parse validates shape and membership, not meaning. It cannot tell you which country a code names (the names are not embedded, see Structure & Formats), and it does not map between the alpha-2 code and the alpha-3 or numeric forms.

Formatting & Display

A country code has a single canonical rendering, the two character string. Rendering never allocates on the heap.

Canonical form

CountryCode::as_str() returns the two character string, for example "US". Internally this is a zero cost borrow of the value’s own byte buffer. It never allocates and never panics, because the bytes are guaranteed to be valid ASCII by construction.

use ftracker_identifiers::CountryCode;

let code = CountryCode::parse("us").unwrap();
assert_eq!(code.as_str(), "US"); // normalized to uppercase

If you need raw bytes instead of a &str, CountryCode::as_bytes() returns &[u8; 2] directly.

Display and Debug

CountryCode implements Display by writing its canonical string, so code.to_string() and {} formatting both produce the two character form:

use ftracker_identifiers::CountryCode;

let code = CountryCode::parse("US").unwrap();
assert_eq!(code.to_string(), "US");

Debug wraps the same string in a readable tuple struct style, which is what you will see in assert_eq! failure messages, logs, and {:?} output:

CountryCode("US")

This makes a mismatched or unexpected value easy to spot at a glance in test output or logs, without needing to manually reformat raw bytes.

Error Handling

Every fallible constructor returns CountryCodeError on failure. It is Clone + PartialEq + Eq, and it implements both core::error::Error and core::fmt::Display, so it composes cleanly with ? and with error aggregation crates (anyhow, thiserror, eyre, and friends).

Variants

  • Empty: the input string was empty.
  • InvalidLength: after trimming surrounding whitespace, the input did not contain exactly two characters. It carries the count found.
  • InvalidCharacter: a non letter appeared at a given position. It carries the character and its 1 indexed position.
  • Unassigned: the two letters are well formed but are not a code that ISO 3166-1 officially assigns. It carries the offending code as two characters.

Unassigned is the variant that sets a country code apart from a purely structural check. The two letters look right, but the pair is not in the assigned set. Reserved codes such as EU and UK surface here too, because they are reserved rather than assigned.

Matching on specific failures

Reach for a match when you need to react differently to different failure modes, for example turning a specific error into a targeted field level message for a form:

use ftracker_identifiers::{CountryCode, CountryCodeError};

match CountryCode::parse(user_input) {
    Ok(code) => save(code),
    Err(CountryCodeError::Empty) => reject("country code is required"),
    Err(CountryCodeError::InvalidLength { found }) => {
        reject(&format!("expected 2 characters, found {found}"))
    }
    Err(CountryCodeError::InvalidCharacter { character, position }) => {
        reject(&format!("unexpected '{character}' at position {position}"))
    }
    Err(CountryCodeError::Unassigned { code }) => {
        reject(&format!("'{}{}' is not an assigned country code", code[0], code[1]))
    }
    Err(_) => reject("country code is not valid"),
}

The final arm handles any variant added in a future release: CountryCodeError is marked non exhaustive, so downstream match expressions include a catch all.

Just want a message?

If you do not need to distinguish between failure modes, the Display implementation already produces a human readable message, so ? and .to_string() work as expected:

use ftracker_identifiers::CountryCode;

fn parse_code(input: &str) -> Result<CountryCode, String> {
    CountryCode::parse(input).map_err(|e| e.to_string())
}

Untrusted input is always re-validated

This matters most when the serde feature is enabled: deserializing a CountryCode from JSON, YAML, or any other serde format re-runs the exact same validation, including the membership check, as CountryCode::parse. There is no serialization shortcut that could let an invalid value slip through. See Feature Flags.

Feature Flags

All of CountryCode’s optional integrations are off by default and purely additive. Enabling one never changes what counts as a valid country code, only what you can do with a CountryCode once you have one.

[dependencies]
ftracker-identifiers = { version = "0.0.1", features = ["serde", "schemars", "arbitrary", "proptest"] }

serde

(De)serializes CountryCode as its canonical two letter string (for example "US"), so it round trips as a plain identifier in JSON, YAML, or config files.

Deserialization always re-runs full validation, including the membership check. An untrusted payload (a malformed API request, a hand edited config file) can never produce an invalid CountryCode. A bad value fails to deserialize with a descriptive error instead of silently producing garbage.

use ftracker_identifiers::CountryCode;

let code = CountryCode::parse("US").unwrap();
let json = serde_json::to_string(&code).unwrap();
assert_eq!(json, "\"US\"");

let back: CountryCode = serde_json::from_str(&json).unwrap();
assert_eq!(code, back);

// Invalid input is rejected at deserialization time, not silently accepted.
assert!(serde_json::from_str::<CountryCode>("\"ZZ\"").is_err());

schemars

Implements JsonSchema for CountryCode, so it can appear in a generated OpenAPI or JSON Schema document as a pattern constrained string rather than an opaque string type. This feature implies serde.

The generated schema:

{
  "type": "string",
  "format": "iso3166-1-alpha2",
  "minLength": 2,
  "maxLength": 2,
  "pattern": "^[A-Z]{2}$",
  "description": "ISO 3166-1 alpha-2 country code. The pattern is structural; membership in the assigned set is enforced on deserialization."
}

The pattern only captures the two uppercase letter shape. A regex cannot express which two letter codes are assigned. Schema validators outside this crate can reject malformed shapes, but true membership validation still requires this crate.

arbitrary

Implements Arbitrary for CountryCode, so fuzz targets (for example via cargo fuzz) can generate assigned codes directly, by choosing from the embedded set, instead of generating raw strings that mostly fail validation before reaching the code under test.

proptest

Exposes reusable proptest strategies at ftracker_identifiers::country::proptest:

  • country::proptest::valid_country_code(): a Strategy<Value = CountryCode> producing valid values by choosing from the assigned set.
  • country::proptest::valid_country_code_string(): the same, rendered as a canonical String, useful for round trip through parsing property tests.

This is the recommended way to property test your own code that accepts a CountryCode, without hand rolling a generator that only yields assigned codes:

use ftracker_identifiers::{country::proptest::valid_country_code, CountryCode};
use proptest::proptest;

proptest! {
    #[test]
    fn my_function_accepts_any_valid_country_code(code in valid_country_code()) {
        // exercise your own code with `code` here
    }
}

Examples

Quick start

use ftracker_identifiers::CountryCode;

let usa = CountryCode::parse("US").unwrap();
assert_eq!(usa.as_str(), "US");

let brazil = CountryCode::parse("br").unwrap(); // lowercase is folded
assert_eq!(brazil.as_str(), "BR");

Validating untrusted input

Use CountryCode::parse right at the boundary where data enters your system (an HTTP handler, a CSV import, a CLI argument) so that everything downstream can assume a CountryCode is already valid:

use ftracker_identifiers::CountryCode;

fn register(raw_code: &str) -> Result<(), String> {
    let code = CountryCode::parse(raw_code).map_err(|e| e.to_string())?;
    // From here on, `code` is guaranteed to be an assigned ISO 3166-1 alpha-2 code.
    record(code);
    Ok(())
}
fn record(_: CountryCode) {}

Sorting and deduplicating a batch

A common data cleaning task: importing a spreadsheet or CSV export that may contain the same code written multiple ways (mixed case, surrounded by whitespace), and needing a deduplicated, sorted list:

use ftracker_identifiers::CountryCode;

let mut codes: Vec<CountryCode> = [
    "US",
    "BR",
    " us ", // same code as above, lower cased and padded
]
.into_iter()
.map(|s| CountryCode::parse(s).unwrap())
.collect();

codes.sort();
codes.dedup();
assert_eq!(codes.len(), 2);

Using CountryCode as a map or set key

Because CountryCode implements Eq and Hash consistently with PartialEq, it works directly as a HashMap or HashSet key (or BTreeMap or BTreeSet, via Ord). Useful for counting records by country:

use ftracker_identifiers::CountryCode;
use std::collections::HashMap;

fn count_by_country(codes: &[CountryCode]) -> HashMap<CountryCode, usize> {
    let mut counts: HashMap<CountryCode, usize> = HashMap::new();
    for &code in codes {
        *counts.entry(code).or_default() += 1;
    }
    counts
}

For a config file or API round trip example using serde, see Feature Flags.

Adding a New Identifier

This crate is organized so that every identifier type — CNPJ today, ISIN and CFI planned — follows the same shape, both in src/ and in this book. Sticking to the pattern keeps the crate predictable to use and cheap to review.

Source layout

Each identifier gets its own module directory, named after the identifier in lowercase (mirror src/cnpj/):

src/
  <identifier>.rs             # public type, module docs, inherent methods
  <identifier>/
    error.rs                  # the <Identifier>Error enum + Display
    fmt.rs                    # Display/Debug + any zero-allocation formatted-string helper
    parser.rs                 # string -> normalized candidate (formatting/case only)
    validation.rs              # normalized candidate -> Result<(), Error> (structural + checksum rules)
    serde.rs                   # #[cfg(feature = "serde")]
    schema.rs                  # #[cfg(feature = "schemars")]
    arbitrary.rs                # #[cfg(feature = "arbitrary")]
    proptest.rs                 # #[cfg(any(test, feature = "proptest"))], pub
    tests.rs                    # #[cfg(test)]

The parser vs. validation split matters: parser only knows about formatting (stripping punctuation, folding case) and has no opinion on which characters are valid where; validation only knows about rules (character class, checksum, degenerate input) and has no opinion on how the original string was formatted. This keeps each half testable independently and makes it obvious where a new rule belongs.

Feature flags

Follow the same Cargo.toml convention as CNPJ — every integration is optional and additive:

[features]
serde = ["dep:serde"]
schemars = ["dep:schemars", "serde"]
arbitrary = ["dep:arbitrary"]
proptest = ["dep:proptest"]

A new identifier should gate its serde/schemars/arbitrary/proptest modules behind the same feature names as CNPJ (not per-identifier features like cnpj-serde), so enabling serde once turns it on for every identifier type in the crate.

lib.rs

Re-export the new public type (and its error type, and any formatted-string helper type) at the crate root, the same way CNPJ does:

mod isin;
pub use isin::{Isin, IsinError, FormattedIsin};

Forgetting this step means the error type technically exists but can never be named by downstream code — the same gap this documentation pass caught and fixed for Cnpj.

Documentation checklist

For the module itself (src/<identifier>.rs):

  • Module-level doc comment covering: what the identifier represents, its segment structure (a table works well), format history/variants if any, the full list of validation rules mapped to error variants, relevant design notes ( allocation, Copy, ordering/hashing semantics), feature flags, and at least one runnable example.
  • Every public method has a doc comment with a runnable # Examples section.
  • Every public trait impl (FromStr, TryFrom, AsRef, …) has a one-line doc comment on the implementing method.
  • # Errors sections on every fallible public function.

For this book (docs/src/identifiers/<identifier>/):

  • README.md — landing page: what the identifier is, a minimal example, a table of contents for the rest of the chapter.
  • format.md — structure and any format history.
  • parsing-and-validation.md — constructors and validation rules.
  • formatting-and-display.md — rendering, Display/Debug.
  • error-handling.md — error variant table and a matching example.
  • feature-flags.md — one section per optional integration.
  • examples.md — end-to-end usage beyond the quick start.

Code blocks in this book use ```rust,ignore rather than ```rust, since mdbook test isn’t currently wired up to link against the compiled crate. The examples themselves should still mirror real, compiler-verified doctests from the source — copy them from src/<identifier>.rs rather than writing new, untested snippets from scratch.

Wiring it into SUMMARY.md

Replace the draft placeholder for your identifier with a real link, and add the same sub-chapters CNPJ has:

- [ISIN](./identifiers/isin/README.md)
    - [Structure & Formats](./identifiers/isin/format.md)
    - [Parsing & Validation](./identifiers/isin/parsing-and-validation.md)
    - [Formatting & Display](./identifiers/isin/formatting-and-display.md)
    - [Error Handling](./identifiers/isin/error-handling.md)
    - [Feature Flags](./identifiers/isin/feature-flags.md)
    - [Examples](./identifiers/isin/examples.md)

A draft entry (- [ISIN](), with no link target) renders as a disabled item in the sidebar and requires no file to exist yet — that’s how ISIN and CFI are currently listed. Only replace it once the chapter files actually exist; a link to a missing file breaks the mdbook build.

Fuzzing

Every identifier in this crate is a small value object built by parsing untrusted text, and each one exposes its canonical string through an unchecked byte to string conversion (from_utf8_unchecked). That makes the parsers a natural place for coverage guided fuzzing: it explores far more shapes of malformed input than hand written tests, and it proves the unchecked conversion can never observe invalid UTF-8.

The fuzz targets live in the fuzz/ directory as a standalone crate. They reuse the Arbitrary implementations (the arbitrary feature) to generate valid values, and the serde feature to check serialization round trips.

Prerequisites

Fuzzing uses cargo-fuzz, which builds with libFuzzer and requires a nightly toolchain. The library itself stays on stable; only the fuzzer runtime needs nightly.

cargo install cargo-fuzz
rustup toolchain install nightly

Targets

There is one target per identifier:

  • country_code
  • cfi
  • isin
  • cnpj

How each target drives the code

To reach the deep logic instead of bouncing off the length and character gates, every target feeds the same input through four lenses:

  1. As arbitrary UTF-8 text handed to parse.
  2. As raw bytes handed to from_bytes.
  3. As a structure aware candidate: a string built to the exact canonical length and character class (for example two letters plus nine alphanumerics plus a digit for an ISIN), so the checksum or taxonomy logic runs on near valid input on almost every execution.
  4. As an Arbitrary generated value, which exercises the acceptance path directly.

A committed dictionary per target (fuzz/dict/<target>.dict) supplies tokens (category letters, sample codes, boundary punctuation) that steer the mutator toward interesting inputs.

Invariants every accepted value must satisfy

  • Soundness: as_str() is valid UTF-8 equal to as_bytes() (this guards the from_utf8_unchecked).
  • Shape: the canonical form has the fixed length, and every byte is in the allowed class for its position.
  • Constructor agreement: parse, new, from_bytes, FromStr, and every TryFrom impl (&str, [u8; N], &[u8]) all return the same value, and parsing the canonical form is idempotent.
  • Accessor consistency: segment accessors match the canonical string. For an ISIN the stored check digit equals the recomputed one, and country() agrees with CountryCode::parse of the prefix (it is None for structurally valid but ISO 3166-1 unassigned prefixes such as XS). For a CNPJ is_root(), branch_number(), and the punctuated formatted() form stay consistent with the branch segment and Display.
  • Trait conversions: PartialEq<str>/PartialEq<&str> and AsRef<str>/AsRef<[u8]> agree with the canonical string and bytes.
  • serde: the value round trips through its JSON string.
  • Rendering: Display and Debug never panic. Formatting a rejected value’s error never panics, and for a rejected input an InvalidLength error reports the same length the parser measured.

Running

The just recipes wrap the nightly invocation and feed in the committed seed corpus:

just fuzz country_code      # run for 60 seconds (default)
just fuzz cfi 120           # run a target for 120 seconds
just fuzz-build             # just compile every target

Equivalently, without just:

cargo +nightly fuzz run country_code fuzz/corpus/country_code fuzz/seeds/country_code

A crash writes a reproducer under fuzz/artifacts/<target>/. Replay it with:

cargo +nightly fuzz run <target> fuzz/artifacts/<target>/<crash-file>

Corpus and seeds

  • fuzz/seeds/<target>/ holds known inputs (valid codes and edge shapes such as wrong length, bad character, and reserved codes), committed to git, to give the fuzzer a head start. Treat this directory as read only.
  • fuzz/dict/<target>.dict is the committed libFuzzer dictionary of useful tokens.
  • fuzz/corpus/<target>/ is the growing working corpus that libFuzzer writes to. It is ignored by git, along with fuzz/artifacts/ and fuzz/target/.

When a target uncovers an interesting new input, consider minimizing it and adding it to the seeds directory so the case is preserved.

Measuring coverage

To see how much of the parsing and validation code the corpus actually exercises:

just fuzz-coverage cnpj

That writes fuzz/coverage/<target>/coverage.profdata. Render a per file report with the nightly llvm-cov, for example:

LLVMCOV="$(rustc +nightly --print target-libdir)/../bin/llvm-cov"
BIN="$(find target -type f -path '*coverage*release/cnpj' ! -name '*.*' | head -1)"
"$LLVMCOV" report "$BIN" -instr-profile=fuzz/coverage/cnpj/coverage.profdata

The parser and validation modules should sit near full line coverage. Formatting and error modules are also exercised, though some rendering branches are covered more thoroughly by the unit tests.