LearnAI ToolsCareerPractice BuildsPlayContact
RustBeginner~1.5 hours

Command-Line Calculator

Parse and evaluate expressions from user input, using Result and the ? operator to handle invalid input gracefully.

Error HandlingPattern MatchingParsing

Overview

A calculator that reads text typed by a human has exactly one job that matters more than the arithmetic: deciding, cleanly, what to do when that text is not a valid expression. Rust does not have exceptions — there is no `try`/`catch` anywhere in this project — so every function that can fail says so in its return type by returning a `Result<T, E>`, and every caller is forced by the compiler to acknowledge that the `Err` case exists, even if all it does is print a message and move on.

By the end of this tutorial you will have a small REPL (read-eval-print loop) that accepts expressions like `3 + 4` typed one per line, parses them into two operands and an operator, evaluates them with `match`, and reports specific, readable errors for bad numbers, unknown operators, and division by zero — all through a custom `CalcError` enum and the `?` operator, without a single `unwrap()` in the parsing or evaluation path.

What You'll Build
  • A `CalcError` enum with `ParseError`, `DivisionByZero`, and `UnknownOperator` variants.
  • A `Display` implementation for `CalcError` so it prints a readable message instead of its raw Debug form.
  • A `parse_expression()` function that splits and validates a line of input.
  • An `evaluate()` function using `match` to dispatch on the operator character.
  • A `calculate()` function chaining parsing and evaluation together with the `?` operator.
  • A REPL loop that reads a line, calculates it, and keeps running after both success and failure.

Prerequisites

  • Basic `enum` definitions and matching on them with `match`.
  • `Result<T, E>` — the difference between the `Ok` and `Err` variants.
  • The `?` operator — what it does when the `Result` it is applied to is `Err`.
  • String methods — `.trim()`, `.split_whitespace()`, and `.parse::<T>()`.
  • Implementing a trait, specifically `std::fmt::Display`, well enough to follow along.

Project Structure

Create the project with `cargo new calculator` and write everything in `src/main.rs`. The file is organized bottom-up: `CalcError` and its `Display` implementation come first, since every other function's signature mentions it; then `parse_expression()`, which turns a raw `&str` into two `f64` operands and a `char` operator; then `evaluate()`, which turns those three values into a result; then `calculate()`, a thin function that chains the previous two together; and finally `main()`, which is nothing more than a loop calling `calculate()` and printing whichever variant of the `Result` it gets back.

Notice that none of `parse_expression()`, `evaluate()`, or `calculate()` ever calls `println!` on an error — only `main()` does. Keeping the error-reporting decision in one place (the REPL loop) instead of scattering `println!` calls through the parsing and evaluation logic is what makes those functions reusable later, for example from a test, a web handler, or a different front end entirely.

Step 1: Define a CalcError Enum

Each variant of `CalcError` carries exactly the data needed to explain what went wrong: `ParseError` holds the offending text, `UnknownOperator` holds the offending character, and `DivisionByZero` needs no payload at all, since there is nothing more to say about it. Implementing `std::fmt::Display` (not just deriving `Debug`) is what lets `{e}` inside a `println!` format string print a human sentence instead of Rust's internal `CalcError::ParseError("abc")` representation; implementing `std::error::Error` on top of that is what makes `CalcError` compatible with the wider ecosystem of Rust error-handling code that expects a real error type.

use std::fmt;
// Each variant carries exactly the data needed to explain the failure --
// this is the idiomatic alternative to throwing three different exception
// types the way another language might.
#[derive(Debug)]
enum CalcError {
ParseError(String), // Holds the text that failed to parse as a number
DivisionByZero, // No payload needed; the variant name says everything
UnknownOperator(char), // Holds the character that wasn't +, -, *, or /
}
// Display controls what {e} / {} prints for a CalcError. Deriving Debug
// (above) already gives us {:?}, but Debug output is meant for programmers,
// not end users -- Display is what a REPL should actually show them.
impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CalcError::ParseError(text) => write!(f, "could not parse '{text}' as a number"),
CalcError::DivisionByZero => write!(f, "division by zero"),
CalcError::UnknownOperator(op) => write!(f, "unknown operator '{op}'"),
}
}
}
// Implementing the standard Error trait (it has no required methods once
// Debug and Display exist) marks CalcError as a first-class error type that
// works with the wider Rust error-handling ecosystem, e.g. Box<dyn Error>.
impl std::error::Error for CalcError {}

