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.
cargo new calc
cd calcThat scaffolds Cargo.toml and src/main.rs. Three commands cover the entire development loop.
cargo check # type check only, fastest feedback
cargo run # compile and execute a debug build
cargo test # run the test suitecargo 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.
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.
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.
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.
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.
Putting it together, the naive calculator:
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.
| Input | What the naive version does | Why | The idiomatic fix |
|---|---|---|---|
| 5 / 0 | prints inf | IEEE 754 floats define division by zero as infinity, so no panic fires | Check the divisor and return an error |
| 5 + abc | panics with a stack trace | expect unwraps an error returned by parse | Propagate the Result and print a clean message |
| 2 + 3 * 4 | prints 5 | Five tokens arrive, the code reads exactly three, and the multiplication is silently discarded | Parse with precedence across all tokens |
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.
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.
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.
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.
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.
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.
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" # 5This 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.
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.