Project: a money library
A library is code other people compile against, so the questions change. A program is finished when it does its job. A library also has to keep doing it for callers who upgrade. This page builds a small one, tinymoney, in five steps, and the last step changes it on purpose to see which changes break a caller. Every output below is a real run in the course image.
RememberGive each meaning its own type, keep fields private, and mark what may grow #[non_exhaustive]. Then tests, doc-tests and a semver check tell you whether a change is safe for the people who use it.
tinymoney/ ├── consumer/ │ ├── src/ │ │ └── main.rs a second package that uses the library like any other crate │ └── Cargo.toml ├── src/ │ ├── amount.rs a newtype │ ├── currency.rs a #[non_exhaustive] enum │ ├── error.rs the error type │ ├── lib.rs crate docs and the public names │ ├── money.rs the main type and its unit tests │ └── rounding.rs an exhaustive enum, on purpose ├── tests/ │ └── api.rs tests that use only the public API ├── Cargo.lock committed └── Cargo.toml the library package, and the workspace that holds the caller
[package]name = "tinymoney"version = "0.1.0"edition = "2024"rust-version = "1.85"description = "Money as whole minor units, with a currency attached"license = "MIT OR Apache-2.0"
[dependencies]thiserror = "2.0.21"
# `consumer` is a second package that uses the library the way another crate would.[workspace]members = ["consumer"][package]name = "consumer"version = "0.1.0"edition = "2024"publish = false
[dependencies]tinymoney = { path = ".." }consumer is a path dependency on the library: it stands in for the code of someone who uses tinymoney, so a change that breaks it is a change that would break them. Project structure explains workspaces. thiserror = "2.0.21" means that version or a newer compatible one; the lock file, below, fixes which.
Step 1: types that cannot be mixed up
Section titled “Step 1: types that cannot be mixed up”Here money is kept as whole minor units, and a bare integer would not say which unit it counts. Amount is a newtype (Idioms): a struct around one i64 of minor units (cents, yen) whose field is private. The Rust API Guidelines list this as C-NEWTYPE and, for the private field, C-STRUCT-PRIVATE. Outside the crate, the way to make an Amount from an integer is Amount::from_minor, so the unit is written at the call site.
//! Money as whole minor units (cents, yen) with a currency attached.//!//! Amounts are integers, so adding them never rounds. Two currencies cannot be//! added by accident: that is an [`Error`], not a wrong number.//!//! ```//! use tinymoney::{Currency, Money};//!//! let price = Money::parse("19.99", Currency::Usd)?;//! let shipping = Money::parse("5", Currency::Usd)?;//! assert_eq!(price.checked_add(shipping)?.to_string(), "USD 24.99");//! # Ok::<(), tinymoney::Error>(())//! ```
#![forbid(unsafe_code)]#![deny(missing_docs)]
mod amount;mod currency;mod error;mod money;mod rounding;
pub use amount::Amount;pub use currency::Currency;pub use error::Error;pub use money::Money;pub use rounding::Rounding;/// A count of the smallest unit of a currency: cents for dollars, yen for yen.////// The field is private. Code outside this crate has to say what the number/// means by calling [`Amount::from_minor`], so a bare `i64` cannot be passed/// where an `Amount` is expected.////// ```/// use tinymoney::Amount;////// let fee = Amount::from_minor(250);/// assert_eq!(fee.minor(), 250);/// ```#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct Amount(i64);
impl Amount { /// No money at all. pub const ZERO: Amount = Amount(0);
/// An amount of `minor` units: `250` is 2.50 dollars. pub fn from_minor(minor: i64) -> Amount { Amount(minor) }
/// The number of minor units. pub fn minor(self) -> i64 { self.0 }
/// The sum, or `None` if it does not fit in an `i64`. pub(crate) fn checked_add(self, other: Amount) -> Option<Amount> { self.0.checked_add(other.0).map(Amount) }}use std::fmt;use std::str::FromStr;
use crate::Error;
/// A currency this crate knows about.////// The enum is `#[non_exhaustive]`: a later version may add currencies without/// a major version bump, so a `match` in another crate needs a `_` arm.////// ```/// use tinymoney::Currency;////// assert_eq!("EUR".parse::<Currency>()?, Currency::Eur);/// assert_eq!(Currency::Jpy.decimals(), 0);/// # Ok::<(), tinymoney::Error>(())/// ```#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]#[non_exhaustive]pub enum Currency { /// United States dollar. Usd, /// Euro. Eur, /// Japanese yen. Jpy,}
impl Currency { /// The three-letter ISO 4217 code. pub fn code(self) -> &'static str { match self { Currency::Usd => "USD", Currency::Eur => "EUR", Currency::Jpy => "JPY", } }
/// How many decimal places the minor unit has: 2 for cents, 0 for yen. pub fn decimals(self) -> u32 { match self { Currency::Usd | Currency::Eur => 2, Currency::Jpy => 0, } }}
impl fmt::Display for Currency { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.code()) }}
impl FromStr for Currency { type Err = Error;
fn from_str(code: &str) -> Result<Currency, Error> { match code { "USD" => Ok(Currency::Usd), "EUR" => Ok(Currency::Eur), "JPY" => Ok(Currency::Jpy), other => Err(Error::UnknownCurrency(other.to_string())), } }}lib.rs keeps the modules private and re-exports the names callers need (Modules); it already names the modules of steps 2 and 3, because the project is shown whole and built up in the telling. Currency knows its decimal places: Wikipedia’s copy of the ISO 4217 list shows 2 for US dollars and euros and 0 for yen (the standard itself was not opened), so Jpy has no fraction. A caller who skips the constructor is stopped by the compiler:
error[E0423]: cannot initialize a tuple struct which contains private fields --> consumer/src/main.rs:5:26 | 5 | let fee = Money::new(Amount(250), Currency::Usd); | ^^^^^^ | = note: a struct named `Amount` exists in another namespace note: constructor is not visible here due to private fields --> src/amount.rs:14:19 | 14 | pub struct Amount(i64); | ^^^ private field help: you might have meant to use the `from_minor` associated function | 5 | let fee = Money::new(Amount::from_minor(250), Currency::Usd); | ++++++++++++
Step 2: errors a caller can match on
Section titled “Step 2: errors a caller can match on”The library returns its own enum, so callers can react to each failure; an application would wrap it in anyhow (Errors in real apps, Errors). thiserror writes the Display text from each #[error("...")] and the std::error::Error impl. Its docs add that it “deliberately does not appear in your public API”, so changing to or from a hand-written impl “is not a breaking change” (docs).
use crate::Currency;
/// What can go wrong when making or combining money.////// `#[non_exhaustive]`, like [`Currency`]: new kinds of failure can be added in/// a minor version.#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]#[non_exhaustive]pub enum Error { /// Two amounts in different currencies were combined. #[error("cannot combine {left} and {right}")] CurrencyMismatch { /// The currency of the left-hand side. left: Currency, /// The currency of the right-hand side. right: Currency, }, /// The result does not fit in 64 bits of minor units. #[error("the amount does not fit in an i64 of minor units")] Overflow, /// The text is not a decimal amount. #[error("{0:?} is not an amount")] InvalidAmount(String), /// More decimal places than the currency has. #[error("{currency} has {max} decimal places")] TooManyDecimals { /// The currency the amount was parsed for. currency: Currency, /// How many decimal places it allows. max: u32, }, /// A currency code this crate does not know. #[error("unknown currency code {0:?}")] UnknownCurrency(String),}Step 3: Money, and tests at three levels
Section titled “Step 3: Money, and tests at three levels”Money pairs an Amount with a Currency, both private. parse reads "19.99" into minor units and refuses "19.999" with TooManyDecimals instead of rounding it. percent multiplies in i128, so the product cannot overflow before the division, and asks a Rounding what to do with the remainder. Adding two currencies is an Err, not a wrong total.
use std::fmt;
use crate::{Amount, Currency, Error, Rounding};
/// An [`Amount`] in one [`Currency`].////// The fields are private, so every `Money` was made by [`Money::new`] or/// [`Money::parse`] and carries its currency with it.////// ```/// use tinymoney::{Amount, Currency, Money};////// let fee = Money::new(Amount::from_minor(250), Currency::Usd);/// assert_eq!(fee.to_string(), "USD 2.50");/// ```#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub struct Money { amount: Amount, currency: Currency,}
impl Money { /// Money from an amount and its currency. pub fn new(amount: Amount, currency: Currency) -> Money { Money { amount, currency } }
/// The amount, in minor units. pub fn amount(self) -> Amount { self.amount }
/// The currency. pub fn currency(self) -> Currency { self.currency }
/// Reads a decimal amount such as `"19.99"`, `"5"` or `"-0.05"`. /// /// It fails with [`Error::TooManyDecimals`] instead of rounding. /// /// # Errors /// /// [`Error::InvalidAmount`] if the text is not digits with at most one /// point, [`Error::TooManyDecimals`] if it has more decimal places than /// the currency, and [`Error::Overflow`] if the value, counted in minor /// units, does not fit in an `i64` (`i64::MIN` itself does). /// /// ``` /// use tinymoney::{Currency, Error, Money}; /// /// let cents = Money::parse("19.99", Currency::Usd)?; /// assert_eq!(cents.amount().minor(), 1999); /// /// let err = Money::parse("19.999", Currency::Usd).unwrap_err(); /// assert_eq!(err.to_string(), "USD has 2 decimal places"); /// # Ok::<(), Error>(()) /// ``` pub fn parse(text: &str, currency: Currency) -> Result<Money, Error> { let invalid = || Error::InvalidAmount(text.to_string()); let (negative, body) = match text.strip_prefix('-') { Some(rest) => (true, rest), None => (false, text), }; let (whole, fraction) = body.split_once('.').unwrap_or((body, "")); let all_digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); if whole.is_empty() || !all_digits(whole) || !all_digits(fraction) || (body.contains('.') && fraction.is_empty()) { return Err(invalid()); } let decimals = currency.decimals(); if fraction.len() > decimals as usize { return Err(Error::TooManyDecimals { currency, max: decimals, }); } // Work with the magnitude as a u64, so that i64::MIN (whose magnitude is // one more than i64::MAX) can be parsed too. let whole: u64 = whole.parse().map_err(|_| Error::Overflow)?; let fraction: u64 = if fraction.is_empty() { 0 } else { let digits: u64 = fraction.parse().map_err(|_| invalid())?; digits * 10_u64.pow(decimals - fraction.len() as u32) }; let magnitude = whole .checked_mul(10_u64.pow(decimals)) .and_then(|w| w.checked_add(fraction)) .ok_or(Error::Overflow)?; let signed = if negative { -i128::from(magnitude) } else { i128::from(magnitude) }; let minor = i64::try_from(signed).map_err(|_| Error::Overflow)?; Ok(Money::new(Amount::from_minor(minor), currency)) }
/// Adds two amounts of the same currency. /// /// # Errors /// /// [`Error::CurrencyMismatch`] if the currencies differ and /// [`Error::Overflow`] if the sum does not fit. /// /// ``` /// use tinymoney::{Currency, Error, Money}; /// /// let usd = Money::parse("1", Currency::Usd)?; /// let eur = Money::parse("1", Currency::Eur)?; /// assert_eq!(usd.checked_add(eur).unwrap_err().to_string(), "cannot combine USD and EUR"); /// # Ok::<(), Error>(()) /// ``` pub fn checked_add(self, other: Money) -> Result<Money, Error> { if self.currency != other.currency { return Err(Error::CurrencyMismatch { left: self.currency, right: other.currency, }); } let amount = self .amount .checked_add(other.amount) .ok_or(Error::Overflow)?; Ok(Money::new(amount, self.currency)) }
/// `percent` per cent of this amount, rounded as `rounding` says. /// /// # Errors /// /// [`Error::Overflow`] if the result does not fit. /// /// ``` /// use tinymoney::{Currency, Error, Money, Rounding}; /// /// let total = Money::parse("24.99", Currency::Usd)?; /// assert_eq!(total.percent(8, Rounding::TowardZero)?.to_string(), "USD 1.99"); /// assert_eq!(total.percent(8, Rounding::HalfAwayFromZero)?.to_string(), "USD 2.00"); /// # Ok::<(), Error>(()) /// ``` pub fn percent(self, percent: i64, rounding: Rounding) -> Result<Money, Error> { let scaled = i128::from(self.amount.minor()) * i128::from(percent); let minor = i64::try_from(rounding.divide(scaled, 100)).map_err(|_| Error::Overflow)?; Ok(Money::new(Amount::from_minor(minor), self.currency)) }}
impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let minor = self.amount.minor(); let sign = if minor < 0 { "-" } else { "" }; let magnitude = minor.unsigned_abs(); let decimals = self.currency.decimals(); if decimals == 0 { return write!(f, "{} {sign}{magnitude}", self.currency); } let scale = 10_u64.pow(decimals); let (whole, fraction) = (magnitude / scale, magnitude % scale); write!( f, "{} {sign}{whole}.{fraction:0width$}", self.currency, width = decimals as usize ) }}
#[cfg(test)]mod tests { use super::*;
#[test] fn parse_table() { let cases = [ ("19.99", Currency::Usd, Ok(1999)), ("5", Currency::Usd, Ok(500)), ("5.1", Currency::Usd, Ok(510)), ("-0.05", Currency::Usd, Ok(-5)), ("500", Currency::Jpy, Ok(500)), ( "19.999", Currency::Usd, Err(Error::TooManyDecimals { currency: Currency::Usd, max: 2, }), ), ( "5.5", Currency::Jpy, Err(Error::TooManyDecimals { currency: Currency::Jpy, max: 0, }), ), ("", Currency::Usd, Err(Error::InvalidAmount(String::new()))), ("1.", Currency::Usd, Err(Error::InvalidAmount("1.".into()))), ( "1e3", Currency::Usd, Err(Error::InvalidAmount("1e3".into())), ), ("99999999999999999999", Currency::Usd, Err(Error::Overflow)), ]; for (text, currency, expected) in cases { let got = Money::parse(text, currency).map(|m| m.amount().minor()); assert_eq!(got, expected, "parsing {text:?}"); } }
#[test] fn parse_reaches_both_ends_of_i64() { let minor = |text, currency| Money::parse(text, currency).map(|m| m.amount().minor()); assert_eq!(minor("-92233720368547758.08", Currency::Usd), Ok(i64::MIN)); assert_eq!(minor("92233720368547758.07", Currency::Usd), Ok(i64::MAX)); assert_eq!(minor("-9223372036854775808", Currency::Jpy), Ok(i64::MIN)); assert_eq!(minor("9223372036854775807", Currency::Jpy), Ok(i64::MAX)); // One past either end is still an error. assert_eq!(minor("-92233720368547758.09", Currency::Usd), Err(Error::Overflow)); assert_eq!(minor("92233720368547758.08", Currency::Usd), Err(Error::Overflow)); assert_eq!(minor("-9223372036854775809", Currency::Jpy), Err(Error::Overflow)); assert_eq!(minor("9223372036854775808", Currency::Jpy), Err(Error::Overflow)); }
#[test] fn display_pads_the_fraction() { let show = |minor, currency| Money::new(Amount::from_minor(minor), currency).to_string(); assert_eq!(show(5, Currency::Usd), "USD 0.05"); assert_eq!(show(-5, Currency::Usd), "USD -0.05"); assert_eq!(show(500, Currency::Jpy), "JPY 500"); assert_eq!(show(i64::MIN, Currency::Jpy), "JPY -9223372036854775808"); }
#[test] fn adding_overflow_is_an_error() { let max = Money::new(Amount::from_minor(i64::MAX), Currency::Eur); let one = Money::new(Amount::from_minor(1), Currency::Eur); assert_eq!(max.checked_add(one), Err(Error::Overflow)); }
#[test] fn percent_rounds_negative_amounts_away_from_zero() { let debt = Money::new(Amount::from_minor(-250), Currency::Usd); let half = debt.percent(1, Rounding::HalfAwayFromZero).unwrap(); assert_eq!(half.amount().minor(), -3); }}//! Tests that use the library only through its public API, as another crate would.
use tinymoney::{Amount, Currency, Error, Money, Rounding};
#[test]fn a_shopping_basket_adds_up() -> Result<(), Error> { let prices = ["19.99", "5", "0.01"]; let mut total = Money::new(Amount::ZERO, Currency::Usd); for text in prices { total = total.checked_add(Money::parse(text, Currency::Usd)?)?; } assert_eq!(total.to_string(), "USD 25.00"); Ok(())}
#[test]fn display_and_parse_agree() -> Result<(), Error> { for text in ["0.00", "12.34", "-7.05"] { let money = Money::parse(text, Currency::Eur)?; assert_eq!(money.to_string(), format!("EUR {text}")); } Ok(())}
#[test]fn errors_say_what_went_wrong() { let err = "ZZZ".parse::<Currency>().unwrap_err(); assert_eq!(err, Error::UnknownCurrency("ZZZ".to_string())); assert_eq!(err.to_string(), "unknown currency code \"ZZZ\"");}
#[test]fn rounding_modes_differ_on_a_half() -> Result<(), Error> { let fee = Money::parse("0.05", Currency::Usd)?; let down = fee.percent(50, Rounding::TowardZero)?; let up = fee.percent(50, Rounding::HalfAwayFromZero)?; assert_eq!((down.amount().minor(), up.amount().minor()), (2, 3)); Ok(())}
#[test]fn rounding_modes_differ_above_a_half_and_agree_below_it() -> Result<(), Error> { let total = Money::parse("24.99", Currency::Usd)?; // 24.99 x 8% = 1.9992: 199.92 cents, 0.92 of a cent is more than a half. assert_eq!(total.percent(8, Rounding::TowardZero)?.to_string(), "USD 1.99"); assert_eq!(total.percent(8, Rounding::HalfAwayFromZero)?.to_string(), "USD 2.00"); // 24.99 x 60% = 14.994: 1499.4 cents, 0.4 of a cent is less than a half. assert_eq!(total.percent(60, Rounding::TowardZero)?.to_string(), "USD 14.99"); assert_eq!(total.percent(60, Rounding::HalfAwayFromZero)?.to_string(), "USD 14.99"); Ok(())}
#[test]fn negative_halves_go_away_from_zero() -> Result<(), Error> { let debt = Money::parse("-2.50", Currency::Usd)?; assert_eq!(debt.percent(1, Rounding::TowardZero)?.to_string(), "USD -0.02"); assert_eq!(debt.percent(1, Rounding::HalfAwayFromZero)?.to_string(), "USD -0.03"); Ok(())}Three kinds of test run under cargo test (Testing covers the basics). Unit tests sit in mod tests next to the code and can reach private items. Each file in tests/ is a separate crate that sees only what the library exports, as consumer does. Doc-tests are the examples in the comments, which rustdoc compiles and runs. The --test-threads=1 keeps the order of the lines fixed, and the sed command drops the paths and replaces the timings. The three groups are the 7 unit tests, the 6 in tests/api.rs and the 7 doc-tests:
$ cargo test -- --test-threads=1 # only the group headers and the lines that name a test or a result; paths dropped, timings replaced by X.XXs
Running unittests src/lib.rs
test money::tests::adding_overflow_is_an_error ... ok
test money::tests::display_pads_the_fraction ... ok
test money::tests::parse_reaches_both_ends_of_i64 ... ok
test money::tests::parse_table ... ok
test money::tests::percent_rounds_negative_amounts_away_from_zero ... ok
test rounding::tests::half_away_from_zero_is_not_round_half_up ... ok
test rounding::tests::the_two_modes_differ_on_a_half ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Running tests/api.rs
test a_shopping_basket_adds_up ... ok
test display_and_parse_agree ... ok
test errors_say_what_went_wrong ... ok
test negative_halves_go_away_from_zero ... ok
test rounding_modes_differ_above_a_half_and_agree_below_it ... ok
test rounding_modes_differ_on_a_half ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Doc-tests tinymoney
test src/amount.rs - amount::Amount (line 7) ... ok
test src/currency.rs - currency::Currency (line 11) ... ok
test src/lib.rs - (line 6) ... ok
test src/money.rs - money::Money (line 10) ... ok
test src/money.rs - money::Money::checked_add (line 110) ... ok
test src/money.rs - money::Money::parse (line 49) ... ok
test src/money.rs - money::Money::percent (line 138) ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
The consumer package uses the same API from outside:
use tinymoney::{Amount, Currency, Error, Money, Rounding};
fn symbol(currency: Currency) -> &'static str { match currency { Currency::Usd => "$", Currency::Eur => "EUR ", // The crate marks `Currency` `#[non_exhaustive]`, so this arm is required. _ => "? ", }}
fn describe(rounding: Rounding) -> &'static str { // `Rounding` is not `#[non_exhaustive]`, so naming every variant is enough. match rounding { Rounding::TowardZero => "drop the fraction", Rounding::HalfAwayFromZero => "round halves away from zero", }}
fn main() -> Result<(), Error> { let price = Money::parse("19.99", Currency::Usd)?; let shipping = Money::new(Amount::from_minor(500), Currency::Usd); let total = price.checked_add(shipping)?; println!( "total: {total} ({}{})", symbol(total.currency()), total.amount().minor() );
for rounding in [Rounding::TowardZero, Rounding::HalfAwayFromZero] { println!( "8% tax, {}: {}", describe(rounding), total.percent(8, rounding)? ); }
let euros = Money::parse("1", Currency::Eur)?; match total.checked_add(euros) { Ok(sum) => println!("sum: {sum}"), Err(err) => println!("refused: {err}"), } println!("{:?}", Money::parse("19.999", Currency::Usd)); Ok(())}total: USD 24.99 ($2499)
8% tax, drop the fraction: USD 1.99
8% tax, round halves away from zero: USD 2.00
refused: cannot combine USD and EUR
Err(TooManyDecimals { currency: Usd, max: 2 })Step 4: documentation that is tested
Section titled “Step 4: documentation that is tested”//! documents the crate and /// the item below it. An example that uses ? ends with a hidden # Ok::<(), Error>(()) line so it can (API Guidelines, rustdoc book); the guidelines also ask for an # Errors section on functions that return Result (C-FAILURE), which parse, checked_add and percent have. parse accepts i64::MIN and reports Overflow one step past either end of the i64 range, which a unit test checks. #![deny(missing_docs)] turns a missing comment on a public item into a build error (task 1 below). cargo doc writes the HTML, and rustdoc itself adds a note to the page of a #[non_exhaustive] enum:
$ cargo doc --no-deps Documenting tinymoney v0.1.0 (/home/you/tinymoney) Generated /home/you/tinymoney/target/doc/tinymoney/index.html $ grep -o "This enum is marked as non-exhaustive" target/doc/tinymoney/enum.Currency.html This enum is marked as non-exhaustive
The doc-tests ran in step 3: seven of them, one for each example. An example that goes stale fails cargo test, which is more than a comment can do (Project structure has the rest of the doc syntax).
Step 5: a public API that can change
Section titled “Step 5: a public API that can change”Three enums are public. Currency and Error are #[non_exhaustive]; Rounding is not, and that is on purpose, to compare. In another crate, the attribute makes a match on the enum need a _ arm (Reference). consumer has one. A copy of its symbol function without it:
error[E0004]: non-exhaustive patterns: `_` not covered --> consumer/src/main.rs:4:11 | 4 | match currency { | ^^^^^^^^ pattern `_` not covered | note: `Currency` defined here --> src/currency.rs:20:1 | 20 | pub enum Currency { | ^^^^^^^^^^^^^^^^^ = note: the matched value is of type `Currency` = note: `Currency` is marked as non-exhaustive, so a wildcard `_` is necessary to match exhaustively help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 7 ~ Currency::Jpy => "JPY ", 8 ~ _ => todo!(),
The compiler asks for a _ arm because the library’s author reserved the right to add variants. Now three changes to the library, each tried against the unchanged consumer. First, a version 0.1.1 would add a currency and a method. diff shows the change; the caller builds and prints what it did before, and the library’s own tests still pass:
$ diff -u before after # src/currency.rs --- before +++ after @@ -24,6 +24,8 @@ Eur, /// Japanese yen. Jpy, + /// Pound sterling. + Gbp, } impl Currency { @@ -33,16 +35,26 @@ Currency::Usd => "USD", Currency::Eur => "EUR", Currency::Jpy => "JPY", + Currency::Gbp => "GBP", } } /// How many decimal places the minor unit has: 2 for cents, 0 for yen. pub fn decimals(self) -> u32 { match self { - Currency::Usd | Currency::Eur => 2, + Currency::Usd | Currency::Eur | Currency::Gbp => 2, Currency::Jpy => 0, } } + + /// The usual symbol, or `None` where this crate has none to offer. + pub fn symbol(self) -> Option<&'static str> { + match self { + Currency::Usd => Some("$"), + Currency::Gbp => Some("£"), + _ => None, + } + } } impl fmt::Display for Currency { @@ -59,6 +71,7 @@ "USD" => Ok(Currency::Usd), "EUR" => Ok(Currency::Eur), "JPY" => Ok(Currency::Jpy), + "GBP" => Ok(Currency::Gbp), other => Err(Error::UnknownCurrency(other.to_string())), } } $ cp new/currency.rs src/currency.rs && cargo run -q -p consumer total: USD 24.99 ($2499) 8% tax, drop the fraction: USD 1.99 8% tax, round halves away from zero: USD 2.00 refused: cannot combine USD and EUR Err(TooManyDecimals { currency: Usd, max: 2 }) $ cargo test # the lines that name a result; timings replaced by X.XXs test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
A new variant on Rounding, which has no attribute, is a different story:
$ diff -u before after # src/rounding.rs --- before +++ after @@ -9,6 +9,8 @@ /// Round to the nearest minor unit; a half goes away from zero, so 0.5 /// becomes 1 and -0.5 becomes -1 (not 0), and 1.49 becomes 1. HalfAwayFromZero, + /// Round halves to the even neighbour. + HalfEven, } impl Rounding { @@ -18,6 +20,14 @@ let remainder = n % d; match self { Rounding::TowardZero => quotient, + Rounding::HalfEven => { + let twice = remainder.abs() * 2; + if twice > d || (twice == d && quotient % 2 != 0) { + quotient + remainder.signum() + } else { + quotient + } + } Rounding::HalfAwayFromZero => { if remainder.abs() * 2 >= d { quotient + remainder.signum() $ cargo build -p consumer # with the new rounding.rs error[E0004]: non-exhaustive patterns: `Rounding::HalfEven` not covered --> consumer/src/main.rs:14:11 | 14 | match rounding { | ^^^^^^^^ pattern `Rounding::HalfEven` not covered | note: `Rounding` defined here --> src/rounding.rs:6:1 | 6 | pub enum Rounding { | ^^^^^^^^^^^^^^^^^ ... 13 | HalfEven, | -------- not covered = note: the matched value is of type `Rounding` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 16 ~ Rounding::HalfAwayFromZero => "round halves away from zero", 17 ~ Rounding::HalfEven => todo!(), | For more information about this error, try `rustc --explain E0004`. error: could not compile `consumer` (bin "consumer") due to 1 previous error
And renaming a public method breaks every caller of it:
error[E0599]: no method named `checked_add` found for struct `Money` in the current scope --> consumer/src/main.rs:23:23 | 23 | let total = price.checked_add(shipping)?; | ^^^^^^^^^^^ method not found in `Money` For more information about this error, try `rustc --explain E0599`. error: could not compile `consumer` (bin "consumer") due to 1 previous error
| Change to the library | The caller | Run here | The Cargo reference (SemVer) |
|---|---|---|---|
New variant on a #[non_exhaustive] enum |
still builds | yes | breaking only if the enum is not marked |
| New public method | still builds | yes | minor |
| New variant on an exhaustive enum | E0004 | yes | major |
| Renamed public method | E0599 | yes | major |
#[non_exhaustive] added to an existing enum |
E0004 | yes (task 3) | major |
Version numbers say which of these happened. For 1.0.0 and later a breaking change needs a new major number; the reference adds that releases starting with 0.y.z “can treat changes in “y” as a major release”, because Cargo treats only a change in the left-most non-zero component as incompatible. This crate is at 0.1.0.
Checking by hand does not scale, so there are tools. cargo-semver-checks compares the library’s rustdoc data with a baseline and names the rule a change breaks. This session installs a pinned version (needs network), keeps a copy of 0.1.0 as the baseline and tries the same three changes. The tool prints timings, replaced here, and exited with status 100 in the two failing runs:
$ cargo install cargo-semver-checks --locked --version 0.51.0 # needs network Installed package `cargo-semver-checks v0.51.0` (executable `cargo-semver-checks`) $ cp -r . /tmp/baseline # keep a copy of 0.1.0 to compare against $ cargo semver-checks --baseline-root /tmp/baseline # with the Gbp variant and symbol(); timings replaced by [X.XXXs], the Building and Parsing lines left out Checking tinymoney v0.1.0 -> v0.1.0 (no change; assume minor) Checked [X.XXXs] 202 checks: 202 pass, 58 skip Summary no semver update required Finished [X.XXXs] tinymoney $ cargo semver-checks --baseline-root /tmp/baseline # with Rounding::HalfEven instead Checking tinymoney v0.1.0 -> v0.1.0 (no change; assume minor) Checked [X.XXXs] 202 checks: 201 pass, 1 fail, 0 warn, 58 skip --- failure enum_variant_added: enum variant added on exhaustive enum --- Description: A publicly-visible enum without #[non_exhaustive] has a new variant. ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/enum_variant_added.ron Failed in: variant Rounding:HalfEven in /home/you/tinymoney/src/rounding.rs:13 Summary semver requires new major version: 1 major and 0 minor checks failed Finished [X.XXXs] tinymoney $ cargo semver-checks --baseline-root /tmp/baseline # with checked_add renamed to try_add Checking tinymoney v0.1.0 -> v0.1.0 (no change; assume minor) Checked [X.XXXs] 202 checks: 201 pass, 1 fail, 0 warn, 58 skip --- failure inherent_method_missing: pub method removed or renamed --- Description: A publicly-visible method or associated fn is no longer available under its prior name. It may have been renamed or removed entirely. ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/inherent_method_missing.ron Failed in: Money::checked_add, previously in file /tmp/baseline/src/money.rs:118 Summary semver requires new major version: 1 major and 0 minor checks failed Finished [X.XXXs] tinymoney
It agrees with the three compiler runs: the first change passes and the other two are reported as needing a new major version. Both copies are named 0.1.0 here, so the tool had no version change to go by and assumed a minor one (no change; assume minor, on the Checking line). The tool runs its own lints, which the output counts (202 checks, 58 skipped in each run). No change that it would miss was tried here, and it cannot know how your callers use the API beyond what the API declares. Treat it as a second pair of eyes (repository).
Dependencies and the lock file
Section titled “Dependencies and the lock file”A program usually commits Cargo.lock so every build gets the same versions. For a library the Cargo FAQ says the lock file “does not affect the consumers of your package, only Cargo.toml does that” (FAQ): the people who use tinymoney resolve their own versions from the thiserror = "2.0.21" line. This project commits it anyway so the sessions on this page build against the same thiserror every time (more). With --locked, Cargo exits with an error instead of changing the lock file (cargo build); here the exit status is 0. rust-version = "1.85" states the oldest compiler the package claims to support; no session on this page uses 1.85, so that claim is not tested here (how to test it).
$ cargo build --locked; echo "exit status $?" # Cargo prints its Compiling and Finished lines; this page leaves them out exit status 0 $ cargo tree -p tinymoney --depth 2 tinymoney v0.1.0 (/home/you/tinymoney) └── thiserror v2.0.21 └── thiserror-impl v2.0.21 (proc-macro)
Try it yourself
Section titled “Try it yourself”Each task is a run in the project; the session under it was run in the image.
- Delete the line
/// No money at all.aboveAmount::ZEROand runcargo build. Themissing_docslint, set todenyinlib.rs, stops the build and names the constant.
error: missing documentation for an associated constant --> src/amount.rs:17:5 | 17 | pub const ZERO: Amount = Amount(0); | ^^^^^^^^^^^^^^^^^^^^^^ | note: the lint level is defined here --> src/lib.rs:16:9 | 16 | #![deny(missing_docs)] | ^^^^^^^^^^^^ error: could not compile `tinymoney` (lib) due to 1 previous error
- In the first example in
src/lib.rs, change"USD 24.99"to"USD 25.00"and runcargo test --doc. The unit and integration tests still pass, and the doc-test is the one that fails, with both strings in the report.
test src/amount.rs - amount::Amount (line 7) ... ok test src/currency.rs - currency::Currency (line 11) ... ok test src/lib.rs - (line 6) ... FAILED test src/money.rs - money::Money (line 10) ... ok test src/money.rs - money::Money::checked_add (line 110) ... ok test src/money.rs - money::Money::parse (line 49) ... ok test src/money.rs - money::Money::percent (line 138) ... ok failures: ---- src/lib.rs - (line 6) stdout ---- assertion `left == right` failed left: "USD 24.99" right: "USD 25.00" failures: test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
- Put
#[non_exhaustive]aboveRounding. Nothing in the library changes, butconsumerno longer builds, because adding the attribute to an existing enum takes away what callers could rely on. The reference lists that as a major change and advises marking an enum when you first introduce it.
error[E0004]: non-exhaustive patterns: `_` not covered --> consumer/src/main.rs:14:11 | 14 | match rounding { | ^^^^^^^^ pattern `_` not covered | note: `Rounding` defined here --> src/rounding.rs:7:1 | 7 | pub enum Rounding { | ^^^^^^^^^^^^^^^^^ = note: the matched value is of type `Rounding` = note: `Rounding` is marked as non-exhaustive, so a wildcard `_` is necessary to match exhaustively help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 16 ~ Rounding::HalfAwayFromZero => "round halves away from zero", 17 ~ _ => todo!(), | For more information about this error, try `rustc --explain E0004`. error: could not compile `consumer` (bin "consumer") due to 1 previous error
What to try next
Section titled “What to try next”These were not run on this page.
- Add a
serdefeature that derivesSerializeandDeserializeforMoney, and forAmountandCurrencyinside it (Serde, features). Check that the build without the feature still works. - Write
Money::split(self, parts: u32)that shares an amount so that the parts add up to the original and differ by at most one minor unit, and prove it with a property test (the idea in Testing, more needs no crate). Decide whatparts == 0returns. - Add
Currency::Gbpfor real, bump the version to 0.2.0 and runcargo semver-checksagainst 0.1.0 as the baseline. - Run
cargo publish --dry-runto see what Cargo would upload (needs network, and may warn about missingrepositoryorreadmefields).
Read more
Section titled “Read more”- Cargo book: SemVer compatibility, the list of changes sorted by major and minor
- Rust API Guidelines: type safety, documentation, future proofing
- rustdoc book: documentation tests
- In this course: Idioms and design patterns, Modules, Errors, Errors in real apps, Project structure
Versions on this page: Rust 1.99.1, thiserror 2.0.21 as pinned in Cargo.lock, cargo-semver-checks 0.51.0.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.