DEV Community

Cover image for Building a Custom Shell in Rust from scratch
Max
Max

Posted on

Building a Custom Shell in Rust from scratch

Introduction

Building a custom REPL (Read-Eval-Print Loop) in Rust is a great way to deepen your understanding of system programming, async I/O, and command parsing. In this article, we'll walk through the process of creating a simple shell that can execute basic commands like echo, ls, and cd.

A REPL (Read-Eval-Print Loop) is an interactive programming environment that continuously reads user input, evaluates it, prints the result, and loops back for more input. Our shell will follow this same structure:

  • Read – Capture user input from standard input (stdin).
  • Evaluate – Convert input into a structured Command type and execute it.
  • Print – Display relevant output to the user via standard output (stdout).
  • Loop – Wait for the next command and repeat the cycle.

To keep things simple, our shell will support the following commands:

  • echo – Print text to the console.
  • ls – List files in the current directory.
  • pwd – Show the present working directory.
  • cd – Change directories.
  • touch – Create new files.
  • rm – Remove files.
  • cat – Read and display file contents.

Along with this guide, I've also created a YouTube video demonstrating the entire build process from start to finish, which you can check out here:

And the complete source code can be found here


Project setup

Before diving into implementation, let’s set up our Rust project and add the necessary dependencies. We'll use cargo, Rust’s package manager, to create a new project.

cargo new shell
Enter fullscreen mode Exit fullscreen mode

This will create a new Rust project inside a shell/ directory with the usual Cargo.toml and src/main.rs files.

Dependencies

For this project we only need 2 dependencies:

  • Tokio – A powerful asynchronous runtime that allows us to handle user input and I/O operations efficiently.
  • Anyhow – A lightweight error-handling library that simplifies working with Result types.

Add them to your Cargo.toml file:

[dependencies] 
anyhow = "1.0.95" 
tokio = { version = "1.43.0", features = ["full"] }
Enter fullscreen mode Exit fullscreen mode

Error handling

Error handling is a critical part of any shell, as we need to gracefully handle issues like missing files, invalid commands, and permission errors. Instead of manually defining a complex error-handling system, we’ll use the anyhow crate to simplify the process.

Why anyhow?

Rust’s standard error handling requires defining detailed Result<T, E> types for every function that can fail. While powerful, this can become cumbersome. anyhow provides a streamlined approach by allowing us to return a generic error type (anyhow::Error) that captures various failure cases without extra boilerplate.

Setting Up Error Handling

To integrate anyhow, create a new file called src/errors.rs and add:

pub type CrateResult<T> = anyhow::Result<T>;
Enter fullscreen mode Exit fullscreen mode

This creates a reusable type alias, CrateResult<T>, which we’ll use throughout our code for functions that may return errors.

Now, import this module in main.rs so we can use it across our project:

mod errors;
Enter fullscreen mode Exit fullscreen mode

With this in place, we can now handle errors more cleanly in our shell’s command execution and I/O operations.


I/O operations

Now that we’ve set up error handling, it’s time to implement I/O operations—the backbone of our REPL loop.

Setting Up the REPL Loop

We’ll run our REPL (Read-Eval-Print Loop) in a dedicated asynchronous task using tokio::spawn. While this isn’t strictly necessary, it’s a great example of managing async tasks in Rust.

Add the following code to main.rs:

use errors::CrateResult;
use tokio::{
    io::{AsyncBufReadExt, AsyncWriteExt},
    task::JoinHandle,
};

fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
    tokio::spawn(async {
        // Initialize stdin and stdout
        let stdin = tokio::io::stdin();
        let stdout = tokio::io::stdout();

        let mut reader = tokio::io::BufReader::new(stdin).lines();
        let mut stdout = tokio::io::BufWriter::new(stdout);

        stdout.write(b"Welcome to the shell!\n").await?;

        while let Ok(Some(line)) = reader.next_line().await {
            // Log user input for now (we'll process commands later)
            println!("User entered: {}", line);
        }

        Ok(())
    })
}
Enter fullscreen mode Exit fullscreen mode

Breaking It Down:

  • tokio::spawn(async { ... }) → Runs the REPL loop in an async task.
  • BufReader::new(stdin).lines() → Reads user input line by line.
  • BufWriter::new(stdout) → Buffers output for improved performance.
  • Looping with reader.next_line().await → Continuously reads user input.
  • Logging input (println!) → Right now, we just print what the user types (we’ll process commands later).

Hooking It Up to main

Now, update main.rs to start the REPL loop and handle potential errors:

