DEV Community

Cover image for Rust for Gophers, Lesson 1: Hello, Cargo
Syam SV
Syam SV

Posted on

Rust for Gophers, Lesson 1: Hello, Cargo

The roadmap

We go from hello world to an async web service with a database in 16 lessons, and every lesson ends with a program you write by hand. Each phase builds the muscle the next one needs: you cannot write a sane async server until ownership and traits feel natural, so we don't rush there.

# Lesson What you build Go concept it replaces
1 Toolchain, basics, tests (today) greet + fizzbuzz with tests go mod, go test, gofmt, go vet
2 Ownership, borrowing, slices, String vs &str Word counter GC + pointers
3 Structs, enums, match, Option Stack data structure structs, nil, iota
4 Error handling: Result, ?, custom errors Base64 encoder/decoder if err != nil, errors.Is
5 Traits and generics Generic stack + Display/Iterator impls interfaces, generics
6 Collections and iterators Log file analyzer for range, maps, slices
7 Modules, crates, workspaces Split the base64 tool into lib + bin packages, internal/
8 Lifetimes in depth Zero-copy tokenizer escape analysis
9 Real CLIs: clap, anyhow, thiserror grep-like CLI tool flag, cobra
10 Threads: std::thread, Arc, Mutex, channels Parallel file hasher goroutines, sync, chans
11 Send/Sync and fearless concurrency Thread pool from scratch sync.WaitGroup, worker pools
12 Raw HTTP over TcpListener Tiny HTTP/1.1 server by hand net package
13 Async Rust: futures, tokio Async chat server goroutines + select
14 Web with axum JSON REST API net/http, chi/gin
15 Databases with sqlx + Postgres CRUD service database/sql, pgx
16 Production polish: tracing, config, testing, CI Ship the CRUD service slog, testing, CI

How each lesson works: I explain the concepts, you write the exercise yourself and paste your code here, then I review it like a senior engineer would on a PR. I give hints before answers, and a full solution only if you ask for one.

1. Setup and tooling

Install Rust with rustup, the official toolchain manager. It is like having go plus a version manager in one tool.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup component add rustfmt clippy   # usually installed by default
rustc --version && cargo --version
Enter fullscreen mode Exit fullscreen mode

The pieces you get:

Tool What it does Go equivalent
rustup Installs and switches toolchains (stable, beta, nightly) go install golang.org/dl/...
rustc The compiler. You almost never call it directly go tool compile
cargo Build, run, test, deps, docs, publish go command
rustfmt (cargo fmt) The one true formatter gofmt
clippy (cargo clippy) 700+ lints for bugs and non-idiomatic code go vet + staticcheck
rust-analyzer Language server for your editor gopls

Install the rust-analyzer extension in your editor now. It shows inferred types inline, which is the single biggest help while learning.

Creating a project

cargo new hello_rust      # binary crate (has main.rs)
cargo new mylib --lib     # library crate (has lib.rs)
Enter fullscreen mode Exit fullscreen mode
hello_rust/
├── Cargo.toml     # manifest: name, version, edition, dependencies (like go.mod)
├── Cargo.lock     # exact resolved versions (like go.sum); commit it for binaries
├── src/
│   └── main.rs    # crate root for a binary
└── target/        # build output; git-ignored
Enter fullscreen mode Exit fullscreen mode
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2024"

[dependencies]
Enter fullscreen mode Exit fullscreen mode

The edition is a Rust-specific idea. Editions (2015, 2018, 2021, 2024) let the language make small breaking syntax changes without breaking old code, and crates of different editions link together fine. Always use the latest for new code.

The commands you will type every day

Command What it does
cargo check Type-checks without producing a binary. Fastest feedback; run it constantly
cargo build / cargo build --release Debug build (fast compile, slow code) / optimized build
cargo run -- arg1 arg2 Build and run; everything after -- goes to your program
cargo test Runs unit, integration and doc tests
cargo fmt Formats all code
cargo clippy -- -D warnings Lints, and treats warnings as errors (what CI should do)
cargo doc --open Builds HTML docs for your crate and all its dependencies
cargo add serde Adds a dependency to Cargo.toml

Industry habit: before every commit run cargo fmt && cargo clippy -- -D warnings && cargo test. Clippy is a teacher, not a nag; read every lint it gives you, because each one links to an explanation.

2. Hello world, dissected

fn main() {
    println!("Hello, world!");
}
Enter fullscreen mode Exit fullscreen mode

Three lines, and four ideas worth understanding properly.

fn main() is the entry point. Like Go's func main() but with no package main line: the crate root file (src/main.rs) plays that role. main can also return Result<(), E>, which we will use from Lesson 4 so errors exit with a non-zero code.

