Building a CLI wordle game in Rust: Part 5
Welcome to the fifth part of this Rust tutorial. In Part 4, we coded an import tool to extend the localized text-file based dictionary and added an integration test. We learned how to parse command line arguments and print meaningful errors for them. This time we will connect a database and implement additional features.
Table of Contents
- Prerequisites
- Getting started
- About ORM
- Sqlite setup
- Postgres setup (optional)
- Adding a new dictionary
- Fun with diesel ORM
- Fun with diesel’s query builder
- The fuzz about
dynandBox<?> - Switching dictionaries
- Rolling back
- Wrapping it up
Prerequisites
This part continues from the last as always, so make sure you completed Building a CLI wordle game in Rust: Part 4. For the database integration, there are a few things to be done before we start. Install the CLI for diesel, an ORM and query builder by following the “Installing Diesel CLI” step on the getting started page.

If you have troubles finding the install section, use the following command.
cargo install diesel_cli --no-default-features --features sqlite
Sometimes, there are things missing for diesel to work. In my case, a lib for the sqlite3 development integration was missing because it’s not part of the artifact.

Try sudo apt install libsqlite3-dev when encountering this error.
I originally started this part with postgres, but changed my mind halfway through this tutorial. Don’t get me wrong, postgres is a great, well-maintained, feature-rich DBMS, but it does have too many features we don’t need in our application. For example, we don’t need administration, multiple users, concurrency or complex data types. You’d have to perform some additional setup by installing a local postgres instance or setting up a docker container.
A sqlite database would suit our needs perfectly, it does not require you to perform any additional setup, and it can be shared in the git repository (albeit binaries shouldn’t be put in git). It can be shipped as part of the finished application.
However, if you want to use postgres instead, feel free to do so. Because I
already prepared this chapter,
jump to the postgres setup and just mentally replace
any mentions of sqlite with postgres thorough the source code and Cargo.toml
explanations. Because we use an ORM, there shouldn’t be any differences.
Use diesel --version to verify if your installation is working. In this
tutorial I use version 1.4.1.
After that, there are four new Rust community crates to be used, add them to
your Cargo.toml like this:
[package]
name = "fancy-hangman"
version = "0.1.0"
edition = "2021"
default-run = "game"
[[bin]]
name = "game"
path = "src/main.rs"
[[bin]]
name = "import"
path = "src/bin/import.rs"
[dependencies]
colored = "2"
rand = "0.8.5"
any_ascii = "0.3.1"
sys-locale = "0.2.0"
uuid = { version = "1.1.1", features = ["v4", "fast-rng"] }
clap = { version = "3.1.18", features = ["derive"] }
strum_macros = "0.24.0"
dotenv = "0.15.0"
chrono = "0.4.0"
diesel = { version = "1.4.4", features = ["sqlite", "chrono"] }
As mentioned before, diesel is the tool of
choice for database integration. We will have a Date column in the
dictionary table, so we need chrono because
it’s required by diesel. Thanks to dotenv we
can read environment variables from a local .env file.
Run cargo build.
Getting started
Over the next few minutes we will
- Set up a database
- Implement a new dictionary
- Implement the “word of the day” feature
About ORM
I’m not the biggest fan of query builders and object relational mapping. You tend to forget what is happening under the hood. Usually, you spend a lot of time figuring out how to correctly build a working query in a particular framework and sometimes DBMS features are not supported. All that precious time could be spent on actually learning SQL instead.

On top of that, when it comes to complex data queries, e.g. by joining tables, you often need to fragment statements that could be a more performant single one, only to manually merge the data in the code later. When a generated query fails – not only faulty statements, but also those that return unexpected data – it can be hard to analyze what actually has been executed on the DBMS, depending on the framework.
But to be fair, with smaller data structures like we have with our Dictionary
those frameworks do provide some convenience. And because this is a Rust
tutorial and not a SQL tutorial I’ll hold myself back and disregard the
performance aspect albeit there might be prettier table designs.
Sqlite setup
Download and install the latest sqlite
binaries suited for your system. When using Linux or
Ubuntu WSL, you should be able to install sqlite by
issuing sudo apt install sqlite3 in the terminal. Verify the installation by
executing sqlite3 in the terminal.

