Hello, World
You can find all the code for this chapter here
It is traditional for your first program in a new language to be Hello, World.
In Rust, we use cargo to create and manage projects. Run the following:
cargo new hello-world
cd hello-world
cargo is Rust's build tool and package manager. cargo new creates a project with a standard structure. Have a look at what it generated:
src/main.rs
Cargo.toml
Cargo.toml describes your project — its name, version, and dependencies. src/main.rs is where your code lives. Open it and you'll see:
fn main() { println!("Hello, world!"); }
Run it:
cargo run
Hello, world!
Two things worth noting before we move on.
println! is a macro, not a function. The ! is the giveaway. For now, treat it as "print this to the terminal with a newline".
fn main() is the entry point of every Rust program. Execution starts here.
How to test
How do you test this? The problem is that println! is a side effect — it prints to the terminal, which is the outside world. Side effects are hard to test.
The better approach is to separate what we want to say from the act of saying it. The greeting logic belongs in a pure function that returns a value. The printing belongs in main.
This is a pattern we'll return to throughout this book. See Separating Concerns for the deeper reasoning.
Write the test first
Add the following to the bottom of src/main.rs:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_greet() { assert_eq!(greet(), "Hello, World!"); } } }
Don't write greet yet. Run the tests:
cargo test
warning: unused import: `super::*`
error[E0425]: cannot find function `greet` in this scope
--> src/main.rs:11:20
|
11 | assert_eq!(greet(), "Hello, World!");
| ^^^^^ not found in this scope
This is the red step. The compiler is telling you exactly what's missing. There's also a warning about the unused use super::* import — once greet exists, that import will be used and the warning will disappear.
A few new concepts
#[cfg(test)] tells the compiler to only include this module when running tests. The test code doesn't end up in your production binary.
mod tests creates a module — a namespace — for our tests. The name tests is conventional.
use super::* brings everything from the parent module into scope, so the test can see greet once we define it.
#[test] marks a function as a test. cargo test finds and runs all functions marked this way.
assert_eq! checks that two values are equal and fails the test with a clear message if they aren't.
Make it pass
Add the greet function above main, and update main to use it:
fn main() { println!("{}", greet()); } fn greet() -> String { format!("Hello, World!") }
-> String declares that the function returns an owned String. format! works like println! but returns a String instead of printing — exactly the separation we wanted.
Run the tests:
cargo test
running 1 test
test tests::test_greet ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. And fast. A tight feedback loop matters — you want running tests to feel like no effort at all.
This is a good moment to commit before we add the next requirement.
Hello, YOU
Now that we have a test, we can iterate safely.
Our next requirement: the greeting should address a specific person. Let's write the test first:
#![allow(unused)] fn main() { #[test] fn greets_a_person_by_name() { assert_eq!(greet("Alice"), "Hello, Alice!"); } }
Run the tests:
error[E0061]: this function takes 0 arguments but 1 argument was supplied
--> src/main.rs:20:20
|
20 | assert_eq!(greet("Alice"), "Hello, Alice!");
| ^^^^^ ------- unexpected argument of type `&'static str`
The compiler has done our TODO list for us. greet needs to accept a name. Let's add the parameter — but do the minimum: just change the signature, don't use name yet:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { format!("Hello, World!") } }
&str is a string slice — the standard type for borrowed string data in Rust. When a function just needs to read a string, &str is usually the right choice. We'll explore Rust's string types properly in a later chapter.
Run the tests:
error[E0061]: this function takes 1 argument but 0 arguments were supplied
--> src/main.rs:2:20
|
2 | println!("{}", greet());
| ^^^^^-- argument #1 of type `&str` is missing
error[E0061]: this function takes 1 argument but 0 arguments were supplied
--> src/main.rs:15:20
|
15 | assert_eq!(greet(), "Hello, World!");
| ^^^^^-- argument #1 of type `&str` is missing
warning: unused variable: `name`
Two things to notice. First: adding the parameter broke two places — main and the first test — both of which were calling greet() with no arguments. Second: Rust warns you about the unused name variable. It won't let you silently ignore things. Fix both call sites by passing "World" for now:
fn main() { println!("{}", greet("World")); }
#![allow(unused)] fn main() { #[test] fn test_greet() { assert_eq!(greet("World"), "Hello, World!"); } }
Run the tests again:
warning: unused variable: `name`
test tests::test_greet ... ok
test tests::greets_a_person_by_name ... FAILED
---- tests::greets_a_person_by_name stdout ----
assertion `left == right` failed
left: "Hello, World!"
right: "Hello, Alice!"
It compiles. The first test passes, the second tells us exactly what's wrong. Make it pass by using name:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { format!("Hello, {}!", name) } }
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Both green, warning gone. But look at the first test — it's passing "World" as an argument. That works, but it feels off. We're not really testing that the function greets a person named "World" — we're using it as a stand-in for "no name given". That intent isn't expressed anywhere. Let's make it explicit.
Default behaviour
Update the first test to pass an empty string instead, and rename it to reflect the intent:
#![allow(unused)] fn main() { #[test] fn greets_world_by_default() { assert_eq!(greet(""), "Hello, World!"); } }
Run the tests:
test tests::greets_world_by_default ... FAILED
---- tests::greets_world_by_default stdout ----
assertion `left == right` failed
left: "Hello, !"
right: "Hello, World!"
Good — a clear failure. Now make it pass:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { let name = if name.is_empty() { "World" } else { name }; format!("Hello, {}!", name) } }
A couple of things here:
let name = ... introduces a new binding that shadows the parameter. Rust allows — and encourages — this rather than mutating a variable.
if name.is_empty() { "World" } else { name } — in Rust, if is an expression, not just a statement. It produces a value. The whole line evaluates to either "World" or the original name.
Run the tests:
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
All green. Commit.
One more thing: Option
The empty string approach works, but it's a workaround. We're using "" to signal "no value given" — but an empty string is a valid string. We're abusing it as a sentinel. Rust has a proper way to express "this value may or may not be present": Option.
Update both tests:
#![allow(unused)] fn main() { #[test] fn greets_world_by_default() { assert_eq!(greet(None), "Hello, World!"); } #[test] fn greets_a_person_by_name() { assert_eq!(greet(Some("Alice")), "Hello, Alice!"); } }
Run the tests:
error[E0308]: mismatched types
--> src/main.rs:16:26
|
16 | assert_eq!(greet(None), "Hello, World!");
| ----- ^^^^ expected `&str`, found `Option<_>`
error[E0308]: mismatched types
--> src/main.rs:21:26
|
21 | assert_eq!(greet(Some("Alice")), "Hello, Alice!");
| ----- ^^^^^^^^^^^^^ expected `&str`, found `Option<&str>`
Both tests fail to compile — the function still expects &str. Update the signature:
#![allow(unused)] fn main() { fn greet(name: Option<&str>) -> String { let name = name.unwrap_or("World"); format!("Hello, {}!", name) } }
Option<&str> is Rust's way of saying "a string that may or may not be there". It has two variants: Some("Alice") when a value is present, and None when it isn't. There's no null, no empty string as a signal — the absence of a value is encoded in the type itself.
unwrap_or("World") extracts the value from Some, or returns "World" if it's None. This replaces the entire is_empty check with something that expresses the intent directly.
Run the tests:
error[E0308]: mismatched types
--> src/main.rs:2:26
|
2 | println!("{}", greet("World"));
| ----- ^^^^^^^ expected `Option<&str>`, found `&str`
|
help: try wrapping the expression in `Some`
|
2 | println!("{}", greet(Some("World")));
main also needs updating — and the compiler even tells us how. Since we want to show the default behaviour, pass None:
fn main() { println!("{}", greet(None)); }
Run the tests:
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
All green. The code is cleaner, the intent is clearer, and we've removed the empty string workaround entirely. Commit.
Wrapping up
We've covered a lot of ground in a small program.
Rust concepts introduced
cargo new— create a projectfn— declare a function&strandString— Rust's two main string types (more on this later)format!,println!— macros for working with stringsifas an expression- Variable shadowing with
let Option<T>— representing a value that may or may not be presentunwrap_or— extracting a value from anOptionwith a fallback
Testing concepts
#[cfg(test)]andmod tests— how Rust isolates test code#[test]— marking a function as a testassert_eq!— asserting equality- The value of watching a test fail before making it pass
The TDD process
We followed the cycle deliberately: write a failing test, write the minimum code to make it pass, refactor. Notice how the compiler errors guided each step — in Rust, the compiler is a collaborator, not an obstacle. Learning to read what it's telling you is one of the most valuable things you can do early on.
See The TDD Cycle for more on why each step matters.
Further reading
- Functions — parameters, return types, and how Rust functions differ from what you may be used to
Stringvs&str— ownership explains why Rust has two string types; this chapter makes it clickOption<T>— why Rust has nonull, and howOptionmodels the absence of a value safely