Step 2: Parse an Expression String

`parse_expression()` expects exactly three whitespace-separated tokens: a number, an operator, and a number. `.split_whitespace()` returns an iterator, and `.collect()` gathers it into a `Vec<&str>` so the token count can be checked with a plain `if`. Each `&str` operand is converted with `.parse::<f64>()`, which itself returns a `Result<f64, ParseFloatError>` — that error type is discarded with `.map_err(...)` and replaced with a `CalcError::ParseError` that actually names the offending token.

// Splits "3 + 4" into (3.0, '+', 4.0). Returns Result rather than panicking,
// because malformed input from a human typing at a prompt is an everyday
// occurrence, not an exceptional program-breaking event.
fn parse_expression(input: &str) -> Result<(f64, char, f64), CalcError> {
let tokens: Vec<&str> = input.trim().split_whitespace().collect();
if tokens.len() != 3 {
return Err(CalcError::ParseError(input.to_string())); // Wrong shape entirely, e.g. "3 +" or "3 + 4 + 5"
}
// parse::<f64>() returns Result<f64, ParseFloatError>; map_err() swaps that
// error type for our own CalcError so every function in this file agrees
// on one error type instead of juggling several.
let left: f64 = tokens[0].parse().map_err(|_| CalcError::ParseError(tokens[0].to_string()))?;
let right: f64 = tokens[2].parse().map_err(|_| CalcError::ParseError(tokens[2].to_string()))?;
// The operator token must be exactly one character; anything else (e.g.
// "++") is treated the same as a bad number -- a parse failure, not an
// UnknownOperator, since UnknownOperator is reserved for Step 3's match.
let operator = tokens[1].chars().next().ok_or_else(|| CalcError::ParseError(tokens[1].to_string()))?;
if tokens[1].len() != 1 {
return Err(CalcError::ParseError(tokens[1].to_string()));
}
Ok((left, operator, right))
}
Example Usage

Click Run to see what this code prints.

Step 3: Evaluate the Expression With match

`evaluate()` receives the already-parsed operands and operator and does the actual arithmetic. The `match` on `operator` is exhaustive: three arms handle `+`, `-`, and `*` unconditionally, the `/` arm has its own nested `if` to guard against a zero divisor before dividing, and the trailing `other =>` arm is required by the compiler because a `char` has vastly more possible values than the four this calculator understands.

fn evaluate(left: f64, operator: char, right: f64) -> Result<f64, CalcError> {
match operator {
'+' => Ok(left + right),
'-' => Ok(left - right),
'*' => Ok(left * right),
'/' => {
if right == 0.0 {
Err(CalcError::DivisionByZero) // Caught here, before the divide, not after
} else {
Ok(left / right)
}
}
other => Err(CalcError::UnknownOperator(other)), // Required: match must cover every possible char
}
}

Step 4: Chain Parsing and Evaluation With ?

This is the step where the `?` operator does the most visible work. `calculate()` calls `parse_expression(input)?` — if parsing failed, `calculate()` returns that same `Err(CalcError::...)` immediately, and `evaluate(left, operator, right)` never even runs. Written without `?`, this function would need an explicit `match` on the result of `parse_expression()` just to unwrap the success case and re-wrap the failure case; `?` does exactly that unwrapping in a single character, for any function whose return type is a compatible `Result`.

// A thin function that chains Step 2 and Step 3 together. Note it does no
// printing itself -- it only ever returns a Result, leaving the decision of
// how to report success or failure entirely to the caller (main(), in Step 5).
fn calculate(input: &str) -> Result<f64, CalcError> {
let (left, operator, right) = parse_expression(input)?; // Short-circuits here if parsing failed
evaluate(left, operator, right) // Last expression, no semicolon: this is the return value
}
Example Usage

Click Run to see what this code prints.

Step 5: Build the REPL Loop

The REPL reads one line per iteration with `io::stdin().read_line(&mut buffer)`, and `match calculate(line.trim())` handles both outcomes explicitly: `Ok(result)` prints the answer, `Err(e)` prints `e` (routed through the `Display` implementation from Step 1, not the raw `Debug` form). Typing `exit` breaks the loop; nothing else — not a bad expression, not a division by zero — ever crashes the program, because every failure path returns a value instead of panicking.