Close the application by pressing ctrl+d.
The database will be represented by a single file named dictionary.db located
in the res/db directory. Create a .env file in the project root and set the
DATABASE_URL property accordingly.
DATABASE_URL=res/db/dictionary.db
Run diesel setup. This command creates a migrations folder and a
diesel.toml in the project root.

That’s it! You can jump to the chapter Adding a new dictionary now.
Postgres setup (optional)
As DBMS, you can optionally replace sqlite with postgres. Install the packages for diesel needed as described in the page.
I will use docker when explaining the database setup. You can choose between configuring a local postgres instance or setting up docker if you didn’t have already. By now, you should have set up a working diesel CLI installation as I mentioned in the prerequisites. Time to set up the database itself. We’ll use the latest postgres Docker image by executing the following command.
docker run --name wordle-db -e POSTGRES_USER=wordle-rs -e POSTGRES_PASSWORD=wordle-pwd -p 5432:5432 -e POSTGRES_DB=wordle-db -d postgres
In the documentation it said that theoretically you can set it up without a
POSTGRES_PASSWORD set, but when I tried, the container would not start up so
make sure you set it. Check your container with docker ps.

One tip: After the first successful container start, you can shut it down with
docker stop wordle-db and start it again with docker start wordle-db. You
will receive an error when running the full command twice.
After it’s up and running, create a .env file in the project’s root directory.
Set the DATABASE_URL property required by diesel to point to the database we
just created.
DATABASE_URL=postgres://wordle-rs:wordle-pwd@localhost:5432/wordle-db
Run diesel setup. Let’s look into the database container once it has finished
by issuing docker exec -it wordle-db psql -U wordle-rs -d wordle-db.

Adding a new dictionary
You can ignore the __diesel_schema_migrations table. It just helps diesel
internally to handle stuff and is unrelated to our own data. Speaking of which,
time to create the dictionary table. In order to do so, define a
diesel migration with the
following command.
diesel migration generate create_dictionary
This will generate a new folder containing the migration’s name in the
migrations directory in the project root.

Anything in those generated files will be executed when telling diesel to
migrate. up.sql should contain the table creation SQL.
create table dictionary
(
id integer primary key not null,
word varchar not null,
used_at DATE null,
guessed boolean default 'f' not null
)
down.sql should destroy anything that has been created before, thus in here
the tables should be dropped.
drop table dictionary;
Open diesel.toml and change the file property of print_schema to match
src/db/schema.rs. This file will be generated in the next step and I like to
keep things tidy. Also don’t forget to create a new folder named db in the
src folder.
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
After that, run the migration with diesel migration run.

There should be a new dictionary table in our postgres instance.

