Let's Build a (Mini)Shell in Rust

9 minute read Published: 2025-05-31
// src/main.rs

use std::io::{self, Write};

fn main() {
    loop {
        print!("> ");
        io::stdout().flush().unwrap();

        let mut input = String::new();
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let input = input.trim();

                // skip empty input
                if input.is_empty() {
                    continue;
                }

                // Parse the input into command and arguments
                let mut parts = input.split_whitespace();
                let command = parts.next().unwrap();
                let args: Vec<&str> = parts.collect();

                println!("Command: {}", command);
                println!("Arguments: {:?}", args);
            }
            Err(error) => {
                eprintln!("Error reading input: {}", error);
                break;
            }
        }
    }
}

Now when we enter a command like ls -la, the shell will parse it into:

Command: ls
Arguments: ["-la"]

Obviously, this is very basic parsing, and it doesn't handle multiple commands and/or piping, but we will fix that later. For now, we have a basic shell that can read input from the user and parse that input into a command and its arguments.

Executing Commands

We now have a basic shell that can read input from the user and parse that input into a sequence of commands that can be executed by spawning new processes. However, not all commands are equally handled by the shell, leading to the need for built-in commands and understanding how shells create processes.

How Shells Create Process

Before we execute commands, I think a little background on how shells create processes is in order. When a shell executes a command, it typically does so by creating a new process, a process being an instance of a running program.

In Unix-like systems, this is done using the fork and exec system calls:

  1. Fork: The shell creates a new process by duplicating itself using the fork system call. This creates a child process that is an exact copy of the parent shell process (this will become important later).
  2. Exec: The shell then replaces the child process's memory space with the new command using the exec system call. This means that the child process is now running the new command, but it still has the same process ID (PID) as the original shell.

As a result, the child process can run independently of the parent shell, and the shell can continue to run and accept new commands. When the child process finishes executing the command, it can return an exit status to the parent shell, which can then display the result to the user.

Even though these details are abstracted away in Rust, they are still important to understand how our shell will work. When we execute a command, we will use the Command struct from the std::process module, which internally handles the fork and exec system calls for us. The Command struct provides a convenient way to spawn new processes and pass arguments to them.

Built-in Commands

With this in mind, the method of creating processes necessitates why shells have built-in commands like cd (change directory) or exit. These commands must be handled by the shell itself rather than being passed to the operating system.

Why? Take for example the case of cd. Remember that when we fork a new process, it is a copy of the parent shell. If we were to exec a command like cd, it would change the directory of the child process, but once that child process exits, the parent shell's working directory would remain unchanged. Thus, the shell must handle cd itself to change its own working directory. In a similar vein, the exit command must also be handled by the shell as it needs to terminate the shell process itself, not just a child process.

Shell Process (Working Directory: /home/user)
    |
    └── Child Process: `cd /tmp` (Working Directory: /tmp)
        [Process exits, directory change is lost]
    |
Shell Process (Working Directory: still /home/user!)

Implementing cd and exit Built-in Commands

Let's implement the cd and exit built-in commands in our shell. We'll add a match arm to handle these commands before we attempt to execute any external commands. Here's how we can do that:

1use std::{
2 env,
3 error::Error,
4 io::{stdin, stdout, Write},
5 path::Path,
6};
7
8fn main() -> Result<(), Box<dyn Error>> {
9 loop {
10 print!("> ");
11 stdout().flush()?;
12
13 let mut input = String::new();
14 stdin().read_line(&mut input)?;
15 let input = input.trim();
16
17 if input.is_empty() {
18 continue;
19 }
20
21 // Parse the input into command and arguments
22 let mut parts = input.split_whitespace();
23 let Some(command) = parts.next() else {
24 continue;
25 };
26 let args: Vec<&str> = parts.collect();
27
28 // Handle built-in commands first
29 match command {
30 "cd" => {
31 // Handle cd command - must be done by shell itself
32 let new_dir = args.first().unwrap_or(&"/");
33 let root = Path::new(new_dir);
34 if let Err(e) = env::set_current_dir(root) {
35 eprintln!("cd: {}", e);
36 }
37 }
38 "exit" => {
39 // Handle exit command - terminate the shell
40 println!("Goodbye!");
41 return Ok(());
42 }
43 // All other commands are external commands
44 command => {
45 println!(
46 "Executing external command: {} with args: {:?}",
47 command, args
48 );
49 // We'll implement this in the next step
50 }
51 }
52 }
53}
54

Note

The revised main function signature now returns a Result<(), Box<dyn Error>>, which allows us to handle errors more gracefully with ? instead of panicking.