use std::io::{self, Write};
fn main() {
println!("Rust Calculator -- type an expression like '3 + 4', or 'exit' to quit.");
loop {
print!("> ");
io::stdout().flush().unwrap(); // Ensure the "> " prompt is visible before read_line blocks for input
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
println!("Failed to read input, try again.");
continue;
}
let line = line.trim();
if line.eq_ignore_ascii_case("exit") {
println!("Goodbye!");
break;
}
if line.is_empty() {
continue; // Silently re-prompt on a blank line instead of reporting a parse error
}
match calculate(line) {
Ok(result) => println!("= {result}"),
Err(e) => println!("Error: {e}"), // {e} uses the Display impl from Step 1, not the raw enum Debug form
}
}
}

Complete Code

Here is the full program assembled in the correct order, ready to save as `src/main.rs` and run with `cargo run`.

use std::fmt;
use std::io::{self, Write};
#[derive(Debug)]
enum CalcError {
ParseError(String),
DivisionByZero,
UnknownOperator(char),
}
impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CalcError::ParseError(text) => write!(f, "could not parse '{text}' as a number"),
CalcError::DivisionByZero => write!(f, "division by zero"),
CalcError::UnknownOperator(op) => write!(f, "unknown operator '{op}'"),
}
}
}
impl std::error::Error for CalcError {}
fn parse_expression(input: &str) -> Result<(f64, char, f64), CalcError> {
let tokens: Vec<&str> = input.trim().split_whitespace().collect();
if tokens.len() != 3 {
return Err(CalcError::ParseError(input.to_string()));
}
let left: f64 = tokens[0].parse().map_err(|_| CalcError::ParseError(tokens[0].to_string()))?;
let right: f64 = tokens[2].parse().map_err(|_| CalcError::ParseError(tokens[2].to_string()))?;
let operator = tokens[1].chars().next().ok_or_else(|| CalcError::ParseError(tokens[1].to_string()))?;
if tokens[1].len() != 1 {
return Err(CalcError::ParseError(tokens[1].to_string()));
}
Ok((left, operator, right))
}
fn evaluate(left: f64, operator: char, right: f64) -> Result<f64, CalcError> {
match operator {
'+' => Ok(left + right),
'-' => Ok(left - right),
'*' => Ok(left * right),
'/' => {
if right == 0.0 {
Err(CalcError::DivisionByZero)
} else {
Ok(left / right)
}
}
other => Err(CalcError::UnknownOperator(other)),
}
}
fn calculate(input: &str) -> Result<f64, CalcError> {
let (left, operator, right) = parse_expression(input)?;
evaluate(left, operator, right)
}
fn main() {
println!("Rust Calculator -- type an expression like '3 + 4', or 'exit' to quit.");
loop {
print!("> ");
io::stdout().flush().unwrap();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
println!("Failed to read input, try again.");
continue;
}
let line = line.trim();
if line.eq_ignore_ascii_case("exit") {
println!("Goodbye!");
break;
}
if line.is_empty() {
continue;
}
match calculate(line) {
Ok(result) => println!("= {result}"),
Err(e) => println!("Error: {e}"),
}
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Support expressions with more than one operator (e.g. `3 + 4 * 2`) by writing a small tokenizer and applying standard operator precedence.
  • Add a `%` (modulo) operator to `evaluate()`, guarding against a zero divisor the same way `/` does.
  • Keep a `Vec<String>` history of every expression and result, and add a `history` command that prints it.
  • Support parentheses by converting the expression to postfix (Reverse Polish) notation before evaluating.
  • Replace `CalcError::ParseError(String)` with a variant that also records which token index failed, so error messages can point at the exact position.

Summary

You built a calculator where every possible failure — a bad number, an unknown operator, a division by zero — is a named variant of your own `CalcError` enum rather than a crash or a silently wrong answer. The `parse_expression()` → `evaluate()` → `calculate()` chain, threaded together with the `?` operator, is the same shape you will reach for anywhere a Rust program needs to perform several fallible steps in sequence and stop cleanly at the first one that fails.