Rust · Intermediate

Result, enums and tests with Cargo

4 min read

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 either Ok(value) or Err(error). The ? operator returns the error early from a function that also returns Result.

  • Enums can carry data (Overdraft { account, balance, amount }), and match must handle every variant.

  • Checked arithmetic (checked_sub, checked_add) returns None on overflow instead of wrapping around, which matters for balances.

  • cargo test runs #[test] functions; cargo clippy catches common mistakes and non-idiomatic code.

Worked example: a ledger

src/lib.rs
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);
    }
}
shellsession
$ 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 $?
0

Points to notice:

  • checked_sub turns "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.

  • apply borrows opening and 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; return Result and 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

  1. Add a fee per transfer paid to a treasury account, with tests that the total is still conserved.

  2. Add a small binary (src/main.rs) that reads a CSV file path from the command line and prints balances or the error.

Further reading