‘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.
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.