println! is a macro, not a function. The ! marks a macro call. Macros run at compile time and generate code. That is why println! can take a variable number of arguments and check your format string at compile time: println!("{} {}", a) is a compile error, while Go's fmt.Printf only catches that in go vet. Formatting basics:

let name = "Syam";
let pi = 3.14159;
println!("Hi {name}");          // inline variable capture
println!("Hi {}", name);        // positional
println!("{pi:.2}");            // 3.14, like %.2f
println!("{:?}", [1, 2, 3]);    // Debug output, like %v
println!("{:#?}", some_struct); // pretty Debug, like %+v on multiple lines
eprintln!("to stderr");         // like fmt.Fprintln(os.Stderr, ...)
let s = format!("{name}!");     // like fmt.Sprintf
Enter fullscreen mode Exit fullscreen mode

{} uses the Display trait (human-facing output) and {:?} uses Debug (developer-facing). A type must opt in to each one; we cover traits in Lesson 5.

Semicolons matter. They turn an expression into a statement. This sounds trivial, but it is the key to the next section.

The compile model. cargo build compiles your whole crate as one unit and produces a single native binary with no runtime and no garbage collector, so there is no GC pause and the binary starts instantly. The price is slower compiles than Go. That is why cargo check exists: it skips code generation and is usually several times faster.

3. Core basics, the Go programmer's way

Variables are immutable by default

let x = 5;         // immutable binding, type inferred as i32
// x = 6;          // compile error: cannot assign twice to immutable variable
let mut y = 5;     // opt in to mutation
y += 1;
const MAX_USERS: u32 = 1_000;  // compile-time constant, type always required
Enter fullscreen mode Exit fullscreen mode

In Go everything is mutable and you trust yourself. In Rust, mut is a signal to the reader: "this value changes, watch it." Industry style is to make things mutable only when you need to; clippy and the compiler warn about unneeded mut.

Shadowing

let spaces = "   ";          // &str
let spaces = spaces.len();   // new variable, now usize; the old one is hidden
Enter fullscreen mode Exit fullscreen mode

This is not mutation; it creates a new binding, and the type can change. It is idiomatic for transforming a value step by step (parse input, then validate it) without inventing names like spacesStr and spacesLen. Go forbids this in the same scope.

Scalar types

Rust Go Notes
i8 i16 i32 i64 i128 int8 … int64 i32 is the default integer
u8 u16 u32 u64 u128 uint8 … uint64 u8 is a byte
isize / usize int / uint Pointer-sized. Indexes and lengths are always usize
f32 / f64 float32 / float64 f64 is the default
bool bool
char rune 4 bytes, one Unicode scalar value

Two differences that bite Go programmers:

  • No implicit conversions, ever. Not even i32 to i64. You write x as i64 for a lossy/primitive cast, or i64::from(x) for a lossless one. Prefer from/try_from in real code because as silently truncates (300_i32 as u8 is 44).
  • Integer overflow is a bug, not a feature. In debug builds u8::MAX + 1 panics; in release it wraps. When you want a specific behaviour say so: checked_add (returns Option), wrapping_add, saturating_add.

Compound types

let t: (i32, &str, f64) = (1, "two", 3.0); // tuple, fixed size, mixed types
let (a, b, c) = t;                         // destructuring
let first = t.0;
let arr: [i32; 3] = [1, 2, 3];             // fixed-size array, like Go's [3]int
let zeros = [0u8; 1024];                   // 1024 zero bytes
Enter fullscreen mode Exit fullscreen mode

The growable list (Go's slice) is Vec<T>, and the empty tuple () ("unit") is what functions return when they return nothing.

Functions, and the big idea: everything is an expression

fn add(a: i32, b: i32) -> i32 {
    a + b      // no semicolon: this is the return value
}
Enter fullscreen mode Exit fullscreen mode

Parameter and return types are always written; Rust never infers function signatures, so a signature is a contract you can read. The last expression in a block, written without a semicolon, is the block's value. Add a ; and it becomes a statement that evaluates to (), giving you the classic beginner error expected i32, found (). Use return only for early exits.

Because blocks are expressions, if and match produce values. Rust has no ternary operator because it doesn't need one:

let name = if args.len() > 1 { &args[1] } else { "world" };
Enter fullscreen mode Exit fullscreen mode

Control flow

// loop: infinite loop that can return a value via break
let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;
    }
};

// while
while counter > 0 {
    counter -= 1;
}

// for over a range: 1..5 is 1 to 4, 1..=5 includes 5
for i in 1..=5 {
    println!("{i}");
}