In this new version, we do the same whitespace splitting as before to get the command and its arguments. Next, in the match expression, we check if the command is cd or exit.

Looking a little closer at the cd command, we use the env::set_current_dir function to change the current working directory of the shell process. If the directory change fails (for example, if the directory does not exist), we print an error message to the user. The unwrap_or(&"/") ensures that if no argument is provided, we default to the root directory /.

You might be asking when not use ~ as the default directory? The reason is that ~ is a shell-specific shorthand for the user's home directory, and it is not universally recognized by all shells. Using / as the default ensures that our shell behaves consistently across different environments, as / is the root directory in Unix-like systems. If you want to support ~, you would need to expand it to the user's home directory using dirs::home_dir() from the dirs crate. This is left as a future exercise for the reader.

In our implementation we just support the cd and exit built-in commands, but for a complete, POSIX-compliant shell, there are many more built-in commands that would need to be implemented, such as export, alias, and source. For a complete list, see section 1.6 Built-In Utilities in the latest POSIX standard.

Executing External Commands

Now that we have the built-in commands handled, we can implement the logic to execute external commands. We'll use the Command struct from the std::process module to spawn new processes. The Command struct provides a convenient way to create and configure a new process, including setting the command to run, passing arguments, and handling input/output streams.

To execute an external command, we can use the Command::new method to create a new command, and then we can call the spawn method to run the command in a new process. For example, to run the ls -la command, we can do the following:

use std::process::Command;

// use Builder pattern to create a new command
let output = Command::new("ls") // create a new command
    .arg("-la") // add argument(s)
    .output() // execute the command and capture output
    .expect("Failed to execute command"); // handle any errors

This will run the ls -la command and capture its output. The output method returns a Result<Output>, where Output contains the standard output and standard error of the command. We can then print the output to the user.

For our shell, we'll primarily use spawn() because we want to control when to wait for the process to complete.

Let's integrate this into our shell, so that it can execute external commands:

1use std::{
2 env,
3 error::Error,
4 io::{stdin, stdout, Write},
5 path::Path,
6 process::Command,
7};
8
9fn main() -> Result<(), Box<dyn Error>> {
10 loop {
11 print!("> ");
12 stdout().flush()?;
13
14 let mut input = String::new();
15 stdin().read_line(&mut input)?;
16 let input = input.trim();
17
18 if input.is_empty() {
19 continue;
20 }
21
22 // Parse the input into command and arguments
23 let mut parts = input.split_whitespace();
24 let Some(command) = parts.next() else {
25 continue;
26 };
27 let args: Vec<&str> = parts.collect();
28
29 // Handle built-in commands first
30 match command {
31 "cd" => {
32 let new_dir = args.first().unwrap_or(&"/");
33 let root = Path::new(new_dir);
34 if let Err(e) = env::set_current_dir(root) {
35 eprintln!("cd: {}", e);
36 }
37 }
38 "exit" => {
39 println!("Goodbye!");
40 return Ok(());
41 }
42 // All other commands are external commands
43 command => {
44 // Create a Command struct to spawn the external process
45 let mut cmd = Command::new(command);
46 cmd.args(&args);
47
48 // Spawn the child process and wait for it to complete
49 match cmd.spawn() {
50 Ok(mut child) => {
51 // Wait for the child process to finish
52 match child.wait() {
53 Ok(status) => {
54 if !status.success() {
55 eprintln!("Command '{}' failed with exit code: {:?}",
56 command, status.code());
57 }
58 }
59 Err(e) => {
60 eprintln!("Failed to wait for command '{}': {}", command, e);
61 }
62 }
63 }
64 Err(e) => {
65 eprintln!("Failed to execute command '{}': {}", command, e);
66 }
67 }
68 }
69 }
70 }
71}

Now for any external command:

  1. We create a Command instance using Command::new(command), passing the command name as an argument.
  2. We then add any additional arguments using cmd.args(&args).
  3. Call cmd.spawn() to execute the command in a new process.

The spawn method returns a Result<Child>, where Child represents the spawned process. We then wait for the child process to finish using child.wait(), which returns a Result<ExitStatus>. If the command fails to execute, we print an error message to the user. If the command succeeds, then it will output its results to the terminal via the standard output stream.

Piping Commands

One of the most powerful features of Unix shells is the ability to pipe the output of one command as input to another command. The pipe operator | allows you to chain commands together. For example, ls | grep txt would list files and then filter for those containing "txt". A major limitation of our current shell is that is only supports a single command at a time, so let's extend our shell to support piping commands together.

