This is an implementation in ls in Rust, designed to help Rust newbies (Rubies?) with building a simple CLI application.
- Cargo and Rust installed.
- An IDE of some form (Rust LSP optional)
- Unix terminal - Mac or Linux will be ok out of the box, you will need WSL if running Windows.
The Book The Cargo Book Building CLI Applications in Rust
ls is a command line program that lists the contents of a directory. This can be files, it can be other directories. It is simple, but very powerful. In this tutorial we will only focus on implementing a small portion of ls functionality, with the option of add more if one is so inclined.
We will be looking at implementing the following:
ls --help will display a help page with available commands.
ls will list all non-hidden (files not starting with .) files in the current directory.
'ls [PATH]' will list all non-hidden files in the provided directory.
ls -a will list all files, including hidden ones.
(In order for our program to no clash with the existing ls app, we will call it rust-ls, e.g rust-ls -a [PATH])
Cargo is the Rust package manager. It can help with basic project setup.
cargo new [PATH] creates a new Rust project in the specified directory.
cargo init creates a new project in the current directory.
If you look at the contents of your project directory, you will see that a Cargo.toml file has been added, as well as an src directory containing the main.rs file. This is a default Hello World application that is a helpful skeleton for your own project.
You can test out this default application using cargo run -> this will build and run the code in your project directory. You should see Hello, world! displayed in the terminal.
You can also build the application without running it using cargo build. By default, this will build it using the dev profile (which can be different from the release or test profiles). You can run your built application using the binary - ./target/debug/rust_ls.
Finally, cargo test allows you to run the tests for your application. If you run it right now, you will find there are no tests (so it will pass quite easily).
We are going to use these Docs, but keep it much simpler.
fn main() {
let entries = std::fs::read_dir(".").unwrap();
for entry in entries {
print!("{}\t", entry.unwrap().path().display())
}
}
unwrap() -> this is a shortcut for error handling in Rust - sometimes called implicit error handling. It will either return the successful result of an operation, or it will panic. This makes it useful shorthand for lmiting the amount of code you have to write, at the expense of being less specific about the error that may occur.
To demonstrate this, you can change the path being used in read_dir to something non-existient; e.g. let entries = std::fs::read_dir("green_apples").unwrap();. Running the application will lead to a panic (and in this case the automatic error message is more than sufficient to help the user understand what went wrong).
The above implementation displays the entire relative path of each entry in the directory. To make it more like ls, we want to extract just the file name from the path - fortunately this is something the Path object allows us to do easily, using the file_name() method.
Extracting the display functionality makes sense at this stage, and will allow us to do some fun stuff down the line, such as visually distinguish files from directories.
At the end of this, my code looks like this:
use std::fs::ReadDir;
fn main() {
let entries = std::fs::read_dir(".").unwrap();
display(entries);
}
fn display(entries: ReadDir) {
for entry in entries {
print!("{}\t", entry.unwrap().path().file_name().unwrap().to_str().unwrap());
}
}
PART THREE: DEALING WITH HIDDEN FILES
The result of read_dir gives us all the entries in the directory, and does not filter out hidden files, unlike the default behaviour of ls. So lets filter out hidden entries.
Some OS-related shinanigans creep in here - different operating systems have different ways of handling hidden files. For the sake of simplicity we are going to stick with the Linux standard and filter out any files or directories starting with ..
So lets add a method to determine whether a entry is hidden:
fn is_hidden(entry: &str) -> bool {
return entry.starts_with(".")
}
We are also going to rewrite the display() method to only display those entries that are not hidden:
fn display(entries: ReadDir) {
for entry in entries {
### here we have split up the operation to obtain the file name. We do this to keep the borrow checkerhappy - otherwise the path would have been created as a temporary variable that would have been freed at the end of the line. If you are interesting in understanding why, see [here](https://users.rust-lang.org/t/rust-and-strings-what-is-the-deal-with-temporary-value-dropped-while-borrowed/98424/2) for a good explanation.
let path = entry.unwrap().path();
let file_name = path.file_name().unwrap().to_str().unwrap();
if !is_hidden(file_name){
print!("{}\t", file_name)
}
}
}
So we have a barebones script that shows us all non-hidden entries in a directory, mimicing the default funcionality of ls. The directory and options (non-hidden/all) are hard-coded - lets change that by adding an argument parser.
This is where things start getting a little complex, so lets start with the easiest part: being able to switch between displaying (or not) hidden entries.
To keep things as simple as possible, lets refactor the display method_signiture to have a boolean property of display_all:
fn display(entries: ReadDir, display_all: bool)
We can then wrap the existing if-statement that calls is_hidden to skip that check if display_all is true:
fn display(entries: ReadDir, display_all: bool) {
for entry in entries {
let path = entry.unwrap().path();
let file_name = path.file_name().unwrap().to_str().unwrap();
if display_all {
print!("{}\t", file_name)
} else {
if !is_hidden(file_name){
print!("{}\t", file_name)
}
}
}
}
Is it pretty? No. But it works, and it is simple, and it is something that we can iterate on. The last step is to update the method call to include the new property:
fn main() {
let entries = std::fs::read_dir(".").unwrap();
display(entries, true);
}
We can now switch between displaying non-hidden and all entries in the directory. Lets add a way to control that when running the application.
There are two ways to do this:
Pros: You will learn a lot. Cons: You will hurt a lot.
Rust standard library includes a way of parsing arguments through std::env:args(). This will return an Iterator of arguments (always at least one - the name of the application, in this case rust-ls).
First, lets do some more refactoring and create a Config struct to hold all the data we aim to get from the arguments:
struct Config {
display_all: bool
}
Then we create a parsing method which takes collects the arguments in to a Vec<String> and builds a Config object based off the present arguments:
fn parse_args() -> Config {
let args: Vec<String> = std::env::args().collect::<Vec<String>>();
return Config {
display_all: args.contains(&"-a".to_string()) || args.contains(&"--all".to_string())
}
}
But then consider - what if the user includes more than one path? You would have to handle that edge case. Do the arguments have to be in a certain order? That will require a more complex implementation that considers the relationship between the arguments.
Writing a command line argument parser is a project of its own, which is why I would recommend:
We are going to use a crate called Clap, which provides all the commonly used features you might expect from a CLI, allowing you to focus on writing the funcionality you actually want from the application.
We need to add clap as a depenency of our project:
cargo add clap --features derive
And then import it within main.rs:
use clap::Parser;
We then create a struct to hold the Arg data, annotating it to enhance it with all those delicious clap features. Lets start with our hidden file functionality:
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long, long_help="show hidden files", default_value_t=false)]
all: bool,
}
Note that because clap automatically generates the --help page, the name of the all value is the same as we want it to be in our docs. You can change this behaviour, but we do not need to in this case - the naming makes sense.
We then call the static parse() method of Args in our main function - the traits that clap added to the struct allow us to do this directly, without having to handroll a function to build it ourselves.
fn main() {
let args = Args::parse();
let entries = std::fs::read_dir(".").unwrap();
display(entries, args.all);
}
Now running rust-ls with the appropriate arguments will allow you to display non-hidden or all entries as expected. Additionally, Clap will automatically generate --help and --version commands, saving you some effort.
Adding a Path argument is very similar. We have to distinguish here between arguments and options - Path is an argument, essential to the functioning of the application (You will always need a directory to list), while all is an option, which modifies how the application functions. Within the Args struct, the distinction is quite simple - we do not annotate path with short or long values:
#[derive(Parser, Debug)]
#[command(version, about, long_about = "A Rust Implementation of ls")]
struct Args {
#[arg(long_help="path to directory", default_value=".")]
path: PathBuf,
#[arg(short, long, long_help="show hidden files", default_value_t=false)]
all: bool,
}
Check out cargo run -- --help and note how the help page treats the two args differently.
We can now control the directory that we want to list - take it for a spin using cargo run -- ~ to list the contents of the home directory. Or try cargo run -- -a ~ to see all the configurations that your OS does not want you to see.
- Distinguish between files and directories in the output by making directories bold and in a different colour.
- Build your own argument parser!