When running migrations, diesel generates the schema.rs file. Locate it in the
path we set before.
table! {
dictionary (id) {
id -> Int4,
word -> Varchar,
used_at -> Nullable<Date>,
guessed -> Bool,
}
}
The table! macro generates a lot of code that ensures mapping between the
types we use in Rust and those of the underlying DBMS, in our case postgres. You
can find an in-depth description in the
diesel guide.
That’s it, the database is set up! Additionally, you can import the database URL into a SQL tool of your choice to make browsing easier.
Fun with diesel ORM
Before we start coding, consider a few things. Until now only TextDictionary
gets created. We defined shared behavior for an additional Dictionary. diesel
requires an additional internal model for the application that the query results
get mapped into. After that, the Rust binaries themselves should be able to
choose between creating a TextDictionary and a DbDictionary.
Start with the module. That means, we’ll create a new module in the already
existing db folder. Add new files: mod.rs, db_dictionary.rs and model.rs
on top of schema.rs to match the new project structure.
fancy-hangman
|- Cargo.toml
|- diesel.toml
|- bin
|- src
|- import.rs
|- res
|- db
|- dictionary.db
|- dictionary_en.txt
|- source_file.txt
|- src
|- db
|- db_dictionary.rs
|- mod.rs
|- model.rs
|- schema.rs
|- lib.rs
|- main.rs
|- dictionary.rs
|- text
|- mod.rs
|- text_dictionary.rs
|- tests
|- text_dictionary_test.rs
Add the module declarations in the newly created mod.rs first.
mod model;
mod schema;
pub mod db_dictionary;
DbDictionary should handle database connectivity internally, just like
TextDictionary does with its dictionary_file_path. The same applies to the
model and the schema. There is no need for other modules to have access, the
only publicly accessible module should be db_dictionary.
Before we do that, open lib.rs and add the following lines.
pub mod db;
#[macro_use]
extern crate diesel;
Without macro_use, the code wouldn’t compile. Although since the 2018 version,
macro_use is discouraged, but some crates still ship with this requirement. It
allows us to access diesel macros across all applications, for example the
table! macro in the generated schema.rs.
Now, open model.rs.
use chrono::NaiveDate;
use super::schema::dictionary;
#[derive(Queryable)]
pub struct DbDictionaryEntry {
pub id: i32,
pub word: String,
pub used_at: Option<NaiveDate>,
pub guessed: bool,
pub language: String
}
#[derive(Insertable)]
#[table_name="dictionary"]
pub struct NewDbDictionaryEntry {
pub word: String,
pub language: String
}
We need two model definitions. The first one, DbDictionaryEntry, is a full
representation of one row in the table dictionary we created when setting up
the database. The Nullable column used_at translates into an Option .
language is part of the localization. Theoretically, a more clean way would be
defining separate tables for each language, but that’s a hard thing to do with
an ORM as the schema would not be known at compile time.
The second one, NewDbDictionaryEntry only exists for inserting a new row. In
the create table statement it is shown that the id is an auto incrementing
primary key and used_at and guessed both have default values set. It’s not
possible to use both derivations
Queryable and
Insertable on the
same struct because, when creating an instance, the field all need to be set and
thus id would be overwritten, resulting in a possible duplicate key violating
a unique constraint. You can’t have two data sets with the same primary key, and
you shouldn’t keep track of primary key uniqueness across the application. That
job belongs to the DBMS.
By defining a second model, the DBMS handles everything except the word and
language columns that come from the application. But now, open
db_dictionary.rs.
Fun with diesel’s query builder
Thanks to the fact that complex data structures are easier to handle with
databases than they are with plain text files, we have more possibilities to
create an enhanced user experience. I sneakingly introduced a few new attributes
for the DbDictionary you haven’t seen in the TextDictionary implementation.
The latter randomly selected a word from the text file, so there’s a chance it
could occur a second time. Where’s the fun in that? DbDictionary won’t work
that way. Similarly to the original game, there’s only one word a day to be
guessed. We can achieve this by updating the row that was randomly selected and
setting today’s date in the used_at column. That row will represent the word
of the day.
This way, if the player fails to guess the word, he won’t have any more attempts
left. If he guessed correctly, the guessed flag will be set to true, and he
receives our dearest congratulations when starting the game again. Start
implementing DbDictionary like shown below.
pub struct DbDictionary {
conn: SqliteConnection,
app_language: AppLanguage
}
impl DbDictionary {
pub fn new(db_url: String, app_language: AppLanguage) -> DbDictionary {
DbDictionary {
conn: SqliteConnection::establish(&db_url)
.expect(&format!("Error connecting to database {}", db_url)),
app_language
}
}
fn update_entry(&self, entry: &DbDictionaryEntry) {
// ...
}
fn get_word_of_today(
&self,
current_day: NaiveDate
) -> Result<Option<DbDictionaryEntry>, Error> {
Ok(None)
}
}
impl Dictionary for DbDictionary {
/// Return a randomly selected word that has not been used before.
/// If found, the database entry will be updated with
/// a [chrono::NaiveDate] matching today's date.
fn get_random_word(&self) -> Option<DictionaryEntry> {
None
}
fn find_word(&self, text: &str) -> Option<DictionaryEntry> {
None
}
fn create_word(&self, word_entry: DictionaryEntry) {
// ...
}
}
Because code tends to become messy when using a query builder, I added two
methods to DbDictionary that help us achieve the new requirements.
update_entry should do as the name says, update an existing row.
fn update_entry(&self, entry: &DbDictionaryEntry) {
match diesel::update(dictionary::dsl::dictionary
.filter(dictionary::id.eq(entry.id)))
.set((dictionary::used_at.eq(entry.used_at),
dictionary::guessed.eq_all(entry.guessed)))
.execute(&self.conn) {
Ok(affected_rows) => if affected_rows <= 0 { println!("No rows were affected when updating {}", entry.id) },
Err(error) => println!("Error when updating entry with id {}:\n{}", entry.id, error)
}
}
This will help with the first part of the “word of the day” feature and when the player successfully guessed the word.
It’s nothing special, just match the id and set used_at and guessed
accordingly. set can process
Tuples to
update more than one column. execute returns a
diesel::result::QueryResult
that we can process in a match construct, we should do that to get useful
error messages. Compared to a plain text file that we use standard libraries
for, database connections handled by an external crate provide more error
potential.
fn get_word_of_today(
&self,
current_day: NaiveDate
) -> Result<Option<DbDictionaryEntry>, Error> {
match dictionary::dsl::dictionary
.filter(dictionary::used_at.eq(current_day))
.filter(dictionary::guessed.eq(false))
.filter(dictionary::language.eq(&self.app_language.to_string()))
.limit(1)
.get_result::<DbDictionaryEntry>(&self.conn)
.optional() {
Err(error) => Err(error),
Ok(result) => match result {
Some(entry) => Ok(Some(entry)),
None => dictionary::dsl::dictionary
.filter(dictionary::used_at.is_null())
.filter(dictionary::language
.eq(&self.app_language.to_string()))
.order(sql::<()>("RANDOM()"))
.limit(1)
.get_result::<DbDictionaryEntry>(&self.conn)
.optional()
}
}
}
This wonky-looking construct consecutively executes two queries to fetch today’s
word. The first part looks for any entries that have the current day set as
used_at. If Some exists, it’s being returned as Option. If None exists,
then the second query is executed that randomly selects a row where used_at is
null. Those statements translate to the SQL below.
select *
from dictionary
where used_at = ?
and guessed = false
and language = ?;
That’s straightforward. Return not guessed entries where the language equals the application settings’ language that have been used at a specific date. This query should always be executed with today’s date.
select *
from dictionary
where used_at is null
and guessed = false
and language = ?
order by random() limit 1;
This one might need some more explanation. Get all entries where used_at and
guessed are not set. language should match as well, then order them randomly
with the random
function and finally limit the result to one. The first statement doesn’t need
limitation as the maximum row count of one is implicit unless somebody tinkers
with the data.
Now it’s time to start implementing Dictionary for DbDictionary.
/// Return a randomly selected word that has not been used before.
/// If found, the database entry will be updated with
/// a [chrono::NaiveDate] matching today's date.
fn get_random_word(&self) -> Option<DictionaryEntry> {
let current_day = chrono::Utc::now();
let current_day: NaiveDate = current_day.naive_utc().date();
match self.get_word_of_today(current_day) {
Ok(result) => match result {
None => None,
Some(mut entry) => {
entry.used_at = Some(current_day);
self.update_entry(&entry);
Some(DictionaryEntry{ word: entry.word })
}
}
Err(error) => {
println!("Error when getting today's word.\n{}", error);
None
}
}
}
get_random_word now fetches an unused entry and marks it with the current date
right afterwards. Therefore, this method will only return the marked word when
called a second time.
fn find_word(&self, text: &str) -> Option<DictionaryEntry> {
let db_result = dictionary::dsl::dictionary
.filter(dictionary::word.eq(text))
.filter(dictionary::language.eq(&self.app_language.to_string()))
.get_result::<DbDictionaryEntry>(&self.conn)
.optional();
match db_result {
Ok(db_word) => match db_word {
Some(entry) => Some(DictionaryEntry { word: entry.word }),
None => None
},
Err(error) => {
println!("Error when looking for '{}' in the database:\n{}",
text, error);
None
}
}
}
find_word looks for an entry with matching word and language.
fn create_word(&self, word_entry: DictionaryEntry) {
match self.find_word(&word_entry.word) {
None => {
let new_word = NewDbDictionaryEntry {
word: word_entry.word,
language: self.app_language.to_string()
};
let db_result = diesel::insert_into(dictionary::table)
.values(&new_word)
.execute(&self.conn);
match db_result {
Ok(_) => println!("Added '{}' to the database!", &new_word.word),
Err(e) => println!("Error when writing '{}' to the database:\n{}", &new_word.word, e)
};
},
Some (_) => {}
};
}
Like in TextDictionary, create_word implicitly calls find_word to avoid
duplicates. After that, an INSERT is being performed.
The fuzz about dyn and Box<?>
When having multiple implementations of a trait, and you want a function to
return said trait, the compiler does not know how much memory to allocate.
That means, in our case, that if you thought about creating a function
get_dictionary() -> Dictionary that returns either a TextDictionary or a
DbDictionary based on various circumstances just like you do in other
languages – e.g. Java – you can quickly put this thought aside. The Rust
documentation mentions how to proceed in
Returning Traits with dyn.
I recommend reading about
stack and heap
to understand what happens here. To summarize, the implementation won’t be
statically allocated, but dynamically allocated on the heap. Instead of
returning Dictionary, the Rust compiler requires us to return a reference that
points to the heap. The reference has a static size that is known during
compile-time.
We do so by putting the implementation of Dictionary into a
std::boxed::Box. But
that’s not enough, the compiler doesn’t know which implementation will be
returned during runtime. Therefore, the
dyn keyword exists. Thankfully
the implementation is fairly simple and doesn’t differ that much from the
original idea.
pub fn get_dictionary(
app_language: AppLanguage,
dictionary_flag: String
) -> Box<dyn Dictionary> {
// ...
}
Switching dictionaries
As for the decision on how to determine the Dictionary implementation, I
thought about a flag that can assume either "text" or "db" with the
TextDictionary as backup. Because the DbDictionary must have a URL, the
function is allowed to `panic! when the URL is missing. It could like this:
pub fn get_dictionary(
app_language: AppLanguage,
dictionary_flag: String
) -> Box<dyn Dictionary> {
match dictionary_flag.as_str() {
"db" => {
dotenv().ok();
Box::new(DbDictionary::new(
env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
app_language
))
},
_ => Box::new(TextDictionary::new(format!("res/dictionary_{}.txt", app_language.to_string()))),
}
}
Thanks to the dotenv Rust community crate, we
can read the DATABASE_URL property in a fairly simple way from the .env
file. The expect function panics when the property does not exist and prints
the error message we set.
The flag should be received as CLI argument. Open main.rs and add a language
argument like we did in
Building a CLI wordle game in Rust: Part 4
for import.rs and add an optional dictionary argument.
/// Play wordle, a word guessing game!
#[derive(Parser)]
struct Arguments {
language: Option<String>,
dictionary: Option<String>
}
fn main() -> {
let dictionary = TextDictionary::new(String::from("res/dictionary.txt"));
// ...
}
We also skipped the localization part here, that’s about to change as well.
Remove the marked line and start evaluating the passed arguments. Remember to
use TextDictionary as fallback.
fn main() -> {
let args = Arguments::parse();
let app_language = match args.language {
None => get_app_language(),
Some(flag) => parse_app_language(flag.as_str())
};
let dictionary: Box<dyn Dictionary> = match args.dictionary {
None => get_dictionary(app_language, String::from("text")),
Some(flag) => get_dictionary(app_language, flag)
};
// ...
}
Apply those changes to import.rs. Currently, the TextDictionary gets created
in the import function. Remove that line and add a parameter
dictionary: Box<dyn Dictionary> instead.
/// A maintenance tool for wordle
#[derive(Parser)]
struct Arguments {
source_file: String,
language: Option<String>,
dictionary: Option<String>
}
fn main() -> std::io::Result<()> {
// ...
let dictionary: Box<dyn Dictionary> = match args.dictionary {
None => get_dictionary(app_language, String::from("text")),
Some(flag) => get_dictionary(app_language, flag)
};
// ...
}
// ...
fn import(
tmp_file_name: String,
dictionary: Box<dyn Dictionary>
) -> Result<i32, Error> {
// ...
}
We are as good as finished. Now, try importing with
cargo run --bin import source_file.txt en db.

Rolling back
Before we look into the database, it’s time to fix some unwanted behavior.
Obviously the words counted are wrong. Because create_word in Dictionary
does not have a return value, the import function has no condition for
incrementing its counter. Mistakes happen, but this can easily be fixed. Rewrite
the create_word function to return either an Option<DictionaryEntry> or a
Result<Option<Dictionary>, Error>, I’ll leave it up to you. Just indicate what
happened.
Because we change how the trait behaves, the integration test should be changed
as well. So, open text_dictionary_test.rs and adapt the test_create_word
test. Run cargo test afterwards.
#[test]
fn test_create_word() {
let file_path = setup();
let dictionary = TextDictionary::new(file_path.clone());
match dictionary.create_word(DictionaryEntry{ word: String::from("rusty") }) {
None => assert!(false),
Some(_) => assert!(true)
}
match dictionary.create_word(DictionaryEntry{ word: String::from("testy") }) {
None => assert!(false),
Some(_) => assert!(true)
}
match dictionary.create_word(DictionaryEntry{ word: String::from("rusty") }) {
None => assert!(true),
Some(_) => assert!(false)
}
teardown(file_path);
}
After you’re finished, call diesel migration redo. In order to test, we need a
fresh start with an empty database. The loop for in the import function should
look like this.
for line_result in buf_reader.lines() {
let line = line_result.unwrap();
match dictionary.create_word(DictionaryEntry { word: line.to_lowercase() }) {
None => {},
Some(_) => {
counter += 1;
}
}
}
Let’s import again.

There should be 11 rows in the database’s dictionary table. Run
sqlite3 res/db/dictionary.db and execute select * from dictionary; after you
connected.

Seems like sqlite does not render the table header. The column names shown are:
id, word, used_at, guessed, language. The null representation is
just an empty column.
This is the initial status of the dictionary. When you start the game, one of
those entries will get selected randomly with a used_at value set. Run
cargo run en db and look into the sqlite DB right after.

When you lose on purpose now, there shouldn’t be any difference in the result of the select statement when starting the game another time.

Start it a second time and check the database. There’s a chance we know the solution this time.

Wrapping it up
Here part 5 of this Rust tutorial comes to an end. When choosing your tools, think wisely. What features does it provide? Do I need them all? Might there be better alternatives? That’s what we did here when choosing the DBMS. Sqlite is easy to set up, portable, and can be shipped within the application. Postgres on the other hand would have a dedicated boolean type, supports concurrency, replication, … and would be the better choice if we planned developing a scalable web application. A simple CLI application couldn’t be further from that. But at last we can enjoy the new features we implemented for the player.
However, there are still a few things missing. In the next and final part of this Rust tutorial we will complete the game logic, improve argument option information, display a greeting message on game startup and add pretty progress bars to the import tool.
You can find the code at this stage on my github page.
Building a CLI wordle game in Rust: Part 4