LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.