Rewriting my pace-utils npm package into Rust

about 19 hours ago

21 views

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:

bash
1cargo new pace-cli 2cd pace-cli 3ls

You should see:

text
1Cargo.toml 2src/

cargo new is basically:

  • npm init
  • git init
  • starter files

all in one command.

Open Cargo.toml:

bash
1cat Cargo.toml
bash
1[package] 2name = "pace-cli" 3version = "0.1.0" 4edition = "2024" 5 6[dependencies]

Cargo.toml is similar to package.json.

  • [package] contains project info
  • edition is 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:

bash
1cat src/main.rs

You will see:

rust
1fn main() { 2 println!("Hello, world!"); 3}

Two things worth noticing:

  • fn declares a function
  • println! has a ! because it is a macro

Rust uses ! to show that something is a macro and not a normal function.

Now run it:

bash
1cargo run

You should see:

text
1Compiling 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:

text
1target/debug/

You will also notice a new target/ folder.

That contains all build files and is automatically gitignored.

Three commands you will use constantly:

bash
1cargo run

Compile and run.

bash
1cargo check

Only type checks. Faster than a full build.

bash
1cargo 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:

rust
1fn 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:

bash
1cargo run

Output:

text
15:30 /km

Simple.

Now look carefully at the function:

rust
1fn 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:

ts
1if (!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:

rust
1let minutes = seconds_per_km / 60; 2let seconds = seconds_per_km % 60;

No Math.floor needed.

Integer division in Rust already floors automatically.

rust
17 / 2 = 3

Next:

rust
1format!("{}:{:02} /km", minutes, seconds)

format! is similar to template literals.

But Rust checks the format string at compile time.

This:

rust
1{:02}

means:

  • width 2
  • pad with zeros

So:

text
15 -> 05

One more important thing:

rust
1format!("{}:{: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:

rust
1fn main() { 2 println!("{}", format_pace(285)); 3 println!("{}", format_pace(305)); 4 println!("{}", format_pace(60)); 5}

Expected:

text
14:45 /km 25:05 /km 31:00 /km

Now try:

rust
1format_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:

bash
1cargo run -- 330 2cargo run -- 285 3cargo run -- hello 4cargo run

Replace main.rs with this:

rust
1use 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:

bash
1cargo 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:

rust
1.collect()

converts that iterator into a vector.

rust
1Vec<String>

means:

  • Vec = dynamic array
  • String = owned string data

So this:

rust
1let args: Vec<String> = env::args().collect();

means:

collect all command line arguments into a vector.

Now this is important:

rust
1match args[1].parse()

.parse() returns a Result.

A Result has two possible values:

rust
1Ok(value)

or:

rust
1Err(error)

Rust forces you to handle both cases.

That is why we use match:

rust
1match 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:

rust
1eprintln!

prints to stderr instead of stdout.

Useful for CLI tools.

Phase 4: Add subcommands and the ? operator

Replace main.rs with this:

rust
1use 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:

bash
1cargo run -- pace 330 2cargo run -- dur 5025 3cargo run -- bad 100

Now the important part:

rust
1let 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:

rust
1args[2].parse()?

is basically shorthand for:

rust
1match args[2].parse() { 2 Ok(v) => v, 3 Err(e) => return Err(e.into()) 4}

Very common Rust pattern.

Also notice:

rust
1fn main() -> Result<(), Box<dyn std::error::Error>>

Now main itself returns a Result.

That allows ? to work.

The success case is:

rust
1Ok(())

which basically means:

text
1program succeeded

One more thing:

rust
1args[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:

rust
1#[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:

bash
1cargo 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:

ts
1expect(() => formatPace(-1)).toThrow()

That test does not exist anymore.

Because:

rust
1format_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.

0/2000
  • No comments yet. Be the first.