// for over a collection, like `for _, v := range items`
for item in &items { /* borrow each */ }
for (i, item) in items.iter().enumerate() { /* like `for i, v := range` */ }
Enter fullscreen mode Exit fullscreen mode

There is no C-style for i := 0; i < n; i++. Ranges and iterators replace it, and they remove off-by-one and out-of-bounds bugs.

match: switch with superpowers

fn describe(code: u16) -> &'static str {
    match code {
        200 => "ok",
        301 | 302 => "redirect",
        400..=499 => "client error",
        500..=599 => "server error",
        _ => "unknown",
    }
}
Enter fullscreen mode Exit fullscreen mode

match must be exhaustive: the compiler refuses to build if a case is missing, which is why _ (the catch-all) exists. Unlike Go's switch, there is no fallthrough and arms are expressions. This becomes enormously powerful with enums in Lesson 3.

Ignore the &'static str for now. It means "a reference to a string that lives for the whole program", which is what string literals are. Lesson 2 is entirely about what & means.

4. Testing from day one

Rust's convention is to put unit tests in the same file as the code, inside a tests module that only compiles under cargo test. Go puts them in a separate _test.go file; Rust keeps them next to the code they check.

fn greeting(name: &str) -> String {
    format!("Hello, {name}!")
}

#[cfg(test)]          // compile this module only when testing
mod tests {
    use super::*;     // bring the parent module's items into scope

    #[test]
    fn greets_by_name() {
        assert_eq!(greeting("Syam"), "Hello, Syam!");
    }

    #[test]
    #[should_panic]
    fn overflow_panics_in_debug() {
        let x: u8 = 255;
        let _ = x + std::hint::black_box(1);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • assert_eq!(left, right) and assert_ne! print both values on failure; assert!(cond, "message {x}") for anything else. There is no t.Errorf: a failing assertion panics, and the test runner catches it.
  • Tests run in parallel threads by default, like t.Parallel() everywhere.
  • cargo test greets runs only tests whose name contains greets, like go test -run.
  • cargo test -- --nocapture shows println! output, which is hidden for passing tests.
  • Unit tests can call private functions, because the tests module is a child of the module under test. Integration tests (Lesson 7) live in a top-level tests/ directory and only see your public API, like Go's package foo_test.

5. Your exercise: fizzbuzz CLI

Build a small CLI that takes a number and prints FizzBuzz up to it, with tests, and passes fmt and clippy cleanly. Write it yourself, no AI autocompletion, and keep the compiler's error messages open: reading them is half the skill.

cargo new fizz
cd fizz
cargo run -- 15     # prints 1, 2, Fizz, 4, Buzz, ... FizzBuzz
cargo run           # prints to stderr: usage: fizz <N>   and exits with code 1
cargo run -- abc    # prints to stderr: error: 'abc' is not a valid number   exit code 1
Enter fullscreen mode Exit fullscreen mode

Requirements

  • [ ] fn fizzbuzz(n: u32) -> String: "Fizz" for multiples of 3, "Buzz" for 5, "FizzBuzz" for both, otherwise the number. Implement it with a single match, not an if/else chain.
  • [ ] fn sum_of_multiples(limit: u32) -> u64: the sum of all numbers below limit divisible by 3 or 5 (for 10 it is 23). Note the return type is wider than the input; convert without as.
  • [ ] main reads the first argument with std::env::args(), parses it to u32, and handles a missing or invalid argument as shown above using eprintln! and std::process::exit(1).
  • [ ] After the FizzBuzz lines, print sum of multiples below N: X.
  • [ ] At least 6 unit tests, including edge cases: 0, 1, 3, 5, 15, and a large number for sum_of_multiples.
  • [ ] cargo fmt --check && cargo clippy -- -D warnings && cargo test all pass.
  • [ ] No mut you don't need, and no unwrap().

Hints (read only when stuck)

  1. You can match on a tuple: match (n % 3, n % 5) { (0, 0) => ..., ... }. Think about which arm order is correct and what _ patterns do inside a tuple.
  2. To turn a number into a String, look up to_string(). Notice every arm of a match must have the same type.
  3. std::env::args().nth(1) returns an Option<String>, which is either Some(value) or None. You can match on it exactly like a u16 above.
  4. "42".parse::<u32>() returns a Result<u32, ParseIntError>: either Ok(n) or Err(e). Also handled with match. We go deep on both in Lessons 3 and 4; for today pattern matching is enough.
  5. For sum_of_multiples, u64::from(x) widens a u32 losslessly. Write it first with a for loop and a mut total, then, as a stretch goal, as one iterator chain using filter, map and sum (look them up in cargo doc --open or doc.rust-lang.org/std).

Top comments (0)