I promise you I'm not being dramatic. I've been literally rewriting history - more specifically, commit history! This week, we were continuing on the work on our personal project. We did some code refactoring and learned a few tricks with git rebase
.
Refactor
I was extremely happy to hear that our task involved a ton of refactoring - As someone who's very particular about keeping things efficiently organized, I've come to enjoy refactoring code. I've done it myself a couple of times throughout the month of work on the mastermind project. And here are some of the things I did this week:
Extract Function
In the api::Instance
module, I used to have very similar code to the API key and the base URL. And with the recent inclusion of a config file, the logic of this part got more complicated. So I decided to rework on the pattern matching syntax, since now I am much more comfortable with it. After that I extracted it as a separate function:
fn read_from_env_or_config_file(
envvar: &str,
config_value: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
match env::var(envvar) {
Ok(key) => Ok(key),
Err(_) => {
if let Some(config_key) = config_value {
Ok(config_key.to_string())
} else {
Err(format!(
"Could not find environment variable '{envvar}' or any related configuration"
)
.into())
}
}
}
}
Restructure Modules
To be honest, my project was already modularized even before this week's work. However, I decided to take it one step further - In certain modules, I had a couple of related structs in one file. For example, in the clue
module, I used to have:
struct Clue {
clue_word: String,
count: usize,
linked_words: Vec<String>,
source: String,
}
pub struct ClueCollection {
clues: Vec<Clue>,
usage: Usage,
}
impl Clue {
// ...
}
impl ClueCollection {
// ...
}
To make the code even more modular and the structs more discoverable, in this example, I separated Clue
and ClueCollection
into two sub-modules and put them under the same parent-module.
I did the same thing for the config
module, which used to contain Config
and ConfigError
. Eventually, I ended up with a more organized file structure:
src
├── api
│ ├── chat_completions.rs
│ ├── models.rs
│ └── mod.rs
├── clues
│ ├── clue_collection.rs
│ ├── clue.rs
│ └── mod.rs
├── configs
│ ├── config_error.rs
│ ├── config.rs
│ └── mod.rs
├── json
│ ├── chat_completions.rs
│ ├── models.rs
│ └── mod.rs
├── lib.rs
├── main.rs
├── model_collection.rs
└── tests
├── expected_outputs
│ ├── chat_completions.txt
│ └── models.txt
├── mock_responses
│ ├── chat_completions.json
│ └── models.json
└── mod.rs
Organize use
Keywords
I used to rely on the IDE to automatically import the correct things for me. It does work, but the result was a quite messy spread of use
statements. So I gave them a makeover. For example:
// External/Built-in library
use std::env;
use std::error::Error;
use clap::Parser;
use dotenv::dotenv;
// Modules from my library crate
use mastermind::{
api, clues, configs, model_collection, read_words_from_file, write_content_to_file, Args,
};
// Components from my modules
use clues::clue_collection::ClueCollection;
use configs::config::Config;
use model_collection::ModelCollection;
Other Chore
clippy
is a fantastic Rust linter and also helps identify common mistakes. However, I only recently heard about this clippy::pedantic
option you can add to it. As soon as I turned it on, I saw warnings all over my projects. Some are quite unnecessary, while others really useful. I followed the useful tips and learned a lot about standard practices in Rust. For example:
-
let x = if let Some(y) = z { y } else { ... }
can simply be written aslet Some(y) = z else { ... }
. - Always prefer references for arguments than transferring ownership. If the function doesn't need ownership of an argument, take it as a reference.
- Calling
.to_string()
on aString
or.to_vec()
on aVec
to make a clone of them technically works, but it's better to clone them explicitly using.clone()
Rebase
I really wish I knew this earlier. There have been so many situations where I messed up on my commits and thought I had to remove my whole branch and start over. rebase
could've been the perfect remedy for that.
Ever since we started this course, the professor kept assuring us that our work never gets lost in git. Learning about rebase really make that idea click for me: It was fascinating to see commit history being rewritten, yet we could still find our way back to those squashed commits.
As for this week's work, it was very straightforward - I simply ran git rebase main -i
on my working branch, and squashed all the little commits into one. I also used git commit --amend
to refine the commit message before pushing it to GitHub.