RustBlockchain11 min readUpdated

Build a Command Line Calculator in Rust

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: Build a Command Line Calculator in Rust

Quick answer

How do you build a calculator in Rust? Create a project with cargo new, read the expression from command line arguments, split it into a left operand, an operator, and a right operand, parse the operands as floating point numbers, then use a match statement on the operator to apply the right arithmetic. Return a Result so parse failures and division by zero become handled values.

Section 01 · The target

What you are building

A command line calculator: you type an expression in a terminal and it prints the answer. Roughly eighty lines of Rust by the end, with no dependencies outside the standard library.

Which Rust this is

The name is overloaded. This is the Rust programming language, the systems language with the borrow checker. Nothing here involves the survival game of the same name.

The build is worth doing because a calculator is the smallest program that forces you to confront the three things that make Rust different. You take input you do not control, so you meet Result. You branch on a value, so you meet match. You hand a string to a function, so you meet borrowing.

If you have read about ownership but not yet used it, start with learning Rust as a working engineer and come back.

Section 02 · Cargo

How to run Rust code from the CLI

Cargo is the tool. It creates the project, resolves dependencies, compiles, and runs.

bash
cargo new calc
cd calc

That scaffolds Cargo.toml and src/main.rs. Three commands cover the entire development loop.

bash
cargo check    # type check only, fastest feedback
cargo run      # compile and execute a debug build
cargo test     # run the test suite

cargo run builds an unoptimized binary with debug assertions on. That matters here: integer overflow panics in debug and wraps in release, so you want debug while you are learning what your code actually does.

To pass arguments through cargo to your program, separate them with a double dash.

bash
cargo run -- "12 * 4"

Section 03 · Input

Reading input from the command line

The args iterator yields the arguments, and the first item is the program path, so skip it.

rust
use std::env;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();

    if args.is_empty() {
        eprintln!("usage: calc \"2 + 3\"");
        std::process::exit(1);
    }

    let expression = args.join(" ");
    println!("{expression}");
}

eprintln! writes to stderr rather than stdout. Errors belong on stderr so that a caller piping your output into another program gets only the result. Exiting with a nonzero code tells the shell the run failed, which is what CI runners check.

Section 04 · Parsing

Parsing strings into numbers with Result

Splitting on whitespace gives three tokens for a simple binary expression.

rust
let tokens: Vec<&str> = expression.split_whitespace().collect();

if tokens.len() != 3 {
    eprintln!("expected: <number> <operator> <number>");
    std::process::exit(1);
}

Note the type. Each token is a borrowed slice pointing into the original expression. Nothing is copied. The borrow checker guarantees the expression outlives the slices, which is why this compiles without a lifetime annotation.

Parsing is where Rust refuses to let you be careless. parse returns a Result, never a bare number. We parse into f64 rather than an integer so that seven divided by two yields 3.5.

If you were sizing numeric types for on chain state instead, where every byte costs rent, the tradeoffs are different and worth reading in Rust data types for Solana.

Section 05 · Dispatch

The Rust match statement dispatches the operator

match compares a value against a series of patterns and runs the arm that fits. It is a switch that cannot fall through, cannot forget a case, and produces a value.

rust
let result = match operator {
    "+" => left + right,
    "-" => left - right,
    "*" => left * right,
    "/" => left / right,
    _ => {
        eprintln!("unknown operator: {operator}");
        std::process::exit(1);
    }
};

The underscore arm is the wildcard. Without it this does not compile, because a string slice has infinitely many possible values and match demands you cover all of them. On an enum, where the cases are finite, you can omit the wildcard and the compiler will name any variant you missed. That property is the reason experienced Rust programmers reach for enums early: adding a variant later turns every unhandled site into a compile error rather than a silent fallthrough.

Every arm must produce the same type. The arms here produce a float; the wildcard arm produces the never type, because exiting does not return. Rust accepts it anywhere because a branch that never yields a value cannot yield the wrong one.

A raw expression string is split into tokens, parsed into numbers with Result, dispatched by a match on the operator, and evaluated to a value or a clean error.
One untrusted string in, a Result out.

Putting it together, the naive calculator:

rust
use std::env;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();
    let expression = args.join(" ");
    let tokens: Vec<&str> = expression.split_whitespace().collect();

    let left: f64 = tokens[0].parse().expect("bad left operand");
    let operator = tokens[1];
    let right: f64 = tokens[2].parse().expect("bad right operand");

    let result = match operator {
        "+" => left + right,
        "-" => left - right,
        "*" => left * right,
        "/" => left / right,
        _ => panic!("unknown operator"),
    };

    println!("{result}");
}

It works. Running it against twelve times four prints 48. Now break it.

Section 06 · Failure

Where the naive version breaks

Three inputs. Every tutorial that stops at the code above ships all three bugs.

Three inputs that break the naive calculator
InputWhat the naive version doesWhyThe idiomatic fix
5 / 0prints infIEEE 754 floats define division by zero as infinity, so no panic firesCheck the divisor and return an error
5 + abcpanics with a stack traceexpect unwraps an error returned by parsePropagate the Result and print a clean message
2 + 3 * 4prints 5Five tokens arrive, the code reads exactly three, and the multiplication is silently discardedParse with precedence across all tokens
Three inputs break the naive calculator: five divided by zero prints infinity, two plus three times four prints five, and five plus abc panics. Two of the three fail silently.
Two of the three failures are silent, which is what makes them expensive.

Two of these three are silent. Dividing five by zero prints infinity because IEEE 754 says so, and that infinity then flows through every later calculation until it surfaces as a nonsense number in a report. The mixed precedence expression prints five because the code indexes the first three tokens and never looks at the rest, so the multiplication vanishes without complaint.

Only the letter case fails loudly, and it fails in the worst way available: a panic with a stack trace, which is what expect does to an error. A crash at least tells you something is wrong. A wrong answer that looks right does not.

Section 07 · Recovery

Handling divide by zero and bad input

Move the arithmetic into a function that returns a Result. Now failure is a value the caller must handle.

rust
fn evaluate(left: f64, operator: &str, right: f64) -> Result<f64, String> {
    match operator {
        "+" => Ok(left + right),
        "-" => Ok(left - right),
        "*" => Ok(left * right),
        "/" => {
            if right == 0.0 {
                Err("division by zero".to_string())
            } else {
                Ok(left / right)
            }
        }
        _ => Err(format!("unknown operator: {operator}")),
    }
}

Parsing gets the same treatment. map_err converts the parse error into our error type, and the question mark operator propagates it.

rust
fn parse_operand(token: &str) -> Result<f64, String> {
    token
        .parse::<f64>()
        .map_err(|_| format!("not a number: {token}"))
}

Now main becomes the only place that talks to the outside world.

rust
fn main() {
    let args: Vec<String> = env::args().skip(1).collect();
    let expression = args.join(" ");

    match run(&expression) {
        Ok(value) => println!("{value}"),
        Err(message) => {
            eprintln!("error: {message}");
            std::process::exit(1);
        }
    }
}

Three inputs, three clean messages, no stack traces. The run function takes a string slice rather than an owned string because it only reads the expression. Ownership stays in main.

Section 08 · Precedence

Adding operator precedence

Two plus three times four should print 14, not 20. Multiplication binds tighter than addition, which means a flat left to right scan gives the wrong answer.

The smallest correct approach is two passes over the token list. First resolve every multiplication and division, then resolve every addition and subtraction across what remains.

rust
fn evaluate_tokens(tokens: &[&str]) -> Result<f64, String> {
    let mut values: Vec<f64> = vec![parse_operand(tokens[0])?];
    let mut operators: Vec<&str> = Vec::new();

    let mut i = 1;
    while i < tokens.len() {
        let operator = tokens[i];
        let operand = parse_operand(tokens[i + 1])?;

        match operator {
            "*" | "/" => {
                let left = values.pop().ok_or("missing left operand")?;
                values.push(evaluate(left, operator, operand)?);
            }
            "+" | "-" => {
                values.push(operand);
                operators.push(operator);
            }
            _ => return Err(format!("unknown operator: {operator}")),
        }

        i += 2;
    }

    let mut total = values[0];
    for (index, operator) in operators.iter().enumerate() {
        total = evaluate(total, operator, values[index + 1])?;
    }

    Ok(total)
}

