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 withoutstdby default consumers who need it, falling back toalloconly where unavoidable (see each identifier’s own chapter for specifics).- Additive feature flags. Integrations with
serde,schemars,arbitrary, andproptestare 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::parseaccepts, and the rules every constructor enforces. - Formatting & Display — rendering the compact and punctuated forms without allocating.
- Error Handling — the
CnpjErrorvariants and how to match on them. - Feature Flags — optional
serde,schemars,arbitrary, andproptestintegrations. - 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:
| Positions | Length | Segment | Meaning |
|---|---|---|---|
| 1–8 | 8 | Root (raiz) | Identifies the entity itself; shared by the head office and every branch |
| 9–12 | 4 | Branch/order (ordem) | "0001" conventionally denotes the head office (matriz) |
| 13–14 | 2 | Verification digits | Computed 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()—truewhen the branch/order segment is"0001".Cnpj::branch_number()— the branch/order segment as au16, 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
| Constructor | Accepts |
|---|---|
Cnpj::parse / Cnpj::new | Punctuated or compact strings, any ASCII case |
Cnpj::from_bytes | Exactly 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:
- Length — after formatting is stripped, the input must contain exactly 14 meaningful characters.
- Character class — positions 1–12 accept a digit or an uppercase letter; positions 13–14 accept only a digit.
- 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. - 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
| Variant | When it occurs |
|---|---|
Empty | The input string was empty. |
InvalidLength | After stripping punctuation (., /, -, whitespace), the input didn’t contain exactly 14 characters. |
InvalidCharacter | A character outside the allowed set appeared at a given position (carries the character, 1-indexed position, and the expected character class). |
InvalidCheckDigits | The Módulo 11 checksum didn’t match one of the two verification digits (carries the position, expected digit, and found digit). |
RepeatedDigits | All 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()— aStrategy<Value = Cnpj>producing checksum-correct values spanning both the numeric and alphanumeric formats.cnpj::proptest::valid_cnpj_formatted_string()— the same, rendered as a punctuatedString, 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::parseaccepts, and the rules every constructor enforces. - Formatting & Display — rendering the canonical form without allocating.
- Error Handling — the
IsinErrorvariants and how to match on them. - Feature Flags — optional
serde,schemars,arbitrary, andproptestintegrations. - 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:
| Positions | Length | Segment | Meaning |
|---|---|---|---|
| 1–2 | 2 | Country code | ISO 3166-1 alpha-2 prefix of the issuing national numbering agency |
| 3–11 | 9 | NSIN | National Securities Identifying Number, alphanumeric |
| 12 | 1 | Check digit | Luhn (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 au8(0..=9).Isin::computed_check_digit()— recomputes the check digit from the first 11 characters (equal tocheck_digit()for any validIsin).
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
| Constructor | Accepts |
|---|---|
Isin::parse / Isin::new | 12-character strings, any ASCII case, trimmed |
Isin::from_bytes | Exactly 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:
- Length — after surrounding whitespace is trimmed, the input must contain exactly 12 characters. (
Isin::parserejects an empty string up front.) - 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.
- 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
| Variant | When it occurs |
|---|---|
Empty | The input string was empty. |
InvalidLength | After trimming surrounding whitespace, the input didn’t contain exactly 12 characters (carries the count found). |
InvalidCharacter | A character outside the allowed set appeared at a given position (carries the character, 1-indexed position, and the expected character class). |
InvalidCheckDigit | The 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()— aStrategy<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 canonicalString, 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::parseaccepts, and the rules every constructor enforces. - Formatting & Display — rendering the canonical form without allocating.
- Error Handling — the
CfiErrorvariants and how to match on them. - Feature Flags — optional
serde,schemars,arbitrary, andproptestintegrations. - 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:
| Positions | Length | Segment | Meaning |
|---|---|---|---|
| 1 | 1 | Category | The broadest class of instrument (e.g. E = equities) |
| 2 | 1 | Group | A subdivision within the category (meaning depends on the category) |
| 3–6 | 4 | Attributes | Four attribute codes whose meaning depends on the category and group |
Cfi exposes each part as an accessor:
Cfi::category()— the category code, as achar.Cfi::group()— the group code, as achar.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
Xconventionally 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
| Constructor | Accepts |
|---|---|
Cfi::parse / Cfi::new | 6-character strings, any ASCII case, trimmed |
Cfi::from_bytes | Exactly 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:
- Length — after surrounding whitespace is trimmed, the input must contain exactly 6 characters. (
Cfi::parserejects an empty string up front.) - Character class — every position must be an uppercase ASCII letter (
A–Z). - Category — position 1 must be a category defined by ISO 10962.
- Group — position 2 must be a group defined for that category.
- 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
| Variant | When it occurs |
|---|---|
Empty | The input string was empty. |
InvalidLength | After trimming surrounding whitespace, the input didn’t contain exactly 6 characters (carries the count found). |
InvalidCharacter | A non-letter appeared at a given position (carries the character and 1-indexed position). |
UnknownCategory | Position 1 is not a category defined by ISO 10962 (carries the offending code). |
UnknownGroup | Position 2 is not a group of the resolved category (carries the category and offending group code). |
InvalidAttribute | An 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()— aStrategy<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 canonicalString, 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::parseaccepts, and the rules every constructor enforces. - Formatting & Display: rendering the canonical form without allocating.
- Error Handling: the
CountryCodeErrorvariants and how to match on them. - Feature Flags: optional
serde,schemars,arbitrary, andproptestintegrations. - 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
EUandUKare not assigned, so they are rejected. - User assigned ranges (for example
AA,QMtoQZ,XAtoXZ, andZZ) 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::parseandCountryCode::newaccept two character strings, in any ASCII case, trimmed of surrounding whitespace.CountryCode::from_bytesaccepts exactly two pre normalized uppercase ASCII bytes.FromStrandTryFrom<&str>behave likeparse, 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:
- Length: after surrounding whitespace is trimmed, the input must contain exactly two characters.
(
CountryCode::parserejects an empty string first.) - Character class: both positions must be an uppercase ASCII letter (
AtoZ). - 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(): aStrategy<Value = CountryCode>producing valid values by choosing from the assigned set.country::proptest::valid_country_code_string(): the same, rendered as a canonicalString, 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
# Examplessection. - Every public trait impl (
FromStr,TryFrom,AsRef, …) has a one-line doc comment on the implementing method. -
# Errorssections 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_codecfiisincnpj
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:
- As arbitrary UTF-8 text handed to
parse. - As raw bytes handed to
from_bytes. - 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.
- As an
Arbitrarygenerated 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 toas_bytes()(this guards thefrom_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 everyTryFromimpl (&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 withCountryCode::parseof the prefix (it isNonefor structurally valid but ISO 3166-1 unassigned prefixes such asXS). For a CNPJis_root(),branch_number(), and the punctuatedformatted()form stay consistent with the branch segment andDisplay. - Trait conversions:
PartialEq<str>/PartialEq<&str>andAsRef<str>/AsRef<[u8]>agree with the canonical string and bytes. - serde: the value round trips through its JSON string.
- Rendering:
DisplayandDebugnever panic. Formatting a rejected value’s error never panics, and for a rejected input anInvalidLengtherror 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>.dictis 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 withfuzz/artifacts/andfuzz/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.