Starting to Rust

doken

Doken Edgar

Posted on November 4, 2024

Starting to Rust

Starting to Rust: A Developer’s Journey into the Rust Language

This article is just me and my thoughts basically; trying to walk through my code and thought-processes, as I learn Rust.

A couple of weeks ago, I decided to challenge myself by learning a new programming language alongside what I use in my day job. That’s how I found myself diving into Rust. The steep learning curve was one of the things that attracted me to it (yes, I’m that kind of developer—you're welcome). Beyond the challenge, I believe Rust will make me a stronger developer overall. Concepts like memory management and data ownership, which are often handled behind the scenes in other languages, are front and center here. And, of course, working without a garbage collector adds a unique layer of excitement (and occasional frustration) with the borrow checker!

Anyways, according to Wikipedia, Rust is a general-purpose programming language, emphasizing performance, type safety, and concurrency. It enforces memory safety, meaning that all references point to valid memory. It does so without a traditional garbage collector; instead, both memory safety errors and data races are prevented by the "borrow checker", which tracks the object lifetime of references at compile time.

It has excellent documentation on how to get started at the official site.

I was using the Rust Book, (I still am), but in order to avoid being caught in tutorial hell, I asked Chat-GPT to suggest projects I can build in order of difficulty to solidify the concepts as I go along. It came back with the following suggestions:

  1. To-Do App
  2. Password Manager
  3. Conway’s Game
  4. Web Scraper
  5. Crate for Utilities
  6. Web Server
  7. URL Shortener
  8. Chat Application
  9. 2D Game
  10. Blockchain
  11. Text Editor

So, I started with the almighty Command-Line To-Do App.

The acceptance criteria was pretty straight forward: add tasks via the command line, display items from the todo list, save, update and delete items, and ensure the data is persisted by writing to a file.

The goal here is to basically get a feel of the Rust language with a specific project and goal in mind, whilst also learning concepts around File I/O, mutability, dealing with the dreaded borrow checker, vectors, modules, external crates and so on.

In order for this not to be an extremely long post, I'll break the write up into multiple parts, each focusing on addressing a single item from the acceptance criteria of the To-Do App.

A word of caution, these are my first forays into the Rust world, hence some of the code may not be the most efficient or idiomatic Rust code you'll ever see (I have this mental note that I can't wait to comeback to this code in a few months/years and see how far I've improved).

With that out of the way, here's my Command-Line To-do App in Rust!

As stated earlier, this is a simple command-line tool where users can manage a to-do list. The app allows users to add tasks, mark them as complete, display a list of tasks, and remove tasks. The list of tasks is saved to a file so that it persists across sessions.

Firstly, for this one app, I envisioned it's flow to be:

  • the app starts, and a menu is printed out on the terminal
  • the menu items are the operations that the app supports. These are view all tasks, add a task, update a task and delete a task
  • each menu item will have a number, and the user is expected to reply back with the menu number, signifying which operation they want to perform. This is because, it is a command line app, and so there's no GUI to use the mouse.

I created a project after setting up my development environment:

cargo new cli_to_do
Enter fullscreen mode Exit fullscreen mode

Cargo is a tool that helps manage Rust projects. It's not compulsory to use it, but it's quite beneficial especially when you start to add dependencies (called crates in Rust) and handling a large project, so it's not the worst idea to be using it here too.

Now, for a Rust app, the entry point is the main.rs file.

main.rs

As can be seen in the main(), I'm using a number of modules, which needed to be imported first:

mod add_task;
mod delete_task;
mod helpers;
mod menu;
mod to_do_struct;
mod update_task;
mod view_all_tasks;
Enter fullscreen mode Exit fullscreen mode

And the folder structure looks like this:

CLI To-Do folder structure

The code and functions for each menu item are saved in their individual modules, whilst the common functionalities are in the helpers module.

As seen from the main() function, when the program starts, the create_file_if_not_exists() function in the helpers module is called. This is to ensure that there's a file ready to work with, so if the file exists, it gets loaded, but if the file doesn't exist, it gets created so that the todo items can be saved on it.

Notice the usage of the double colon (::); in Rust, this can be / is used as a path separator, similar to the forward slash(/) in a file system. It basically points to where a module or function is found.
So

helpers::create_file_if_not_exists(FILE_PATH);
Enter fullscreen mode Exit fullscreen mode

means in the helpers module that we've already imported, there's a function called create_file_if_not_exists() that should be called. And that function takes an argument, which is a path to determine if the file exists at that location or not.

pub fn create_file_if_not_exists(file_path_string: &str) {
    if !check_file_exists(file_path_string) {
        let file_path = create_path(file_path_string);
        let _ = File::create_new(file_path);
    }
}
Enter fullscreen mode Exit fullscreen mode

Once this completes, the app then displays the available menu items that a user can choose from, menu::show_menu(true);
This command displays:

Menu showing available actions

And the user is expected to type in their response on the cli. This is retrieved by

let mut menu_input: Result<usize, ParseIntError> = menu::get_menu_selection();
Enter fullscreen mode Exit fullscreen mode

Now a couple of things here:

  • The mut in the menu_input variable declaration indicates that this is a mutable variable. By default, variables in Rust are immutable, but if you require them to be mutable, then you need to use the mut keyword when declaring that variable.
  • The second thing to note is the data type, Result<usize, ParseIntError>. What this means is, the function we're calling (menu::get_menu_selection()) returns a type which is a Result, but it can be one of two things: either we get a value of type usize (a built-in type in Rust), or we get a value of ParseIntError, the result of trying to parse an invalid value into a number. This is possibly because the user tried inputting words (or any character that cannot be passed into a number) instead of a number. The get_menu_selection() function in the menu module is where the input of the user is received, and that input is parsed to get the actual number, but if the parsing fails, the method returns the ParseIntError object. Now, we don't want the application to just crash because some fellow (definitely not from Dumne 😉), either intentionally or not, decided to provide an invalid input, so in the while {} block, we check if the result of parsing the input results in an error, if it does, we prompt the user to provide the input again, and don't move forward until a valid number is provided and parsed.

Once this happens, we need to then extract the number. There are a couple of ways to do this, the recommended way is to use what's called pattern matching. But that's not what was used here, and I'll explain why.
Instead of matching, I used the unwrap() function:

let mut menu_selection = menu_input.unwrap();
Enter fullscreen mode Exit fullscreen mode

Why matching is recommended is, it provides a way of handling both outcomes where a variable results in an error, as well as when it contains the actual value, but keep in mind that we've already ensured that the result can't be an error, because if it were, it wouldn't have passed the while {} block, so it feels redundant to check for that error here again.

When we get this number, we then need to ensure that the unwrapped value is not more than the available menu actions our app supports. So imagine someone enters the number 50, this is technically a valid number, but it's not logically valid in our case because the actions we support are numbered 1 - 5. That's what the next while {} block guards against:

while menu_selection > 6 {
        println!("Selected value {menu_selection}, greater than options");
        menu::show_menu(false);
        menu_input = menu::get_menu_selection();
        while menu_input.is_err() {
            println!("\nError parsing menu selection.\nPlease try again:\n");
            menu::show_menu(false);
            menu_input = menu::get_menu_selection();
        }
        menu_selection = menu_input.unwrap();
    }
Enter fullscreen mode Exit fullscreen mode

Note: I just realized that instead of "> 6" in the outer loop, it should've been "> 5", this doesn't really matter though, because we'll use pattern matching later on to map actions to the number inputs.

The outer loop ensures that the input is within the required range of actions, and if it's not, it asks the user to re-enter the selection again, and this requires us to re-validate that they're not making invalid inputs, instead of a number, hence the inner while {}.

Once a valid number has been provided, then

menu_selection = menu_input.unwrap();
Enter fullscreen mode Exit fullscreen mode

extracts the number from the Result.

This reassigning that occurs on the variables menu_input and menu_selection denotes why we needed to mark the variables with the mut keyword, as they have the tendency of being updated, depending on whether the user decided to be sensible or not (😂).

The last block/scope in the main() function tries to match actions to be performed, based on the number that was entered from the command-line:

match menu_selection {
        1 => view_all_tasks::view_all(),
        2 => add_task::create_todo(),
        3 => update_task::update_todo(),
        4 => delete_task::delete_task(),
        5 => println!("Have a nice day, good bye!"),
        _ => println!("\nDon't know what action to perform\n"),
    }
Enter fullscreen mode Exit fullscreen mode

This is called pattern matching.
As the name suggest, it takes a variable, and provides "arms" of what'll happen when there's a match.
The last arm, "_", is used to catch all other cases, if the provided ones were not matched.
Pattern matching is similar to switch-case statements in other programming languages like Typescript and the likes.

So in subsequent parts, I'll have posts to go through what happens at each "arm", that is, as we go from viewing all tasks, creating a new task, modifying an existing task and then deleting a task.

Let me know your thoughts, and please be on the lookout for the follow-up parts to this as I explore Rust land!

The repository with the code is here. It'll be updated as needed.

💖 💪 🙅 🚩
doken
Doken Edgar

Posted on November 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Starting to Rust
rust Starting to Rust

November 4, 2024

Starting to Rust
rust Starting to Rust

November 4, 2024