This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
qsv_currency is a fork of Tahler/currency-rs, created for
the qsv CSV toolkit. The fork exists to support multi-character
currency strings ("USD", "EUR") rather than only single-char symbols ("$", "€"), plus serde support,
num 0.4, and is_iso_currency().
Note the naming mismatch: the crate is qsv_currency, but repository in Cargo.toml points at
dathere/currency-rs.
cargo test # 16 unit tests + 10 doctests
cargo test test_from_str # single test (all tests are inline in `mod tests`; no tests/ dir)
cargo test --doc # doctests only — the public API is documented almost entirely by them
cargo clippy --all-targets
cargo fmt --checkEdition 2024, MSRV 1.98.0 (declared via rust-version, so older toolchains refuse to build
rather than failing confusingly). cargo fmt applies the 2024 style edition — notably a different
import sort order than 2021.
clippy::pedantic is enabled via [lints.clippy] in Cargo.toml and the tree is clean under it,
so plain cargo clippy --all-targets is the pedantic run — any warning is something you introduced.
One group-wide exception is recorded there: needless_pass_by_value is allowed, because
ToString::to_string takes &self, and &impl ToString would force callers to write
Currency::from(1000, &'$'). One #[expect(clippy::too_many_lines, reason = ...)], on test_from_str, carries its own
justification. Prefer #[expect] over #[allow] — it warns when the suppression stops being
needed, which is how a misplaced one gets caught.
cargo clippy --all-targets and cargo fmt --check are both currently clean — treat any warning as
something you introduced.
Everything lives in src/lib.rs (~1500 lines). There is no module structure to learn — the
non-obvious parts are the invariants below.
Representation. Currency { symbol: String, coin: BigInt }. coin is in 1/100 units, so
Currency::from(1000, '$') is $10.00. DECIMAL_PLACES is hardcoded to 2, so 0-decimal (JPY) and
3-decimal (KWD) currencies pass is_iso_currency() but are still stored and formatted with 2 decimals.
Arithmetic panics on symbol mismatch. Add/Sub/Div between two Currency values with different
symbols is a panic!, not an Err. This is the single biggest gotcha in the API.
Operators are macro-generated. To add or change one, edit the macro and its invocation list rather than writing impls by hand:
| Macro | Covers |
|---|---|
impl_all_trait_combinations_for_currency! |
Add/Sub between two Currency (all 4 owned/borrowed combos). Mul is deliberately commented out. |
impl_all_trait_combinations_for_currency_into_bigint! |
Mul/Div by BigUint, u8..usize, i8..isize |
impl_all_trait_combinations_for_currency_conv_bigint! |
Mul/Div by f32/f64 via from_f32/from_f64 |
Currency / Currency (4 impls) and Neg are hand-written, not macro-generated.
from_str is a hand-rolled char scanner, not a regex or grammar. It splits leading non-digit
chars into the symbol, then decides decimal placement from a heuristic: the last delimiter seen
(. or ,) plus the length of the trailing digit streak.
- streak == 3 → treated as a thousands separator, no decimals (so
"£1.000"parses as 1000.00) - streak < 2 → pad with zeros
- streak > 2 → rounded half away from zero, in
BigInt(see "Known-shaky areas")
It also accepts accounting-style negatives: (1.00) and -1.00, and trims whitespace off both
ends of the symbol. It is extremely permissive: a string with no digits parses to a zero amount
with the whole string as the symbol, rather than erroring.
Formatting. {} (Display) produces comma grouping with a . decimal; {:e} (LowerExp)
produces the European form by mapping ,↔. in a single pass over Display's output. Do not
reintroduce a placeholder-character swap — the old one corrupted symbols containing that character.
is_iso_currency checks iso_currency::Currency::from_code() and falls back to a
OnceLock<HashSet<String>> of every ISO symbol built from iso_currency::Currency::iter(). The
iterator feature on iso_currency is load-bearing — removing it breaks the build. Code lookup is
case-sensitive ("USd" is not ISO), and crypto symbols (Ð, Ξ) are correctly rejected.
Serde is hand-written, not derived. Serialize emits to_string(); Deserialize runs
from_str. Because Deserialize goes through String::deserialize, the wire form must be a JSON
string ({"amount": "-$12,000.99"}) — a bare JSON number fails, but a malformed string does not:
it deserializes to zero. serde_json and serde_derive
are #[cfg(test)]-only dev-dependencies.
Ordering is a #[derive(PartialOrd)] on (symbol, coin), so cross-symbol comparison sorts
lexicographically by symbol first — this is derived behavior, not a designed contract, and the tests
only ever compare same-symbol values.
Currency op scalar and scalar op Currency are generated by separate macro pairs
(impl_currency_lhs_* / impl_scalar_lhs_*). Mul gets both halves; Div gets only the
currency_lhs half. 2.0 / $10.00 deliberately does not compile — the impls that used to answer
it silently computed $10.00 / 2.0 instead.
For the float ops, the scalar is carried as a BigInt pre-multiplied by 100, so the factor has to
be cancelled on the correct side. Both formulas live in exactly one place each — combine_mul and
combine_div. Sharing one formula between the two is what produced a 10,000× error in Div, so
keep them separate.
Parsing is exact: it rounds half away from zero in BigInt, with no f64 in the path.
Multiplication and division still truncate to two decimal places, and convert() inherits that
from Mul. Treat mul/div precision as a known open problem; parse precision is settled.
A 3-digit streak after a delimiter is read as a thousands separator, so "$100.777" is 100777.00,
not 100.78. That is ambiguous input rather than a bug — a commented-out case in test_from_str
records the tension, and flipping it would break "$1,210".
mod tests::regressions pins the eight defects found in the 2026-08 review. Each was written first
to assert the wrong value, confirmed to pass, then flipped. Don't relax them.