‘ve recently been working with Rust and learning about the language and ecosystem by implementing small, typical task applications. There’s no better way to learn than to jump into it.

In this post, I’ll cover how I could read data from a CSV file, deserialize the contents into an array of struct, and then output that collection into a pretty console table.

To follow along, I assume you have a working Rust environment. If not, I suggest RustRover, which works excellently and provides excellent tooling around the Rust ecosystem. Let’s get into it.

Crate Dependencies

I had to research to find the appropriate dependencies required to read a CSV from disk and then deserialize the rows into a Rust struct. These are the dependencies I found: csv, serde, and lazy_static.

The csv crate provides helper methods to read CSV files using a readers pattern. We’ll see how this works when we get to the code sample.

The serde crate allows me to serialize and deserialize directly into Rust data structures, which I’ll be doing from the on disk CSV.

While lazy_static is optional, it will allow us to read and deserialize our rows once for the application’s lifetime.

There will also be a set of other dependencies that you’ll need to import to get the app working. I also need to import ratatui and it’s dependency of crossterm to work with the terminal.

[dependencies]
crossterm = "0.27.0"
ratatui = "0.26.1"
csv = "1.3.0"
serde = { version = "1.0.197", features = ["derive"] }
lazy_static = "1.4.0"

Here is the complete list of use statements from the final sample.

use crossterm::{event:: {self, KeyCode}, ExecutableCommand};
use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};
use lazy_static::lazy_static;
use ratatui::{prelude::*, widgets::*};
use std::io::{stdout, Result};
use std::process::exit;
use crossterm::event::{Event, KeyEventKind};

Ok, we have everything we need to get started writing some code. Let’s start by processing our CSV file. Here is some sample data.

name,region,toppings
Margherita,Italy,"Tomatoes, Mozzarella, Basil, Salt, Olive oil"
Hawaiian,Canada,"Tomatoes, Ham, Pineapple, Cheese"
Pepperoni,USA,"Tomatoes, Pepperoni, Cheese"
BBQ Chicken,USA,"BBQ Sauce, Chicken, Onions, Cheese"

Processing a CSV Row into a Struct

While it’s possible to work with CSVs dealing with primitive types, it’s not generally a fun way to work with data. Instead, we want to deal with logical structures. In the case of this tutorial, we are dealing with Pizza.

#[derive(Debug, serde::Deserialize)]
pub struct Pizza {
    name: String,
    region: String,
    toppings: String,
}

The attribute of serde::Deserialize lets our serialization dependency know that this is something we’d like to fill with data.

Now, for the fun part, let’s read the data from disk.

fn read_pizzas() -> Vec<Pizza> {
    let mut reader = csv::Reader::from_path("pizzas.csv")
        .unwrap_or_else(|err| {
            eprintln!("Error reading CSV file: {}", err);
            exit(1);
        });

    reader.deserialize().map(|r| {
        match r {
            Ok(pizza) => pizza,
            Err(err) => {
                eprintln!("Error deserializing pizza: {}", err);
                exit(1);
            }
        }
    }).collect()
}

Rust expects us to handle the Result type and all its potential results. In the case of this sample, if the CSV is mangled, we want to exit the application. As you can see, it’s only two lines of code. The Pizza type is inferred from the Vec<Pizza> return value of our read_pizzas function.

We’ll need to put this information where we can access it once it’s initialized. Here, we’ll use the lazy_static! macro to define a global static type.

lazy_static! {
    static ref PIZZAS: Vec<Pizza> = read_pizzas();
}

Now, we can access this value using &*PIZZAS in our code.

let pizzas = &*PIZZAS;
let count = pizzas.len();
println!("{} total pizzas", count)

Output a Pretty Console Table

The next step uses the ratatui library, but be aware that this library is about building Terminal UIs referred to at TUIs. The nature of the this library is it relies on a UI loop and every iteration renders the contents. This is a helpful model, allowing for real-time updates and fascinating use cases.

To take advantage of ratatui, we must first update our main function to have a UI loop.

fn main() -> Result<()> {
    stdout().execute(EnterAlternateScreen)?;
    enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    terminal.clear()?;

    loop {
        terminal.draw(ui)?;
        std::thread::sleep(std::time::Duration::from_millis(10));
        // wait for keys
        if let Event::Key(key) = event::read()? {
            if key.kind == KeyEventKind::Press {
                use KeyCode::*;
                match key.code {
                    Char('q') | Esc => return Ok(()),
                    _ => {}
                }
            }
        }
    }
}

I’ve also allowed folks to exit the app by hitting q or Esc. To reduce the load on the CPU, I’ve also made it so the thread sleeps every 10 milliseconds, just enough to be responsive but not burn up my cores.

The only thing left to do is implement the ui function, but before we do that, we need a helper method to convert our Pizza struct, into a Row. Here we’ll use Rust’s impl keyword to fill in that functionality.

