An ergonomic Rust library for handling money.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Rosa Richter 3df16cef64
All checks were successful
ci/woodpecker/push/test Pipeline was successful
chore: Release lucre version 0.10.0
2026-08-14 10:11:02 -06:00
.woodpecker ci: Add CI 2026-08-11 17:15:14 -06:00
src feat: Serialize exchange rates and rate tables 2026-08-14 10:03:46 -06:00
.editorconfig feat: Initial commit 2026-08-11 11:29:25 -06:00
.gitignore feat: Initial commit 2026-08-11 11:29:25 -06:00
build.rs feat: Go through Rust API Guidelines checklist 2026-08-12 03:38:32 -06:00
Cargo.lock chore: Release lucre version 0.10.0 2026-08-14 10:11:02 -06:00
Cargo.toml chore: Release lucre version 0.10.0 2026-08-14 10:11:02 -06:00
CHANGELOG.md chore: Release lucre version 0.10.0 2026-08-14 10:11:02 -06:00
isodata.tsv feat: build Currency from isodata.tsv 2026-08-11 11:45:15 -06:00
LICENSE doc: flesh out README.md and add LICENSE 2026-08-11 13:29:45 -06:00
mise.toml feat: Add serde support behind a feature flag 2026-08-13 19:28:44 -06:00
README.md chore: Release lucre version 0.10.0 2026-08-14 10:11:02 -06:00
release.toml fix: cargo-release mangled the new serde feature line 2026-08-13 19:41:21 -06:00
renovate.json Add renovate.json 2026-08-11 20:05:19 +00:00

lucre

status-badge Crates.io Version docs.rs

An ergonomic Rust library for handling money.

Represent money in your applications without fussing over generics or lifetimes, while still being safe and fast. ISO 4217 currency definitions are built in.

Install

Add lucre to your Cargo.toml.

[dependencies]
lucre = "0.10.0"

Usage

The Money struct is the primary interface, with the Currency struct supporting it. Currency contains constants for all current ISO 4217 currencies.

use lucre::{Money, Currency, Format, MoneyError};

fn main() -> Result<(), MoneyError> {
    // Create money from major or minor units
    let subtotal = Money::from_major(100, &Currency::USD);
    let tax = Money::from_minor(475, &Currency::USD);

    // Arithmetic is available as either checked or panicking operations
    let _unchecked = subtotal + tax;
    let total = subtotal.checked_add(&tax)?;

    // The display format has sensible defaults...
    assert_eq!(total.to_string(), "104.75 USD");

    // ...which can be overridden
    let format = Format::default().symbol();
    assert_eq!(total.format_with(&format).to_string(), "$104.75");

    Ok(())
}

Several currencies at once

Money refuses to mix currencies — + panics and comparisons answer None. When amounts in different currencies have to travel together, put them in a MoneyBag, which keeps each currency on its own books.

use lucre::{Currency, Money, MoneyBag};

let mut wallet = MoneyBag::new();
wallet += Money::from_major(25, &Currency::USD);
wallet += Money::from_major(10, &Currency::EUR);

assert_eq!(
    wallet.balance(&Currency::USD),
    Money::from_major(25, &Currency::USD)
);

// A currency the wallet has never held answers zero, not nothing
assert_eq!(
    wallet.balance(&Currency::JPY),
    Money::from_major(0, &Currency::JPY)
);

// Both sums are available over the same iterator, so the type asked for is
// the decision: `Option<Money>` insists the currencies match, a bag keeps
// them apart
let refunds = [
    Money::from_minor(1999, &Currency::USD),
    Money::from_minor(1250, &Currency::USD),
];

assert_eq!(
    refunds.iter().sum::<Option<Money>>(),
    Some(Money::from_minor(3249, &Currency::USD))
);
assert_eq!(refunds.iter().sum::<MoneyBag>().to_string(), "32.49 USD");

