Build a Mini Shell in Rust
Build a POSIX shell in Rust using fork, execvp, and waitpid syscalls.
Introduction
30 Second Summary
Every time you type a command in your terminal, a shell program reads your input, finds the right program, and runs it. It's one of the most fundamental pieces of software on any computer.
In this project, you will build "rush" (Rust Shell), a mini POSIX shell that reads commands, parses them into tokens, and executes programs using low-level system calls.
What You'll Build
A working interactive shell that displays a prompt, parses user input, and executes external programs via POSIX fork/execvp/waitpid.
By the end of this project, you'll have:
- A REPL loop that reads user input, splits it into tokens, and dispatches commands in a continuous cycle.
- Built-in commands (cd, pwd, exit) dispatched using Rust enums and pattern matching.
- Low-level process execution using fork/execvp/waitpid via the nix crate, with your first unsafe block.
- Secret Mission: Add input/output redirection (> and <) to your shell so commands can read from and write to files.
Are there any prerequisites?
This project assumes familiarity with the C/C++ process model (fork/exec) and basic command line usage on macOS. You don't need prior Rust experience, but understanding how processes work at the OS level will help the concepts click faster.
Before We Start
Before We Start...
Install Rust and Scaffold the Project
To build our shell, we need the Rust toolchain. This includes rustc (the compiler), cargo (the package manager and build tool), and rustup (the version manager that keeps everything up to date).
In this step, you'll install the full Rust toolchain, scaffold a new project called rush, and add the nix crate as a dependency. The nix crate provides typed wrappers around POSIX syscalls that we'll use later to control processes.
In this step, get ready to:
- Install the Rust toolchain and verify it works.
- Scaffold the rush project with cargo.
- Add the nix crate dependency and confirm it compiles.
Install Rust via rustup and verify the installation
Rustup is the official installer and version manager for Rust. It sets up the compiler, standard library, and cargo in one go.
- Press Cmd+Space (macOS) to open your search bar.
- Type Terminal and press Enter to open it.
- Check if Rust is already installed by running this command:
rustc --version
✔️ I see version 1.96.0 or higher
Rust is ready to go. Skip ahead to the next substep where you create the project.
⨯ I see an older version
You have Rust installed, but you need version 1.96.0 or later. Upgrade by running:
rustup update
After the update finishes, verify your new version:
rustc --version
You should now see 1.96.0 or higher. Once confirmed, continue to the next substep.
⨯ Command not found
Rust is not installed yet. Install it by running:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
The installer will ask you to choose an installation option. Press Enter to accept the defaults (option 1).
Once installation completes, load the new tools into your current terminal session by running:
source $HOME/.cargo/env
- Verify the installation by running:
rustc --version
You should see rustc 1.96.0 or a higher version number.
- Also confirm cargo is available by running:
cargo --version
You should see a version number printed.
What is rustup?
Rustup manages your Rust installation. It handles downloading new versions, switching between stable and nightly toolchains, and keeping cargo and rustc in sync.
The source $HOME/.cargo/env command adds Rust's binary directory to your terminal's PATH so you can run rustc and cargo from anywhere.
Getting a linker error?
If you see an error mentioning a missing linker when compiling later, you need the Xcode Command Line Tools. Install them by running:
xcode-select --install
This provides the C linker that Rust needs on macOS to produce executables.
help me fix a linker error when compiling Rust on macOS
Create the project and add the nix dependency
Cargo can scaffold a complete binary project with a single command. This gives you a Cargo.toml manifest and a src/main.rs with a starter main() function.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project and move into it by running these commands:
cargo new rush
cd rush
What did cargo new do?
This created a rush/ directory on your Desktop containing src/main.rs, Cargo.toml, and Cargo.lock. The Cargo.toml file is your project's manifest. It declares the package name, version, and dependencies.
- Confirm the files exist by running:
ls
You should see Cargo.toml and src in the output.
Now you need to add the nix crate as a dependency. This crate provides safe Rust wrappers around POSIX system calls like fork, execvp, and waitpid that we'll use to run commands.
- Open Cargo.toml in your text editor of choice.
- Replace the entire contents of Cargo.toml with the following:
[package]
name = "rush"
version = "0.1.0"
edition = "2024"
[dependencies]
nix = { version = "0.31.2", features = ["process", "signal", "fs"] }
What does this configuration do?
The [dependencies] section declares external crates your project needs. The nix crate at version 0.31.2 gives us typed wrappers around Unix system calls.
The features array enables only the modules we need: process for fork and exec, signal for signal handling, and fs for file descriptor operations.
- Save Cargo.toml.
- Download and compile the dependency by running:
cargo build
This downloads the nix crate and all its transitive dependencies, then compiles them. You should see output ending with a line containing Finished and no errors.
Cargo build failed?
Make sure you saved Cargo.toml before running the build command.
Check that the [dependencies] section matches the code above exactly. A missing quote or bracket will cause a parse error.
help me fix a cargo build error for my rush project
Verify the skeleton runs
Before writing any shell code, confirm that the default project template runs correctly.
- Run the project by running this command:
cargo run
You should see Hello, world! printed in your terminal.
What does cargo run do?
This command compiles your project (if anything changed) and then immediately runs the resulting binary. It is a shortcut for building and executing in one step.
Don't see Hello, world!?
Make sure you are inside the rush/ directory. Run pwd to check your current location. You should see a path ending in rush.
If you get a linker error, run xcode-select --install and try again.
help me troubleshoot cargo run not producing output
✔️ Awesome, I've got everything!
Great. Double check you have saved Cargo.toml and confirm cargo run prints Hello, world! with no errors.
⨯ I'd like to double check the full code
Here is what your Cargo.toml should look like:
[package]
name = "rush"
version = "0.1.0"
edition = "2024"
[dependencies]
nix = { version = "0.31.2", features = ["process", "signal", "fs"] }
And here is src/main.rs (unchanged from the template):
fn main() {
println!("Hello, world!");
}
Your Rust environment is ready and the project skeleton compiles. Next up, you'll replace that Hello World with a real read-eval-print loop that accepts commands.
Build the REPL Loop and Input Parser
Every shell follows the same fundamental pattern: print a prompt, read what the user types, do something with it, repeat.
This pattern is called a REPL (Read-Eval-Print Loop). In this step, you'll build the REPL that powers your shell and write a parser that splits raw input into individual command tokens.
In this step, get ready to:
- Implement an infinite loop that prints a prompt and reads user input.
- Write a parser function that tokenizes input by splitting on whitespace.
- Test the loop by running commands and confirming token output.
Implement the read-eval-print loop
The REPL loop needs to print a prompt, wait for user input, and handle edge cases like the user pressing Ctrl+D (EOF) or just hitting Enter on an empty line.
Rust's read_line method appends to a String, which is a heap-allocated owned type. Each loop iteration creates a fresh String buffer, demonstrating how ownership works inside a loop.
- Replace the entire contents of src/main.rs with the following code:
use std::io::{self, Write};
fn main() {
// Start the read-eval-print loop
loop {
// Display the prompt without a newline
print!("rush> ");
// Flush stdout so the prompt appears before read_line blocks
io::stdout().flush().expect("Failed to flush stdout");
// Create a new owned String buffer for this iteration
let mut input = String::new();
let bytes_read = io::stdin().read_line(&mut input).expect("Failed to read line");
// bytes_read == 0 means EOF (Ctrl+D)
if bytes_read == 0 {
println!();
break;
}
// Trim whitespace and re-bind as an immutable &str
let input = input.trim();
if input.is_empty() {
continue;
}
// Tokenize the input and print the result
let tokens = parse_input(input);
if tokens.is_empty() {
continue;
}
println!("Tokens: {:?}", tokens);
}
}
// Borrow the input as &str, return owned Vec<String>
fn parse_input(input: &str) -> Vec<String> {
input.split_whitespace().map(String::from).collect()
}
What does this code do?
This file has two parts: the main loop and the parser function.
- print!("rush> ") displays the prompt without a trailing newline. Because stdout is line-buffered, you must call flush() to force the text onto the screen before read_line blocks waiting for input.
- let mut input = String::new() allocates a fresh buffer each iteration. This avoids stale data from the previous loop pass.
- bytes_read == 0 detects when the user presses Ctrl+D (EOF). The loop breaks cleanly instead of crashing.
- let input = input.trim() shadows the mutable String with an immutable &str slice. Shadowing is idiomatic in Rust when you no longer need the original binding.
- parse_input borrows the trimmed line as &str and returns a Vec<String> of individually owned tokens.
- Save src/main.rs.
- Confirm it compiles by running:
cargo build
You should see a "Compiling rush" message followed by "Finished" with no errors.
Seeing a compile error?
- If you see "cannot find function parse_input", make sure the parse_input function is defined outside and below the main function's closing brace.
- If you see "expected item, found keyword let", check that all your code is inside either main() or parse_input(). Rust does not allow bare statements at the top level.
- Double check that the use std::io::{self, Write}; import is at the very top of the file.
Help me fix a compile error in my rush shell REPL loop.
Understand the input parser
The parse_input function you just wrote is where Rust's ownership model becomes interesting for the first time in this project.
Why does parse_input borrow input but return owned data?
The function signature fn parse_input(input: &str) -> Vec<String> tells the full story. It borrows the input (&str) but returns owned data (Vec<String>).
Each token needs to outlive the borrow of the original input string. If the function returned borrowed slices (&str), they'd be tied to the lifetime of the original input buffer. By converting each slice to an owned String via String::from, the tokens become independent values the caller can use freely.
This is your first encounter with a common Rust pattern: borrow cheaply at the boundary, own the data internally.
Test the parser in the loop
- Start your shell by running:
cargo run
You should see the rush> prompt appear and the cursor waiting for input.
- Type ls -la /tmp and press Enter.
You should see this output:
Tokens: ["ls", "-la", "/tmp"]
- Press Enter on an empty line. The prompt should reappear with no output.
- Press Ctrl+D to send EOF. The shell should exit cleanly back to your normal terminal.
What should I see?
The shell prints tokens as a Rust debug-formatted vector. Every word separated by whitespace becomes its own element. The shell loops back to the prompt after each command until you press Ctrl+D.
Prompt not appearing before you type?
This happens when the flush() call is missing. Make sure you have io::stdout().flush().expect("Failed to flush stdout"); immediately after the print!("rush> ") line.
Help me debug why my rush shell prompt isn't showing up.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use std::io::{self, Write};
fn main() {
loop {
print!("rush> ");
io::stdout().flush().expect("Failed to flush stdout");
let mut input = String::new();
let bytes_read = io::stdin().read_line(&mut input).expect("Failed to read line");
if bytes_read == 0 {
// EOF (Ctrl+D)
println!();
break;
}
let input = input.trim();
if input.is_empty() {
continue;
}
let tokens = parse_input(input);
if tokens.is_empty() {
continue;
}
println!("Tokens: {:?}", tokens);
}
}
fn parse_input(input: &str) -> Vec<String> {
input.split_whitespace().map(String::from).collect()
}
Your shell can read and tokenize input. Next up, you'll make those tokens actually do something by dispatching commands and executing programs.
Execute Commands with std::process::Command
Your shell reads input and splits it into tokens. But right now it just prints them and loops back. A real shell needs to actually run those commands.
In this step, you will use Rust's standard library to spawn child processes. You will also add built-in commands that the shell handles internally without spawning anything.
In this step, get ready to:
- Execute external programs using std::process::Command.
- Add built-in commands (cd, exit, pwd) that the shell handles natively.
- Test the shell end-to-end as an interactive terminal.
Wire up external command execution
Right now the REPL prints debug tokens. You need to replace that with code that actually spawns the program the user typed.
- In src/main.rs, find the imports at the top of the file:
use std::io::{self, Write};
- Replace that single import line with these three imports:
use std::env;
use std::io::{self, Write};
use std::process::Command;
- Next, find this debug print line inside your loop:
println!("Tokens: {:?}", tokens);
- Replace it with a call to an execute_command function:
execute_command(&tokens);
- Now add the execute_command function below the parse_input function by copying this code:
fn execute_command(tokens: &[String]) {
// Spawn the program with its arguments, wait for it to finish
match Command::new(&tokens[0]).args(&tokens[1..]).status() {
Ok(_) => {}
Err(e) => eprintln!("rush: {}: {}", tokens[0], e),
}
}
What does this code do?
Command::new(&tokens[0]) creates a process builder for the first token (the program name). .args(&tokens[1..]) attaches all remaining tokens as arguments.
.status() spawns the child process and waits for it to finish. It returns a Result because the program might not exist or might fail to start. If the command is not found, the Err branch prints a helpful message to stderr.
- Save src/main.rs.
- Run your shell and test an external command by running:
cargo run
- At the rush> prompt, type ls and press Enter. You should see your project files listed (Cargo.toml, Cargo.lock, src, target).
- Type notarealcommand and press Enter. You should see an error message from rush.
- Press Ctrl+D to exit.
Command runs but nothing prints?
Make sure you removed the old println!("Tokens: {:?}", tokens); line. If both that line and execute_command exist, the command still runs but the debug output is confusing.
If you see a compile error about Command not being found, check that your import says use std::process::Command; at the top of the file.
Help me debug why my execute_command function isn't working.
Add built-in commands
Some commands cannot be external programs. cd must change the shell process's own working directory. If you ran cd as an external program, the child process would change its directory and then exit, leaving the parent shell unchanged.
- In src/main.rs, find the line inside the loop that says:
execute_command(&tokens);
- Replace it with this match expression that checks for built-in commands before falling through to external execution:
match tokens[0].as_str() {
"exit" => break,
"cd" => builtin_cd(&tokens),
"pwd" => builtin_pwd(),
_ => execute_command(&tokens),
}
What is pattern matching doing here?
The match expression converts tokens[0] (a String) to a &str with .as_str(), then compares it against string literals. The _ wildcard catches everything else and dispatches to execute_command.
This is Rust's version of a switch statement, but the compiler enforces exhaustiveness. You must handle every possible case or include a wildcard.
- Now add the builtin_cd function below parse_input by copying this code:
fn builtin_cd(tokens: &[String]) {
// If no argument given, go to HOME directory
let path = if tokens.len() < 2 {
env::var("HOME").unwrap_or_else(|_| "/".to_string())
} else {
tokens[1].clone()
};
// Attempt to change directory, print error if it fails
if let Err(e) = env::set_current_dir(&path) {
eprintln!("rush: cd: {}: {}", path, e);
}
}
What does builtin_cd do?
If the user typed cd with no argument, the function reads the HOME environment variable and navigates there. If a path argument is provided, it uses that instead.
env::set_current_dir changes the process's working directory. It returns a Result, so if the path doesn't exist, you get a clean error instead of a panic.
- Add the builtin_pwd function directly below builtin_cd:
fn builtin_pwd() {
// Print the current working directory
match env::current_dir() {
Ok(path) => println!("{}", path.display()),
Err(e) => eprintln!("rush: pwd: {}", e),
}
}
What does builtin_pwd do?
env::current_dir() returns the process's current working directory as a PathBuf. The .display() method formats it as a printable string. If reading the directory fails (rare, but possible if the directory was deleted underneath you), the error branch handles it.
- Save src/main.rs.
Seeing a compile error about builtin_cd or builtin_pwd?
Make sure you added use std::env; at the top of the file (from the imports you updated earlier). Both functions depend on the env module.
Also confirm the function signatures match exactly. builtin_cd takes tokens: &[String] (a slice reference), while builtin_pwd takes no arguments.
Help me fix a compile error in my built-in command functions.
Test the full flow
- Run your shell by running:
cargo run
- At the rush> prompt, type pwd and press Enter. You should see your current directory printed (something like /Users/yourname/Desktop/rush).
- Type cd /tmp and press Enter.
- Type pwd again. It should now print /tmp (or /private/tmp on macOS).
- Type ls -la and press Enter. You should see a directory listing.
- Type echo hello world and press Enter. You should see hello world printed.
- Type exit and press Enter. The shell should exit cleanly.
Your shell now behaves like a basic interactive terminal. External commands run via Command, and built-ins modify the shell process directly.
Why must cd be a built-in?
Every external command runs in a forked child process. If cd ran as an external program, the child would change its own directory, exit, and the parent shell's directory would remain unchanged. That is why every real shell (bash, zsh, fish) implements cd as a built-in.
cd works but pwd still shows the old directory?
Double check that your match arm for "cd" calls builtin_cd(&tokens) and not execute_command(&tokens). If cd falls through to the wildcard arm, it spawns as an external process and cannot change the parent's directory.
Help me figure out why cd isn't changing my shell's directory.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use std::env;
use std::io::{self, Write};
use std::process::Command;
fn main() {
loop {
print!("rush> ");
io::stdout().flush().expect("Failed to flush stdout");
let mut input = String::new();
let bytes_read = io::stdin().read_line(&mut input).expect("Failed to read line");
if bytes_read == 0 {
println!();
break;
}
let input = input.trim();
if input.is_empty() {
continue;
}
let tokens = parse_input(input);
if tokens.is_empty() {
continue;
}
match tokens[0].as_str() {
"exit" => break,
"cd" => builtin_cd(&tokens),
"pwd" => builtin_pwd(),
_ => execute_command(&tokens),
}
}
}
fn parse_input(input: &str) -> Vec<String> {
input.split_whitespace().map(String::from).collect()
}
fn builtin_cd(tokens: &[String]) {
let path = if tokens.len() < 2 {
env::var("HOME").unwrap_or_else(|_| "/".to_string())
} else {
tokens[1].clone()
};
if let Err(e) = env::set_current_dir(&path) {
eprintln!("rush: cd: {}: {}", path, e);
}
}
fn builtin_pwd() {
match env::current_dir() {
Ok(path) => println!("{}", path.display()),
Err(e) => eprintln!("rush: pwd: {}", e),
}
}
fn execute_command(tokens: &[String]) {
match Command::new(&tokens[0]).args(&tokens[1..]).status() {
Ok(_) => {}
Err(e) => eprintln!("rush: {}: {}", tokens[0], e),
}
}
Your shell is fully functional with the safe, high-level process API. Next up, you will drop to the low-level POSIX layer and implement the same execution using fork, execvp, and waitpid via the nix crate.
Drop to fork/execvp/waitpid via the nix Crate
Your shell runs commands using std::process::Command, a high-level abstraction that hides all the process lifecycle details behind a clean builder API.
Now you will replace that abstraction with the raw POSIX primitives it wraps: fork to clone the process, execvp to replace the child with a new program, and waitpid to pause the parent until the child exits.
In this step, get ready to:
- Replace std::process::Command with nix's fork, execvp, and waitpid.
- Learn why Rust marks fork as unsafe and how CString bridges the FFI boundary.
- Verify your shell behaves identically to the previous step.
Replace Command with fork + execvp + waitpid
The nix crate provides typed Rust wrappers around POSIX syscalls that integrate with Rust's Result type.
You need to update two things: the imports at the top of src/main.rs, and the execute_command function body.
- In src/main.rs, find the imports at the very top of the file:
use std::env;
use std::io::{self, Write};
use std::process::Command;
- Replace those three lines with the following six lines:
use std::env;
use std::ffi::CString;
use std::io::{self, Write};
use std::process;
use nix::sys::wait::waitpid;
use nix::unistd::{execvp, fork, ForkResult};
What changed in the imports?
You removed std::process::Command (the high-level API) and added four new imports.
- std::ffi::CString creates null-terminated C strings that POSIX functions require.
- std::process gives you process::exit() for the child to terminate on exec failure.
- The three nix imports bring in fork, ForkResult, execvp, and waitpid from the nix crate.
- Now scroll to the bottom of src/main.rs and find the current execute_command function:
fn execute_command(tokens: &[String]) {
match Command::new(&tokens[0]).args(&tokens[1..]).status() {
Ok(_) => {}
Err(e) => eprintln!("rush: {}: {}", tokens[0], e),
}
}
- Replace the entire execute_command function with this new implementation that uses fork, execvp, and waitpid:
fn execute_command(tokens: &[String]) {
// Convert Rust strings to null-terminated C strings for POSIX
let c_args: Vec<CString> = tokens
.iter()
.map(|s| CString::new(s.as_str()).expect("CString conversion failed"))
.collect();
// Fork the process: parent waits, child execs
match unsafe { fork() } {
Ok(ForkResult::Parent { child, .. }) => {
// Parent process: wait for the child to finish
waitpid(child, None).expect("waitpid failed");
}
Ok(ForkResult::Child) => {
// Child process: replace itself with the target program
let c_args_refs: Vec<&CString> = c_args.iter().collect();
if let Err(e) = execvp(&c_args[0], &c_args_refs) {
eprintln!("rush: {}: {}", tokens[0], e);
process::exit(1);
}
}
Err(e) => {
eprintln!("rush: fork failed: {}", e);
}
}
}
What does this code do?
- CString::new() converts each Rust &str token into a null-terminated C string. POSIX functions expect null-terminated strings, and Rust strings are not null-terminated.
- unsafe { fork() } duplicates the entire process. Execution continues in both the parent and the child from this exact point.
- The Parent branch calls waitpid(child, None) to block until the child process finishes. This prevents the prompt from reappearing before the command completes.
- The Child branch calls execvp to replace the child's memory image with the target program. On success, execvp never returns. On failure, the child prints an error and exits.
- Save src/main.rs.
- Confirm the code compiles by running:
cargo build
- You should see Compiling rush v0.1.0 followed by Finished with no errors.
Seeing a compilation error?
- If you see an "unused import" warning for Command, make sure you removed the old use std::process::Command; line entirely.
- If you see "cannot find value fork", check that your nix import reads use nix::unistd::{execvp, fork, ForkResult}; with correct capitalization.
- If you see a type mismatch on execvp, make sure c_args_refs is typed as Vec<&CString> (references to CString, not owned CStrings).
Why won't my code compile?
Understand why fork is unsafe in Rust
You just wrote your first unsafe block. Two concepts here deserve attention.
Why is fork() marked unsafe?
After forking in a multithreaded program, only async-signal-safe functions may be called in the child. Rust's allocator and panic machinery are NOT async-signal-safe.
In your single-threaded shell this is safe, but the unsafe block documents the contract: "I know this process is single-threaded at this point." The compiler cannot verify this invariant for you, so you promise it manually.
Why does CString::new() return a Result?
C strings are null-terminated. If the input contains a null byte in the middle, the C function would interpret the string as shorter than intended. Rust prevents this undefined behavior at the type level.
CString::new() checks for interior null bytes and returns an Err if it finds one. In C, this same scenario would silently truncate the string.
Test low-level execution
The behavior should be identical to the previous step. The difference is that you now control the process lifecycle directly.
- Start your shell by running:
cargo run
- At the rush> prompt, type ls and press Enter. You should see your project files listed.
- Type echo hello and press Enter. You should see hello printed.
- Type cd /tmp then pwd. You should see /tmp printed.
- Type exit to leave the shell.
The prompt should reappear only after each command finishes. This confirms that waitpid is properly blocking the parent.
Command runs but rush> appears before output?
- Make sure you are calling waitpid(child, None) in the Parent arm. Without it, the parent continues immediately to the next loop iteration.
- If you see "rush: ls: No such file or directory", check that you imported execvp (not execv). execvp searches your PATH while execv requires the full binary path.
Help me debug my fork/exec shell
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use std::env;
use std::ffi::CString;
use std::io::{self, Write};
use std::process;
use nix::sys::wait::waitpid;
use nix::unistd::{execvp, fork, ForkResult};
fn main() {
loop {
print!("rush> ");
io::stdout().flush().expect("Failed to flush stdout");
let mut input = String::new();
let bytes_read = io::stdin().read_line(&mut input).expect("Failed to read line");
if bytes_read == 0 {
// EOF (Ctrl+D)
println!();
break;
}
let input = input.trim();
if input.is_empty() {
continue;
}
let tokens = parse_input(input);
if tokens.is_empty() {
continue;
}
match tokens[0].as_str() {
"exit" => break,
"cd" => builtin_cd(&tokens),
"pwd" => builtin_pwd(),
_ => execute_command(&tokens),
}
}
}
fn parse_input(input: &str) -> Vec<String> {
input.split_whitespace().map(String::from).collect()
}
fn builtin_cd(tokens: &[String]) {
let path = if tokens.len() < 2 {
env::var("HOME").unwrap_or_else(|_| "/".to_string())
} else {
tokens[1].clone()
};
if let Err(e) = env::set_current_dir(&path) {
eprintln!("rush: cd: {}: {}", path, e);
}
}
fn builtin_pwd() {
match env::current_dir() {
Ok(path) => println!("{}", path.display()),
Err(e) => eprintln!("rush: pwd: {}", e),
}
}
fn execute_command(tokens: &[String]) {
let c_args: Vec<CString> = tokens
.iter()
.map(|s| CString::new(s.as_str()).expect("CString conversion failed"))
.collect();
match unsafe { fork() } {
Ok(ForkResult::Parent { child, .. }) => {
waitpid(child, None).expect("waitpid failed");
}
Ok(ForkResult::Child) => {
let c_args_refs: Vec<&CString> = c_args.iter().collect();
if let Err(e) = execvp(&c_args[0], &c_args_refs) {
eprintln!("rush: {}: {}", tokens[0], e);
process::exit(1);
}
}
Err(e) => {
eprintln!("rush: fork failed: {}", e);
}
}
}
Secret mission
Add I/O Redirection
Your shell can run commands, but real shells let you redirect input and output with > and < operators. Can you add file redirection to rush so that commands can read from and write to files?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources to continue experimenting, or delete them to free up disk space.
Resources you used:
- The rush/ project directory on your Desktop.
- The Rust toolchain (rustup, cargo, rustc) installed to ~/.cargo/bin.
- Any test files created in /tmp/ (e.g. /tmp/rush_test.txt).
Keep everything running
No action needed. Choose this if you plan to keep building on your shell or experimenting with Rust. Your project files and the Rust toolchain will remain available whenever you open a terminal.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files will stay on disk until you decide to delete them.
- If you want to free up the compiled artifacts without deleting your source code, run this from inside the rush/ directory:
cargo clean
This removes the target/ directory (compiled binaries and intermediate build files) but keeps your source code intact.
Delete - I don't want to use this again
Remove all project resources and the Rust toolchain entirely.
- Delete the project directory by running:
cd ~/Desktop
rm -rf rush
- Remove any test files created during the project:
rm -f /tmp/rush_test.txt
- Uninstall the Rust toolchain (rustup, cargo, rustc, and all installed toolchains) by running:
rustup self uninstall
This will ask for confirmation before removing all Rust toolchains and the ~/.cargo and ~/.rustup directories. Enter y to confirm.
Nice Work!
Nice Work!
You just built a working interactive shell from scratch in Rust. Your "rush" shell reads input, parses commands, dispatches built-ins, and forks child processes to execute external programs.
You've learned how to:
- Build a REPL loop with input parsing, and implement built-in commands (cd, pwd, exit) using Rust's std::env APIs.
- Apply Rust's ownership model in practice, handling String vs &str boundaries and converting to CString for FFI with null-terminated C strings.
- Execute processes at the POSIX level using the nix crate's fork/execvp/waitpid wrappers, writing your first unsafe block to document the single-threaded safety contract.
- Secret Mission: Add I/O redirection (> and <) to your shell using File and the Stdio API.
Ready to quiz yourself?