Rust · Intermediate
Result, enums and tests with Cargo
Why it matters
Blockchain and infrastructure code handles money and untrusted input, so it must fail predictably. Rust has no exceptions: errors are values of type Result, and the compiler makes you handle them. Combined with enums and pattern matching, that makes error paths explicit and testable.
The concept
Result<T, E>is eitherOk(value)orErr(error). The?operator returns the error early from a function that also returnsResult.Enums can carry data (
Overdraft { account, balance, amount }), andmatchmust handle every variant.Checked arithmetic (
checked_sub,checked_add) returnsNoneon overflow instead of wrapping around, which matters for balances.cargo testruns#[test]functions;cargo clippycatches common mistakes and non-idiomatic code.
Worked example: a ledger
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, PartialEq)]
pub enum LedgerError {
BadLine(usize),
BadAmount(usize, String),
Overdraft { account: String, balance: u64, amount: u64 },
}
impl fmt::Display for LedgerError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LedgerError::BadLine(n) => write!(f, "line {n}: expected 'from,to,amount'"),
LedgerError::BadAmount(n, a) => write!(f, "line {n}: '{a}' is not a whole amount"),
LedgerError::Overdraft { account, balance, amount } => {
write!(f, "{account} has {balance}, cannot send {amount}")
}
}
}
}
/// Apply transfers in order. Balances start from `opening`; nothing may go below zero.
pub fn apply(opening: &HashMap<String, u64>, csv: &str) -> Result<HashMap<String, u64>, LedgerError> {
let mut balances = opening.clone(); // borrow `opening`, own our copy
for (i, line) in csv.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
let parts: Vec<&str> = line.split(',').map(str::trim).collect();
let [from, to, amount] = parts[..] else { return Err(LedgerError::BadLine(i + 1)) };
let amount: u64 = amount.parse().map_err(|_| LedgerError::BadAmount(i + 1, amount.to_string()))?;
let balance = *balances.get(from).unwrap_or(&0);
let remaining = balance.checked_sub(amount).ok_or_else(|| LedgerError::Overdraft {
account: from.to_string(),
balance,
amount,
})?;
balances.insert(from.to_string(), remaining);
*balances.entry(to.to_string()).or_insert(0) += amount;
}
Ok(balances)
}
#[cfg(test)]
mod tests {
use super::*;
fn opening() -> HashMap<String, u64> {
HashMap::from([("asha".to_string(), 100), ("ravi".to_string(), 20)])
}
#[test]
fn transfers_move_value_and_conserve_total() {
let b = apply(&opening(), "asha,ravi,30\nravi,meera,45\n").unwrap();
assert_eq!(b["asha"], 70);
assert_eq!(b["ravi"], 5);
assert_eq!(b["meera"], 45);
assert_eq!(b.values().sum::<u64>(), 120);
}
#[test]
fn overdraft_is_an_error_not_a_wraparound() {
let err = apply(&opening(), "ravi,asha,21").unwrap_err();
assert_eq!(err.to_string(), "ravi has 20, cannot send 21");
}
#[test]
fn bad_input_is_reported_with_line_numbers() {
assert_eq!(apply(&opening(), "asha,ravi").unwrap_err(), LedgerError::BadLine(1));
assert_eq!(apply(&opening(), "\nasha,ravi,-5").unwrap_err(), LedgerError::BadAmount(2, "-5".into()));
}
#[test]
fn opening_balances_are_not_modified() {
let open = opening();
let _ = apply(&open, "asha,ravi,100").unwrap();
assert_eq!(open["asha"], 100);
}
}$ cargo new --lib ledger # then replace src/lib.rs
$ cargo test
test tests::bad_input_is_reported_with_line_numbers ... ok
test tests::transfers_move_value_and_conserve_total ... ok
test tests::overdraft_is_an_error_not_a_wraparound ... ok
test tests::opening_balances_are_not_modified ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo clippy -- -D warnings
$ echo $?
0Points to notice:
checked_subturns "ravi sends 21 but has 20" into an error; plain subtraction of unsigned integers would panic in debug builds and wrap around in release builds.The slice pattern
let [from, to, amount] = parts[..] else { ... }both checks the number of fields and names them.applyborrowsopeningand returns new balances, so the caller's data is never changed, which one of the tests proves.The conservation test checks an invariant (the total never changes), the same idea as the fuzz test on the Smart contracts shelf.
Common mistakes
unwrap()in library code. It panics on errors; returnResultand let the caller decide.Unchecked arithmetic on money. Use checked operations or a type that enforces limits.
Stringly typed errors. An enum lets callers match on the exact failure.
Interview angle
"How does Rust handle errors?" (Result and ?, panics only for bugs). "What does ? do?" "When would you use unwrap?" (tests and truly impossible cases). "How do you avoid integer overflow bugs?"
Practice task
Add a
feeper transfer paid to a treasury account, with tests that the total is still conserved.Add a small binary (
src/main.rs) that reads a CSV file path from the command line and prints balances or the error.