#[tokio::main]
async fn main() {
    let user_input_handler = spawn_user_input_handler().await;

    if let Ok(Err(e)) = user_input_handler {
        eprintln!("Error: {}", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that if anything goes wrong during execution, we’ll log the error and exit cleanly.


Command handling

Now that our shell can read user input, we need a way to interpret and execute commands.

Since our shell supports a fixed set of commands, we’ll use an enum to represent them. Enums in Rust provide a safe, type-checked way to handle different command variants without relying on fragile string comparisons.

Defining the Command Enum

Create a new file src/command.rs and define our Command enum:

use anyhow::anyhow;

#[derive(Clone, Debug)]
pub enum Command {
    Exit,
    Echo(String),
    Ls,
    Pwd,
    Cd(String),
    Touch(String),
    Rm(String),
    Cat(String),
}
Enter fullscreen mode Exit fullscreen mode

Each variant represents a command, and those requiring an argument (like echo, cd, touch, rm, and cat) store a String inside them.

Parsing User Input into Commands

To convert user input into a Command, we’ll implement TryFrom<&str> for Command:

impl TryFrom<&str> for Command {
    type Error = anyhow::Error;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let split_value: Vec<&str> = value.split_whitespace().collect();

        match split_value[0] {
            "exit" => Ok(Command::Exit),
            "ls" => Ok(Command::Ls),
            "echo" => {
                if split_value.len() < 2 {
                    Err(anyhow!("echo command requires an argument"))
                } else {
                    Ok(Command::Echo(split_value[1..].join(" ")))
                }
            }
            "pwd" => Ok(Command::Pwd),
            "cd" => {
                if split_value.len() < 2 {
                    Err(anyhow!("cd command requires an argument"))
                } else {
                    Ok(Command::Cd(split_value[1..].join(" ")))
                }
            }
            "touch" => {
                if split_value.len() < 2 {
                    Err(anyhow!("touch command requires an argument"))
                } else {
                    Ok(Command::Touch(split_value[1..].join(" ")))
                }
            }
            "rm" => {
                if split_value.len() < 2 {
                    Err(anyhow!("rm command requires an argument"))
                } else {
                    Ok(Command::Rm(split_value[1..].join(" ")))
                }
            }
            "cat" => {
                println!("{}", split_value[1..].join(" "));
                if split_value.len() < 2 {
                    Err(anyhow!("cat command requires an argument"))
                } else {
                    Ok(Command::Cat(split_value[1..].join(" ")))
                }
            }

            _ => Err(anyhow!("Unknown command")),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Breaking It Down:

  • Splitting the input → We use .split_whitespace() to break the input into words.
  • Matching commands → We check the first word (split_value[0]) to extract the user’s command.
  • Handling arguments → Commands like echo, cd, etc., require additional arguments, so we check split_value.len() < 2.
  • Error handling → We return Err(anyhow!(...)) if the command is unknown or missing.

Integrating Command Parsing into main.rs

Now, import the Command module at the top of main.rs:

use command::Command;

mod command;
Enter fullscreen mode Exit fullscreen mode

Let’s integrate this new type into src/main.rs to provide uniform handling for user inputs. Create the below function:

async fn handle_new_line(line: &str) -> CrateResult<Command> {
    // Leverages the TryFrom trait implemented above
    let command: Command = line.try_into()?;

    match command.clone() {
        // Placeholder
        _ => {}
    }
    Ok(command)
}
Enter fullscreen mode Exit fullscreen mode

Now update the REPL loop in spawn_user_input_handler to call this function on user’s inputs:

while let Ok(Some(line)) = reader.next_line().await {
    let command = handle_new_line(&line).await;

    if let Ok(command) = &command {
        match command {
            _ => {}
        }
    } else {
        eprintln!("Error parsing command: {}", command.err().unwrap());
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, our shell can parse user input’s and match commands, but the command execution is incomplete. Next, we’ll improve logging, then implement the command logic for each, starting with filesystem operations like ls, pwd, cd, and file manipulation.


Improve logging

Let’s improve the logging for our shell so that using it feels more familiar. To do this we should first create a helper method that returns the Shell’s current directory, we will need to log this after each command to emulate a normal Shell’s behaviour.

Create the file src/helpers.rs and add this method:

use crate::errors::CrateResult;

pub fn pwd() -> CrateResult<String> {
    let current_dir = std::env::current_dir()?;

    Ok(current_dir.display().to_string())
}
Enter fullscreen mode Exit fullscreen mode

And import this new file into our module:

mod helpers;
Enter fullscreen mode Exit fullscreen mode

Now lets modify the logging inside our REPL loop to print “>” in the terminal before users enter their commands and also to log the users present working directory after each command. Update your spawn_user_input_handler to match the following:

fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
    tokio::spawn(async {
        let stdin = tokio::io::stdin();
        let stdout = tokio::io::stdout();

        let mut reader = tokio::io::BufReader::new(stdin).lines();
        let mut stdout = tokio::io::BufWriter::new(stdout);

        stdout.write(b"Welcome to the shell!\n").await?;

        stdout.write(pwd()?.as_bytes()).await?;
        stdout.write(b"\n>").await?;
        stdout.flush().await?;

        while let Ok(Some(line)) = reader.next_line().await {
            let command = handle_new_line(&line).await;

            if let Ok(command) = &command {
                match command {
                    _ => {}
                }
            } else {
                eprintln!("Error parsing command: {}", command.err().unwrap());
            }

            stdout.write(pwd()?.as_bytes()).await?;
            stdout.write(b"\n>").await?;
            stdout.flush().await?;
        }

        Ok(())
    })
}
Enter fullscreen mode Exit fullscreen mode

Commands

Now we have built a system to interpret and execute commands, now we need to implement the logic for each.

Exit

Let’s first add the exit command so user’s can close the terminal, to do this modify the while loop in src/main.rs to break when this command is detected:

if let Ok(command) = &command {
    match command {
        Command::Exit => {
            println!("Exiting...");
            break;
        }
        _ => {}
    }
}
Enter fullscreen mode Exit fullscreen mode

Echo

The echo command simply prints out the arguments given to it, which is useful for displaying messages or testing output. Handle the Echo command in handle_new_line:

Command::Echo(s) => {
    println!("{}", s);
}
Enter fullscreen mode Exit fullscreen mode

Ls

The ls command lists files in the current directory.

  1. Create a helper function in src/helpers.rs:
pub fn ls() -> CrateResult<()> {
    let entries = fs::read_dir(".")?;

    for entry in entries {
        let entry = entry?;
        println!("{}", entry.file_name().to_string_lossy());
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode
  1. Handle Ls in handle_new_line:
Command::Ls => {
    helpers::ls()?;
}
Enter fullscreen mode Exit fullscreen mode

Pwd

The pwd command prints the current working directory, we’ve already defined this function so just add the relevant match case in handle_new_line:

Command::Pwd => {
    println!("{}", helpers::pwd()?);
}
Enter fullscreen mode Exit fullscreen mode

Cd

The cd command changes the current directory.

  1. Create a helper function in src/helpers.rs:
pub fn cd(path: &str) -> CrateResult<()> {
    std::env::set_current_dir(path)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode
  1. Handle Cd in handle_new_line:
Command::Cd(s) => {
    helpers::cd(&s)?;
}
Enter fullscreen mode Exit fullscreen mode

Touch

The touch command creates an empty file.

  1. Create a helper function in src/helpers.rs:
pub fn touch(path: &str) -> CrateResult<()> {
    fs::File::create(path)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode
  1. Handle Touch in handle_new_line:
Command::Touch(s) => {
    helpers::touch(&s)?;
}
Enter fullscreen mode Exit fullscreen mode

Rm

The rm command removes a file.

  1. Create a helper function in src/helpers.rs
pub fn rm(path: &str) -> CrateResult<()> {
    fs::remove_file(path)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode
  1. Handle Rm in handle_new_line:
Command::Rm(s) => {
    helpers::rm(&s)?;
}
Enter fullscreen mode Exit fullscreen mode

Cat

The cat command reads and prints the contents of a file.

  1. Create a helper function in src/helpers.rs:
pub fn cat(path: &str) -> CrateResult<String> {
    let pwd = pwd()?;
    let joined_path = std::path::Path::new(&pwd).join(path);
    let contents = fs::read_to_string(joined_path)?;
    Ok(contents)
}
Enter fullscreen mode Exit fullscreen mode
  1. Handle Cat in handle_new_line:
Command::Cat(s) => {
    let contents = helpers::cat(&s)?;
    println!("{}", contents);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We’ve successfully built a simple REPL/Shell in Rust that supports basic commands like ls, pwd, cd, touch, rm, and cat. The architecture is modular, making it easy to extend with additional commands.

This is just the beginning! You can add more features, such as:

  • Command history
  • Autocompletion
  • Piping commands
  • Background execution

If you want to dive deeper, check out the accompanying YouTube video that walks through the build process step by step

ASMR Programming - REPL Shell in Rust 🦀 (Outside - NO TALKING) - YouTube

Happy coding! 🚀

Top comments (0)