Two details earn their place. The multiplication arm is an or pattern, matching either operator in one branch. And ok_or turns an Option into a Result, so a malformed expression that empties the stack becomes an error rather than a panic.

The run function now hands every token to evaluate_tokens instead of reading exactly three. A valid expression always has an odd token count, alternating operand and operator, so that is the check to make.

rust
fn run(expression: &str) -> Result<f64, String> {
    let tokens: Vec<&str> = expression.split_whitespace().collect();

    if tokens.len() < 3 || tokens.len() % 2 == 0 {
        return Err("expected: <number> <operator> <number>".to_string());
    }

    evaluate_tokens(&tokens)
}

Verify it against the three inputs that broke the naive version, plus a chained one to confirm that subtraction still associates left to right.

bash
cargo run -- "2 + 3 * 4"      # 14
cargo run -- "5 / 0"          # error: division by zero
cargo run -- "5 + abc"        # error: not a number: abc
cargo run -- "10 - 2 - 3"     # 5

This handles the four operators at two precedence levels. Parentheses need a real parser, and the standard answer is the shunting yard algorithm or a small recursive descent parser. Both are a reasonable next project.

Section 09 · Ship

Building and shipping the release binary

Debug builds are slow by design. The optimized binary comes from one flag.

bash
cargo build --release
./target/release/calc "2 + 3 * 4"

Two things change in release mode. Optimizations turn on, so the binary runs considerably faster. Debug assertions turn off, which means integer overflow wraps silently instead of panicking. Our calculator uses floating point throughout so overflow is not a concern here, but if you swap to integers, test in both profiles.

The binary in the release directory is statically linked against the Rust standard library and depends only on the system C library. Copy it to a machine with the same architecture and it runs, with no runtime to install.

Wrap up

Where to go next

You have a program that reads untrusted input, parses it, dispatches with match, and fails with a message instead of a stack trace. That is the shape of most command line tools.

Extend it in one of three directions. Add parentheses with a recursive descent parser. Add a read evaluate print loop so it stays open between expressions. Or write tests: the evaluate function is a pure function of three arguments and is trivially testable, which is exactly why it was worth pulling out of main.

The same ownership and error discipline governs on chain Rust, where a panic costs a failed transaction rather than a terminal message. If that is where you are headed, Anchor is the framework that wraps it, and the equivalent exercise in Solidity is the calculator smart contract tutorial.

If your team is evaluating Rust for production systems or on chain work, my blockchain development practice helps teams make that call before they commit an engineering quarter to it.

FAQ

Frequently asked questions

How do I run Rust code from the CLI?

Use cargo run inside a project directory created by cargo new. It compiles a debug build and executes it in one step. Pass arguments to your program by putting them after a double dash. Use cargo build --release when you want the optimized binary, which lands in the target release directory.

What does the match statement do in Rust?

Match compares a value against patterns and runs the first arm that fits, producing a value. Unlike a C style switch it cannot fall through and it must be exhaustive: if you match on an enum and forget a variant, the code does not compile. That exhaustiveness is what makes match the safe way to dispatch on an operator, a state, or an error kind.

How do you handle divide by zero in a Rust calculator?

Check the divisor before dividing and return an error rather than dividing. Floating point division by zero does not panic in Rust; it yields infinity, following IEEE 754. That silent result is more dangerous than a crash because it propagates through later arithmetic. For integers, division by zero does panic, so a check is required there too.

Why does my Rust program panic on bad input?

Because unwrap or expect was called on a Result that turned out to be an error. Both convert a recoverable error into an immediate panic. Replace them with the question mark operator to propagate the error to the caller, or with a match that handles both branches. Reserve expect for cases where failure genuinely indicates a bug.

Do I need external crates to build a calculator in Rust?

No. Everything here uses the standard library: std env for arguments, str parse for numbers, and match for dispatch. Once you add parentheses, flags, or subcommands, clap becomes worth the dependency for argument parsing, and pest or nom for expression grammars.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View Blockchain Development serviceSee ChainTrust Compliance Engine case study

Related service

Blockchain Development

See scope & pricing →

Related case study

ChainTrust Compliance Engine

Read case study →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →