RustArena

Step 3 of 6

SHOP-210: orders with validation

SHOP-210: Validate orders before taking stock

Last week an order for 3 mugs and 1 unknown product reduced the mug stock and then failed. Support had to fix the numbers by hand. New rule: an order either succeeds completely or changes nothing, and every failure comes with a clear reason the storefront can show.

Two ideas carry this ticket:

use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
enum TransferError {
    #[error("amount must be positive")]
    NotPositive,
    #[error("account {0} not found")]
    UnknownAccount(String),
    #[error("insufficient funds: balance {balance}, needed {needed}")]
    Insufficient { balance: u64, needed: u64 },
}

fn check(balance: u64, account: &str, amount: u64) -> Result<u64, TransferError> {
    if amount == 0 {
        return Err(TransferError::NotPositive);
    }
    if account != "ACC-1" {
        return Err(TransferError::UnknownAccount(account.to_string()));
    }
    if amount > balance {
        return Err(TransferError::Insufficient { balance, needed: amount });
    }
    Ok(balance - amount)
}

fn main() {
    println!("{:?}", check(500, "ACC-1", 200));
    for (account, amount) in [("ACC-1", 0), ("ACC-9", 10), ("ACC-1", 900)] {
        if let Err(e) = check(500, account, amount) {
            println!("{e}");
        }
    }
}
Ok(300)
amount must be positive
account ACC-9 not found
insufficient funds: balance 500, needed 900

{0} in an #[error] message is the first field of a tuple variant, and {balance} a named field of a struct variant.

Common mistakes

Your task

Implement Inventory::place_order(&mut self, order: &[(&str, u32)]) -> Result<Receipt, OrderError>. Each order line is (sku, quantity). In this order:

  1. An empty order is OrderError::Empty.
  2. Going through the lines in order, the first line with quantity 0 gives ZeroQuantity(sku), and the first unknown SKU gives UnknownSku(sku) (whichever line comes first; a line with both problems gives ZeroQuantity).
  3. Add up the quantities per SKU. The first SKU (in order of first appearance) whose total exceeds its stock gives OutOfStock { sku, requested: total, available: stock }.
  4. Only then reduce the stock and return a Receipt with one ReceiptLine per order line (product name, quantity, quantity times price) in order, and the total.

On any error, nothing in the inventory changes.

Challenge 2

SHOP-214: Low stock report

Purchasing wants a daily list of products that are running low, the emptiest first.

Add fn low_stock(&self, threshold: u32) -> Vec<String> to Inventory: one line per product whose stock is below threshold, formatted as SKU (Name): N left, sorted by stock (lowest first) and then by SKU. The inventory is a HashMap, so sorting is what makes the report deterministic.

Challenge 3

SHOP-220: Discounts at checkout

Marketing keeps inventing new kinds of discounts. Checkout shouldn't change every time: make discounts pluggable.

The seed defines a Discount trait with label() and amount_off(subtotal_cents, items). Implement it for three types:

Then write fn checkout(subtotal_cents: u64, items: u32, discounts: &[Box<dyn Discount>]) -> (u64, Vec<String>). Every discount is computed from the original subtotal (they don't stack on each other), the total discount can't make the price negative, and the returned labels are those of the discounts that took something off (amount above 0), in order.

Previous: LOG-102: the daily traffic report Next: OPS-330: validate the deploy config