A minimal Rust project that prints a greeting, accepts a name from the command line, includes a test suite, and uses a Makefile to drive common tasks. This tutorial covers project setup with Cargo, module organization, argument parsing with std::env, unit tests, and build automation.
Prerequisites
- Rust 1.70 or later (install via rustup)
- A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)- No third-party crates required
Step 1: Create the project with Cargo
Create the file
cargo new hello-world
Detailed breakdown
cargo new hello-worldscaffolds a new Rust project with aCargo.tomlmanifest and asrc/main.rsentry point.- When run outside an existing Git repository, Cargo also initializes a new Git repo and generates a
.gitignore. When run inside an existing repo (as in this tutorial), it skips Git initialization. - The project name
hello-worldbecomes both the directory name and the package name inCargo.toml.
Step 2: Update the gitignore
The default Cargo .gitignore only excludes target/. Add common temporary artifacts.
Create the file
The .gitignore already exists from cargo new. Update it with additional entries:
cd hello-world
Add the code: .gitignore
/target
.DS_Store
*.log
tmp/
Detailed breakdown
/targetexcludes the Cargo build directory containing compiled binaries, intermediate artifacts, and dependency caches..DS_Store,*.log, andtmp/cover common OS and temporary artifacts.
Step 3: Create the greeter module
This module contains the core greeting logic, separated from the entry point so it can be tested independently.
Create the file
touch src/greeter.rs
Add the code: src/greeter.rs
/// Return a greeting string for the given name.
/// Defaults to "World" when the name is empty.
pub fn greet(name: &str) -> String {
let name = if name.is_empty() { "World" } else { name };
format!("Hello, {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_greeting() {
assert_eq!(greet(""), "Hello, World!");
}
#[test]
fn custom_name() {
assert_eq!(greet("Rust"), "Hello, Rust!");
}
#[test]
fn name_with_spaces() {
assert_eq!(greet("Jane Doe"), "Hello, Jane Doe!");
}
}
Detailed breakdown
pub fn greetis public so it can be called frommain.rs. It takes a string slice (&str) and returns an ownedString.- The empty-string check defaults to
"World", keeping the fallback logic inside the library rather than pushing it to the caller. format!("Hello, {name}!")uses Rust’s inline format syntax (stabilized in Rust 1.58) for clean string interpolation.- The
#[cfg(test)]block contains unit tests that are only compiled when runningcargo test. This is the standard Rust pattern for co-locating tests with the code they exercise. use super::*imports everything from the parent module, giving tests access togreetwithout a full path.- Three tests cover the empty-string default, a single-word name, and a multi-word name with spaces.
Step 4: Create the command-line entry point
The entry point reads an optional --name argument from the command line and prints the greeting.
Create the file
The src/main.rs already exists from cargo new. Replace its contents:
Add the code: src/main.rs
mod greeter;
fn parse_name(args: &[String]) -> &str {
let mut i = 0;
while i < args.len() {
if args[i] == "--name" {
if i + 1 < args.len() {
return &args[i + 1];
}
}
i += 1;
}
""
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let name = parse_name(&args);
println!("{}", greeter::greet(name));
}
Detailed breakdown
mod greeter;declares thegreetermodule, telling the compiler to look forsrc/greeter.rs.parse_namescans the argument list for a--nameflag followed by a value. It returns a string slice reference into theargsvector, avoiding allocation. If no flag is found, it returns an empty string.std::env::args().skip(1).collect()gathers command-line arguments into aVec<String>, skipping the first element (the binary path).greeter::greet(name)calls the public function from the greeter module. Whennameis empty,greetapplies the"World"default.- Using
std::envdirectly avoids adding a CLI argument parsing crate for a single flag.
Step 5: Create the Makefile
The Makefile provides a single command interface for building, running, testing, and cleaning the project. Running make with no arguments prints a help screen.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help build run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
build: ## Build the release binary
cargo build --release
run: ## Run the application (use NAME= to set a custom name)
cargo run -- $(if $(NAME),--name "$(NAME)",)
test: ## Run the test suite
cargo test
clean: ## Remove build artifacts
cargo clean
Detailed breakdown
.DEFAULT_GOAL := helpensures that running plainmakeprints the help screen rather than triggering a build.- The
helptarget usesgrepandawkto extract targets annotated with##comments and format them into a readable list. buildrunscargo build --releaseto produce an optimized binary attarget/release/hello-world.runusescargo runwhich compiles (if needed) and executes in one step. The--separator passes subsequent arguments to the compiled binary rather than to Cargo itself. The optionalNAMEvariable lets users runmake run NAME=Rust.testrunscargo testwhich compiles and executes all#[test]functions across the project.cleanrunscargo cleanto remove the entiretarget/directory, freeing disk space from compiled artifacts and dependencies.
Step 6: Run and validate
Execute the following commands from the hello-world/ directory.
Verify the help screen
cd hello-world && make
Expected output:
help Show this help screen
build Build the release binary
run Run the application (use NAME= to set a custom name)
test Run the test suite
clean Remove build artifacts
Run the application
make run
Expected output (after compilation messages):
Hello, World!
Run with a custom name
make run NAME=Rust
Expected output (after compilation messages):
Hello, Rust!
Run the tests
make test
Expected output (summary):
running 3 tests
test greeter::tests::custom_name ... ok
test greeter::tests::default_greeting ... ok
test greeter::tests::name_with_spaces ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Build the release binary
make build
Run the release binary directly:
./target/release/hello-world --name Rust
Expected output:
Hello, Rust!
Clean build artifacts
make clean
Troubleshooting
error[E0583]: file not found for module 'greeter': Make suresrc/greeter.rsexists. Themod greeter;declaration inmain.rsexpects this file at that exact path.make: *** No rule to make target ...: Verify the Makefile uses tabs for indentation, not spaces. Many editors convert tabs to spaces by default.- Cargo not found: Install Rust via rustup which includes both
rustcandcargo. --nameflag ignored: Make sure you include the--separator when running directly with Cargo:cargo run -- --name Rust. The Makefile handles this automatically.
Recap
This tutorial built a complete Rust hello-world project with:
- A
greetermodule with a publicgreetfunction and co-located unit tests - A command-line entry point with
--nameargument parsing usingstd::env - Three unit tests covering default, custom, and multi-word name cases
- A Makefile with a default help screen, build, run, test, and clean targets
Next improvements could include adding clap for richer argument parsing, a fmt Makefile target using cargo fmt, or a lint target using cargo clippy.