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)
  • make installed (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-world scaffolds a new Rust project with a Cargo.toml manifest and a src/main.rs entry 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-world becomes both the directory name and the package name in Cargo.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

  • /target excludes the Cargo build directory containing compiled binaries, intermediate artifacts, and dependency caches.
  • .DS_Store, *.log, and tmp/ 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 greet is public so it can be called from main.rs. It takes a string slice (&str) and returns an owned String.
  • 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 running cargo 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 to greet without 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 the greeter module, telling the compiler to look for src/greeter.rs.
  • parse_name scans the argument list for a --name flag followed by a value. It returns a string slice reference into the args vector, avoiding allocation. If no flag is found, it returns an empty string.
  • std::env::args().skip(1).collect() gathers command-line arguments into a Vec<String>, skipping the first element (the binary path).
  • greeter::greet(name) calls the public function from the greeter module. When name is empty, greet applies the "World" default.
  • Using std::env directly 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 := help ensures that running plain make prints the help screen rather than triggering a build.
  • The help target uses grep and awk to extract targets annotated with ## comments and format them into a readable list.
  • build runs cargo build --release to produce an optimized binary at target/release/hello-world.
  • run uses cargo run which compiles (if needed) and executes in one step. The -- separator passes subsequent arguments to the compiled binary rather than to Cargo itself. The optional NAME variable lets users run make run NAME=Rust.
  • test runs cargo test which compiles and executes all #[test] functions across the project.
  • clean runs cargo clean to remove the entire target/ 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 sure src/greeter.rs exists. The mod greeter; declaration in main.rs expects 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 rustc and cargo.
  • --name flag 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:

  1. A greeter module with a public greet function and co-located unit tests
  2. A command-line entry point with --name argument parsing using std::env
  3. Three unit tests covering default, custom, and multi-word name cases
  4. 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.