The first thing we'll do is modify our input parsing to split the input on the pipe character | instead of whitespace. This will allow us to handle multiple commands in a single input line. We'll also store these commands in a peekable iterator. Why peekable? Because we want to check if there are more commands to process after the current one, so we can decide whether to pipe the output to the next command or not.

// Split input on pipe characters to handle command chaining
let mut commands = input.trim().split(" | ").peekable();

Since we are now dealing with multiple commands, we need to keep track of the output of the previous command so that we can pipe it to the next command, if there is one. Additionally, we want to track all of the child processes that we spawn so that we can wait for them to finish later.

let mut prev_stdout = None; // This will hold the output of the previous command
let mut children: Vec<Child> = Vec::new(); // This will hold all child processes we spawn

Next, we will loop through each command in the pipeline, parsing it into the command name and its arguments, and then executing it. If the command is cd or exit, we handle it as before. For external commands, we will set up the stdin and stdout streams based on whether there is a previous command to pipe from or if it is the last command in the pipeline. If there is a previous command, we will use its output as the input for the current command.

Putting it all together, our updated shell now looks like this:

1use std::{
2 env,
3 error::Error,
4 io::{stdin, stdout, Write},
5 path::Path,
6 process::{Child, Command, Stdio},
7};
8
9fn main() -> Result<(), Box<dyn Error>> {
10 loop {
11 print!("> ");
12 stdout().flush()?;
13
14 let mut input = String::new();
15 stdin().read_line(&mut input)?;
16 let input = input.trim();
17
18 if input.is_empty() {
19 continue;
20 }
21
22 // Split input on pipe characters to handle command chaining
23 let mut commands = input.trim().split(" | ").peekable();
24 let mut prev_stdout = None;
25 let mut children: Vec<Child> = Vec::new();
26
27 // Process each command in the pipeline
28 while let Some(command) = commands.next() {
29 let mut parts = command.split_whitespace();
30 let Some(command) = parts.next() else {
31 continue;
32 };
33 let args = parts;
34
35 match command {
36 "cd" => {
37 // Built-in: change directory
38 let new_dir = args.peekable().peek().map_or("/", |x| *x);
39 let root = Path::new(new_dir);
40 if let Err(e) = env::set_current_dir(root) {
41 eprintln!("cd: {}", e);
42 }
43 // Reset prev_stdout since cd doesn't produce output
44 prev_stdout = None;
45 }
46 "exit" => {
47 println!("Goodbye!");
48 return Ok(());
49 }
50 command => {
51 // External command: set up stdin/stdout for piping
52
53 // Input: either from previous command's output or inherit from shell
54 let stdin = match prev_stdout.take() {
55 Some(output) => Stdio::from(output),
56 None => Stdio::inherit(),
57 };
58
59 // Output: pipe to next command if there is one, otherwise inherit
60 let stdout = if commands.peek().is_some() {
61 Stdio::piped() // More commands follow, so pipe output
62 } else {
63 Stdio::inherit() // Last command, output to terminal
64 };
65
66 // Spawn the command with configured stdin/stdout
67 let child = Command::new(command)
68 .args(args)
69 .stdin(stdin)
70 .stdout(stdout)
71 .spawn();
72
73 match child {
74 Ok(mut child) => {
75 // Take ownership of stdout for next command in pipeline
76 prev_stdout = child.stdout.take();
77 children.push(child);
78 }
79 Err(e) => {
80 eprintln!("Failed to execute '{}': {}", command, e);
81 break;
82 }
83 }
84 }
85 }
86 }
87
88 // Wait for all child processes to complete
89 for mut child in children {
90 let _ = child.wait();
91 }
92 }
93}

Woohoo! Now our shell can handle multiple commands piped together! When you run the shell and enter a command like ls | wc -l, it will execute each command in the pipeline, passing the output of one command as the input to the next command, with the final output displayed in the terminal.

Example of piping commands in a shell

Example of piping commands in a shell; `ls | wc -l` counts the number of files in the current directory

Footnotes

1

This is a simplified version of the shell lifecycle. In reality, shells may have more complex lifecycles, especially when dealing with job control, background processes, and other advanced features.

While technically you could classify a shell as a REPL (Read-Eval-Print Loop), the term REPL is more commonly used in the context of programming languages and interactive interpreters. A shell is more than just a REPL since it interacts with the operating systems and provides a more general command-line interface.

If you are interested in the intricacies of shells, I recommend checking out the codebase of an existing shell, such as Fish (my personal favorite), which is has been rewritten entirely in Rust.