Skip to content

Latest commit

 

History

History
117 lines (87 loc) · 6.49 KB

File metadata and controls

117 lines (87 loc) · 6.49 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this crate is

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.

Commands

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 --check

Edition 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.

Architecture

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.

Scalar arithmetic is split on purpose

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.

Known-shaky areas

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.