// Once the currencies disagree, only the bag still answers
let mixed = [
    Money::from_minor(1999, &Currency::USD),
    Money::from_minor(1250, &Currency::EUR),
];

assert_eq!(
    mixed.iter().sum::<MoneyBag>().to_string(),
    "12.50 EUR, 19.99 USD"
);

Converting between currencies

Exchange rates are live data rather than a standard, so lucre quotes none of its own. State a rate you already have and it handles the arithmetic; an Exchange keeps a set of them to look up by currency pair.

use std::error::Error;

use lucre::{Currency, Exchange, ExchangeRate, Money};
use rust_decimal::dec;

fn main() -> Result<(), Box<dyn Error>> {
    let mut desk = Exchange::new();
    desk.set_rate(&ExchangeRate::new(Currency::USD, Currency::EUR, dec!(0.9))?);
    desk.set_rate(&ExchangeRate::new(Currency::EUR, Currency::JPY, dec!(160))?);

    let usd_eur = desk
        .rate(&Currency::USD, &Currency::EUR)
        .ok_or("the desk quotes USD against EUR")?;

    assert_eq!(
        usd_eur.convert(&Money::from_major(100, &Currency::USD))?,
        Money::from_major(90, &Currency::EUR)
    );

    // A pair the desk does not quote can be crossed through one it does
    let eur_jpy = desk
        .rate(&Currency::EUR, &Currency::JPY)
        .ok_or("the desk quotes EUR against JPY")?;
    let usd_jpy = usd_eur.cross_with(&eur_jpy)?;

    assert_eq!(usd_jpy.rate(), dec!(144.0));

    // A conversion keeps every digit multiplying reached; round when it is due
    let converted = usd_jpy.convert(&Money::from_minor(2599, &Currency::USD))?;

    assert_eq!(converted.amount(), dec!(3742.5600));

    Ok(())
}

Rates are held in one direction at a time, so quoting dollars against euros says nothing about euros against dollars. Iterating a MoneyBag yields each currency's balance in ISO alphabetic order, which is enough to price a whole bag against a rate apiece.

Features

serde

Off by default. Turning it on gives Money, MoneyBag, ExchangeRate, Exchange, Currency, IsoAlphabeticCode, IsoNumericCode, and RoundingMode a Serialize and a Deserialize impl.

[dependencies]
lucre = { version = "0.10.0", features = ["serde"] }

Amounts and rates travel as text, which carries a fraction exactly and keeps the scale the figure was built with. Numbers are read as well, floats included, but only text makes the round trip untouched.

{ "amount": "104.75", "currency": "USD" }

A bag is a balance per currency, keyed by ISO alphabetic code. Reading one totals whatever the document says, rather than insisting it already be in the shape a bag would have written: a zero balance leaves no currency behind, and a currency named twice is summed.

{ "EUR": "10.00", "USD": "30.00" }

A quote states the pair it spans and the multiplier between them, and a rate of zero or less is refused on the way in, as ExchangeRate::new refuses it.

{ "from": "USD", "to": "EUR", "rate": "0.9" }

An Exchange is a rate per pair, keyed the way a rate board names one. Each direction is an entry of its own, and a pair named twice keeps the rate given last.

{ "USD/EUR": "0.9", "EUR/USD": "1.1" }

The smaller types are single values rather than objects:

Type Shape Accepts
Currency "USD" the three-letter code, unassigned codes refused
IsoAlphabeticCode "ZZZ" three capitals, assigned or not
IsoNumericCode 840 an integer of at most three digits
RoundingMode "half-up" or "half-down", or "half-even"

JSON, TOML, YAML and their kin work. Formats that carry no type tags, such as bincode and postcard, do not.

Maintainer

This project is maintained by Rosa Richter. For ways to contact her, see her contact page.

Contributing

Questions and contributions are absolutely welcome! Please create an issue for bugs, feature requests, or questions.

License

BSD-2-Clause-Patent © Rosa Richter