impl From<&Pizza> for Row<'_> {
    fn from(p: &Pizza) -> Self {
        Row::new(vec![
            p.name.clone(),
            p.region.clone(),
            p.toppings.clone(),
        ])
            .bottom_margin(1)
            .style(Style::default().fg(Color::White))
    }
}

Now, let’s look at the ui method.

fn ui(frame: &mut Frame) {
    let pizzas = &*PIZZAS;
    let widths = [
        Constraint::Max(20),
        Constraint::Max(10),
        Constraint::Max(50)
    ];
    let header = Row::new(vec![
        "Name".to_string(),
        "Region".to_string(),
        "Toppings".to_string(),
    ])
        .style(Style::default().fg(Color::White).bg(Color::Blue))
        .bold()
        .bottom_margin(1);

    let table = Table::new(pizzas, widths)
        .header(header)
        .footer(Row::new(vec![format!("{} Pizzas ", pizzas.len())]))
        .block(Block::new().borders(Borders::ALL).title("Pizzas"))
        .style(Style::new().blue())
        .widths(widths);

    frame.render_widget(table, frame.size());
}

Pretty straightforward. Running the application, we’ll see the table rendered in our terminal.

Terminal output from rust application in RustRover

Here’s the final codebase in its complete form.

use crossterm::{event::{self, KeyCode}, ExecutableCommand};
use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};
use lazy_static::lazy_static;
use ratatui::{prelude::*, widgets::*};
use std::io::{stdout, Result};
use std::process::exit;
use crossterm::event::{Event, KeyEventKind, read};

#[derive(Debug, serde::Deserialize)]
pub struct Pizza {
    name: String,
    region: String,
    toppings: String,
}

lazy_static! {
    static ref PIZZAS: Vec<Pizza> = read_pizzas();
}

impl From<&Pizza> for Row<'_> {
    fn from(p: &Pizza) -> Self {
        Row::new(vec![
            p.name.clone(),
            p.region.clone(),
            p.toppings.clone(),
        ])
            .bottom_margin(1)
            .style(Style::default().fg(Color::White))
    }
}

fn main() -> Result<()> {
    stdout().execute(EnterAlternateScreen)?;
    enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    terminal.clear()?;

    loop {
        terminal.draw(ui)?;
        std::thread::sleep(std::time::Duration::from_millis(10));
        // wait for keys
        if let Event::Key(key) = event::read()? {
            if key.kind == KeyEventKind::Press {
                use KeyCode::*;
                match key.code {
                    Char('q') | Esc => return Ok(()),
                    _ => {}
                }
            }
        }
    }
}

fn ui(frame: &mut Frame) {
    let pizzas = &*PIZZAS;
    let widths = [
        Constraint::Max(20),
        Constraint::Max(10),
        Constraint::Max(50)
    ];
    let header = Row::new(vec![
        "Name".to_string(),
        "Region".to_string(),
        "Toppings".to_string(),
    ])
        .style(Style::default().fg(Color::White).bg(Color::Blue))
        .bold()
        .bottom_margin(1);

    let table = Table::new(pizzas, widths)
        .header(header)
        .footer(Row::new(vec![format!("{} Pizzas ", pizzas.len())]))
        .block(Block::new().borders(Borders::ALL).title("Pizzas"))
        .style(Style::new().blue())
        .widths(widths);

    frame.render_widget(table, frame.size());
}

fn read_pizzas() -> Vec<Pizza> {
    let mut reader = csv::Reader::from_path("pizzas.csv")
        .unwrap_or_else(|err| {
            eprintln!("Error reading CSV file: {}", err);
            exit(1);
        });

    reader.deserialize().map(|r| {
        match r {
            Ok(pizza) => pizza,
            Err(err) => {
                eprintln!("Error deserializing pizza: {}", err);
                exit(1);
            }
        }
    }).collect()
}

Update: Once_cell instead of Lazy_static

I typically share my learnings in real-time on Mastodon, and someone mentioned that lazy_static has been usurped by once_cell, so I decided to port the code over to use the newer approach. The following lines are converted into the following.

// 👎 old lazy_static way
lazy_static! {
    static ref PIZZAS: Vec<Pizza> = read_pizzas();
}

// 👍 once_cell Lazy
static PIZZAS: once_cell::sync::Lazy<Vec<Pizza>> =
    once_cell::sync::Lazy::new(|| {
        read_pizzas()
    });

This is a much nicer approach in my opinion, and much easier to follow in code. Try out both ways to see which you prefer.

Conclusion

This was a fun little side project, but I learned much from it. For one, I learned more about Rust’s borrow checker and how to use lazy_static to create a shared resource in memory. Secondly, I learned how to read CSV files from disk and deserialize them into a custom struct. Finally, I learned about ratatui and building TUIs in Rust.

Thanks for reading and sharing my posts with friends and colleagues. Cheers.