Thought it would be fun to rebuild my pace-utils package in Rust.
Original npm package: @vaibhavt07/pace-utils
Rust CLI source: github.com/mrtyagi07/pace-cli
If you are coming from JavaScript or TypeScript, this is probably one of the easiest ways to start learning Rust.
Before starting, these 5 things are important to know:
- Ownership = who owns the data
- Borrowing = who can use the data temporarily
- Vector = a dynamic list of values
- Iterator = a stream that gives values one at a time
- Macro = not a normal function, it is code that writes code at compile time, marked with
!(e.g.println!)
Phase 1: Setup
Create a new project:
bash1cargo new pace-cli 2cd pace-cli 3ls
You should see:
text1Cargo.toml 2src/
cargo new is basically:
- npm init
- git init
- starter files
all in one command.
Open Cargo.toml:
bash1cat Cargo.toml
bash1[package] 2name = "pace-cli" 3version = "0.1.0" 4edition = "2024" 5 6[dependencies]
Cargo.toml is similar to package.json.
[package]contains project infoeditionis the Rust language version[dependencies]contains packages
We are keeping dependencies empty for now because Rust's standard library already gives us everything we need.
Now open src/main.rs:
bash1cat src/main.rs
You will see:
rust1fn main() { 2 println!("Hello, world!"); 3}
Two things worth noticing:
fndeclares a functionprintln!has a!because it is a macro
Rust uses ! to show that something is a macro and not a normal function.
Now run it:
bash1cargo run
You should see:
text1Compiling pace-cli v0.1.0 (...) 2Finished `dev` profile [unoptimized + debuginfo] 3Running `target/debug/pace-cli` 4Hello, world!
cargo run compiles and runs the project together.
The compiled binary lives inside:
text1target/debug/
You will also notice a new target/ folder.
That contains all build files and is automatically gitignored.
Three commands you will use constantly:
bash1cargo run
Compile and run.
bash1cargo check
Only type checks. Faster than a full build.
bash1cargo build --release
Creates an optimized production build.
That's it.
You already have a working Rust CLI.
Phase 2: Translate format_pace
Replace src/main.rs with this:
rust1fn format_pace(seconds_per_km: u32) -> String { 2 let minutes = seconds_per_km / 60; 3 let seconds = seconds_per_km % 60; 4 5 format!("{}:{:02} /km", minutes, seconds) 6} 7 8fn main() { 9 let pace = format_pace(330); 10 11 println!("{}", pace); 12}
Run:
bash1cargo run
Output:
text15:30 /km
Simple.
Now look carefully at the function:
rust1fn format_pace(seconds_per_km: u32) -> String
The important part is u32.
u32 means:
- unsigned
- 32 bit integer
- cannot be negative
- cannot be decimal
- cannot be NaN
In TypeScript, you probably had validation like this:
ts1if (!Number.isFinite(secondsPerKm) || secondsPerKm < 0) { 2 throw new RangeError(...) 3}
Rust removes that entire validation block because the type system already prevents invalid input.
This is a very common Rust pattern:
Make invalid states impossible.
Now the function body:
rust1let minutes = seconds_per_km / 60; 2let seconds = seconds_per_km % 60;
No Math.floor needed.
Integer division in Rust already floors automatically.
rust17 / 2 = 3
Next:
rust1format!("{}:{:02} /km", minutes, seconds)
format! is similar to template literals.
But Rust checks the format string at compile time.
This:
rust1{:02}
means:
- width 2
- pad with zeros
So:
text15 -> 05
One more important thing:
rust1format!("{}:{:02} /km", minutes, seconds)
No semicolon.
In Rust, the last expression becomes the return value.
If you add a semicolon, it stops returning the value.
You will forget this multiple times.
The compiler will yell.
Try different inputs:
rust1fn main() { 2 println!("{}", format_pace(285)); 3 println!("{}", format_pace(305)); 4 println!("{}", format_pace(60)); 5}
Expected:
text14:45 /km 25:05 /km 31:00 /km
Now try:
rust1format_pace(-1)
The compiler will refuse to build.
That is the Rust type system doing its job.
Phase 3: Read from the command line
We want this:
bash1cargo run -- 330 2cargo run -- 285 3cargo run -- hello 4cargo run
Replace main.rs with this:
rust1use std::env; 2use std::process; 3 4fn format_pace(seconds_per_km: u32) -> String { 5 let minutes = seconds_per_km / 60; 6 let seconds = seconds_per_km % 60; 7 8 format!("{}:{:02} /km", minutes, seconds) 9} 10 11fn main() { 12 let args: Vec<String> = env::args().collect(); 13 14 if args.len() < 2 { 15 eprintln!("usage: pace-cli <seconds-per-km>"); 16 process::exit(1); 17 } 18 19 let seconds_per_km: u32 = match args[1].parse() { 20 Ok(n) => n, 21 Err(_) => { 22 eprintln!("error: '{}' is not a valid non-negative integer", args[1]); 23 process::exit(1); 24 } 25 }; 26 27 println!("{}", format_pace(seconds_per_km)); 28}
Now run:
bash1cargo run -- 330 2cargo run -- hello 3cargo run
env::args() returns an iterator.
An iterator gives values one by one instead of all at once.
Then:
rust1.collect()
converts that iterator into a vector.
rust1Vec<String>
means:
Vec= dynamic arrayString= owned string data
So this:
rust1let args: Vec<String> = env::args().collect();
means:
collect all command line arguments into a vector.
Now this is important:
rust1match args[1].parse()
.parse() returns a Result.
A Result has two possible values:
rust1Ok(value)
or:
rust1Err(error)
Rust forces you to handle both cases.
That is why we use match:
rust1match args[1].parse() { 2 Ok(n) => n, 3 Err(_) => { 4 process::exit(1); 5 } 6}
Reading it:
- if parsing succeeds, return the number
- if parsing fails, exit the program
Rust will not let you ignore possible failures.
That is one of the biggest differences from JavaScript.
Also:
rust1eprintln!
prints to stderr instead of stdout.
Useful for CLI tools.
Phase 4: Add subcommands and the ? operator
Replace main.rs with this:
rust1use std::env; 2use std::process; 3 4fn format_pace(seconds_per_km: u32) -> String { 5 let minutes = seconds_per_km / 60; 6 let seconds = seconds_per_km % 60; 7 8 format!("{}:{:02} /km", minutes, seconds) 9} 10 11fn format_duration(seconds: u32) -> String { 12 let h = seconds / 3600; 13 let m = (seconds % 3600) / 60; 14 let s = seconds % 60; 15 16 if h > 0 { 17 format!("{}h {}m {}s", h, m, s) 18 } else if m > 0 { 19 format!("{}m {}s", m, s) 20 } else { 21 format!("{}s", s) 22 } 23} 24 25fn main() -> Result<(), Box<dyn std::error::Error>> { 26 let args: Vec<String> = env::args().collect(); 27 28 if args.len() < 3 { 29 eprintln!("usage: pace-cli <pace|dur> <seconds>"); 30 process::exit(1); 31 } 32 33 let value: u32 = args[2].parse()?; 34 35 match args[1].as_str() { 36 "pace" => println!("{}", format_pace(value)), 37 "dur" => println!("{}", format_duration(value)), 38 other => { 39 eprintln!("unknown command '{}'", other); 40 process::exit(1); 41 } 42 } 43 44 Ok(()) 45}
Run:
bash1cargo run -- pace 330 2cargo run -- dur 5025 3cargo run -- bad 100
Now the important part:
rust1let value: u32 = args[2].parse()?;
The ? operator means:
- if success, unwrap the value
- if failure, return the error immediately
Without ?, you would manually write a full match block.
So this:
rust1args[2].parse()?
is basically shorthand for:
rust1match args[2].parse() { 2 Ok(v) => v, 3 Err(e) => return Err(e.into()) 4}
Very common Rust pattern.
Also notice:
rust1fn main() -> Result<(), Box<dyn std::error::Error>>
Now main itself returns a Result.
That allows ? to work.
The success case is:
rust1Ok(())
which basically means:
text1program succeeded
One more thing:
rust1args[1].as_str()
converts String into &str.
&str is a borrowed string slice.
No copying happens.
Rust just creates a lightweight view into the existing string.
We need .as_str() here because the match arms below compare against string literals like "pace", which are &str — not String.
Phase 5: Tests
Add this at the bottom of main.rs:
rust1#[cfg(test)] 2mod tests { 3 use super::*; 4 5 #[test] 6 fn pace_formats_whole_minutes_per_km() { 7 assert_eq!(format_pace(330), "5:30 /km"); 8 } 9 10 #[test] 11 fn pace_pads_single_digit_seconds() { 12 assert_eq!(format_pace(305), "5:05 /km"); 13 } 14 15 #[test] 16 fn duration_formats_hours_minutes_seconds() { 17 assert_eq!(format_duration(5025), "1h 23m 45s"); 18 } 19 20 #[test] 21 fn duration_drops_the_hour_when_zero() { 22 assert_eq!(format_duration(125), "2m 5s"); 23 } 24 25 #[test] 26 fn duration_returns_seconds_only_for_short_durations() { 27 assert_eq!(format_duration(30), "30s"); 28 } 29}
Run:
bash1cargo test
You should see all tests passing.
Rust ships with testing built into the language.
No Vitest installation.
No separate config.
Now this is interesting.
In TypeScript, you probably had a test like this:
ts1expect(() => formatPace(-1)).toThrow()
That test does not exist anymore.
Because:
rust1format_pace(-1)
does not compile.
The compiler already guarantees it.
This is something you will notice a lot in Rust:
Better types reduce runtime checks and even remove entire tests.
That is one of the biggest reasons Rust feels so safe.
Final thoughts
This project is tiny.
But inside this small CLI, you already learned:
- functions
- macros
- ownership
- borrowing
- vectors
- iterators
- pattern matching
- Result
- error handling
- the
?operator - testing
That is actually a huge amount of Rust.
The biggest thing to understand is this:
Rust pushes problems to compile time.
At first it feels annoying.
Later you realize the compiler is basically reviewing your code while you write it.
And honestly, that is when Rust becomes fun.
Comments
No login needed. Be kind.
- No comments yet. Be the first.