January 26, 2025 in Development, Showcase9 minutes
This is the third post in a series documenting my progress on the Graph Notes project.
In my last article, I made some decisions I knew I’d come to regret. Well, it’s still a bit early in the project to take on major cleanup (what with the fact that we just don’t yet have that much code), but it’s nevertheless a great opportunity to lay out some best practices, clear organization, and perhaps give a bit of forethought into how we might want this project to trend.
First, I’ll lead with an assumption that you, as a reader, are a brand-new developer who just woke up one day and decided to write some Rust. In reality, I’d expect most readers to already have some general understanding of code quality tools, but for the sake of this discussion, I’ll treat you as ignorant. If you already know this stuff, feel free to skip around as you see fit.
You get this one for free simply by having installed Rust (assuming you did so via the normal routes; if you have some
special way of installing it, you’re on your own here). This tool does the same compile-time checking that the cargo build
command does, but without a lot of the overhead associated with actually compiling (which, let’s be honest, can sometimes
take a while with Rust codebases). It’s a great tool to spot-check yourself if you’re not ready for a full build but want
to know whether you have glaring issues in your code; but, it also performs some rudimentary linting, which is why we’re here
now.
With my code in its
current state,
running the cargo check
command in my project folder reveals some issues:
warning: unused variable: `frame`
--> src/app.rs:45:41
|
45 | fn update(&mut self, ctx: &Context, frame: &mut Frame) {
| ^^^^^ help: if this is intentional, prefix it with an underscore: `_frame`
|
= note: `#[warn(unused_variables)]` on by default
warning: variable does not need to be mutable
--> src/app.rs:102:25
|
102 | if let Some(mut selected_note) = self.notes.get_mut(self.selected_note) {
| ----^^^^^^^^^^^^^
| |
| help: remove this `mut`
|
= note: `#[warn(unused_mut)]` on by default
warning: `graph_note` (bin "graph_note") generated 2 warnings (run `cargo fix --bin "graph_note"` to apply 1 suggestion)
You might notice that adding the --fix
argument would have automatically addressed one issue. Myself, I prefer not to
use that flag, as going through and addressing these things by hand can function as a training tool. Periodically, as the
language itself evolves and the tooling with it, new standards and suggestions are introduced through Rust’s code quality
tools, and this can be a great way to keep in lockstep with the language’s growth.
I’ll apply the fixes as suggested, which gets me this commit.
If you don’t already have this tool installed (it, unlike the prior one, doesn’t come with Rust), you may need to
install it now. It builds on what cargo check
does by adding
even more compile-time checks; you could run it instead of cargo check
if you are the pedantic sort who always
chases down compiler warnings, but I prefer to do so infrequently and treat its output as technical debt. Whichever
route you prefer, though, this tool is an essential way by which the Rust team communicates “idiomatic Rust” to developers
in an ongoing fashion.
Because our project is still pretty green, and we’ve already addressed the cargo check
recommendations, we have nothing
of note from this tool right now. However, keep this one around; we’ll come back to it later, as it will help us to identify
antipatterns and best practices we may otherwise be unaware of.
Since I have no way of knowing what kind of IDE you’re using, I won’t get too specific here. Suffice to say, I would highly recommend having an IDE that gives you real-time feedback on code quality issues; Visual Studio Code with the rust-analyzer extension is a good route to take, but I personally prefer to use RustRover or Helix for my Rust coding needs. No matter which of those routes (or any other that has LSP support) you choose to take, you should see in real-time when you’ve botched something; consider this intentional goof, for instance:
Note that I don’t have to compile my code to know there’s a problem. To add to this, I could have my RustRover configured
to add automatic cargo check
runs for every time my files are saved; and, because my editor is auto-saving frequently,
this means I get rapid feedback on pressing code-quality issues.
This point isn’t exclusive to Rust - other strongly-typed languages afford you the chance to do what I’m about to showcase. That said, when taking into account how Rust handles its exhaustiveness checking or other forms of correctness, and when backed by a good revision control strategy*, I should be able to make arbitrary changes to my code and let the resulting errors dictate my next course of action.
Be committed to committing!
You should never get to a place in your code where you’re afraid to make a change. One of the ways to ensure that you can do this is to have a good strategy - commit often, commit atomic. I’ll surely have a lot more to say about this in the future, but it’s crucial to have frequent and meaningful checkpoints to capture and describe our work.
So, for the sake of discussion, let’s say I
regret my earlier decision
to store timestamps as raw SystemTime
values. I even made myself a note reminding myself to fix this one day. Well,
that day is today.
First, I like what SystemTime offers us, but think it’s an opinionated choice that doesn’t lend itself well to long-term interests. There are a number of different ways we could go, including ISO8601-formatted dates, but I’ll stick with the simpler UNIX epoch-based time since 1970-01-01. And, I doubt that we need more than second-based resolution for our timestamps, so I’ll make the decision to use a 64-based unsigned integer to store this - this can be changed someday if necessary, but let’s not entertain the idea of backdating our notes for the time being.
So, to sum up what I think I want: to have the storage of my timestamps be represented as a u64
, but to somehow keep
all of our current SystemTime
-based behavior. The data model doesn’t need to “know” how we’ll handle the values, and
the business logic shouldn’t need to “know” how we store our data. Having this clear separation of concerns sets us up
for future changes - as said before, we might want to change our mind about this u64
business down the road, and I want
to set us up for future success.
So, let’s begin by first modeling our ideal date structure:
pub struct Timestamp(pub u64);
impl std::fmt::Display for Timestamp {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let as_systemtime = OffsetDateTime::from_unix_timestamp(self.0 as i64)
.expect("should be valid datetime");
write!(f, "{}", as_systemtime.to_string())
}
}
impl Timestamp {
pub fn now() -> Self {
let secs_since_epoch = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("should produce valid time")
.as_secs();
Self(secs_since_epoch)
}
}
We have two immediate concerns regarding timestamps - storage, and display. The storage will be handled by wrapping our
seconds-since-epoch value in a named newtype called Timestamp
. The second we handle with the std::fmt::Display
trait,
which gives us the .to_string()
method and the {}
format string glyph when using things like the format!()
macro.
I’ve reproduced the original formatting logic into this code, giving us parity with our original behavior.
Additionally, I’ve handled the constructor (appropriately named “now” to indicate intent), which handles the conversion
from SystemTime
to a numeric value.
With these things in place, we no longer need for our app to know about or understand SystemTime
- let’s start by replacing
that type declaration in our Note
struct:
struct Note {
- created_on: SystemTime,
+ created_on: Timestamp,
title: String,
text: String,
}
As a consequence of this change, we should now start to see some issues in our code. For me, I have three red underlines jump out at me in RustRover:
Note
constructor, we instantiate the created_on
field with SystemTime::now()
ui.label()
call with a call to OffsetDateTime::from()
that now takes the wrong type as inputui.label()
callI can fix #1 by replacing one type name with another, since I construct with an identical function name:
impl Note {
pub fn new() -> Self {
Note {
- created_on: SystemTime::now(),
+ created_on: Timestamp::now(),
title: String::new(),
text: String::new(),
}
}
}
The ui.label()
calls on #2 and #3 can be changed as follows…
for (index, note) in self.notes.iter().enumerate() {
ui.label(if index == self.selected_note { "*" } else { "" });
- ui.label(OffsetDateTime::from(note.created_on).to_string());
+ ui.label(note.created_on.to_string());
ui.label(note.title.as_str());
- ui.label(format!("Created at {:?}", OffsetDateTime::from(selected_note.created_on)));
+ ui.label(format!("Created at {}", selected_note.created_on));
Putting it all together, our red squigglies go away, and the code runs and performs identically to how it did previously.
I’ll just say that not all refactors work this cleanly. I frequently refer to this practice of changing my code in vivo as “pulling on a thread” - the allusion being to when you snag a thread in a knotted ball of yarn (or as happens to me, a bunched up pile of extension cord) and you’re forced to unknot it in all the places it gets hung. In some cases, I’ve spent days on such refactors, because what inevitably happens is the “simple” change you make forces you to subsequently change several other things downstream of your code. I’m not ambitious enough to show that to you today, but I promise it’s extremely gratifying when you:
It sounds like a terrible workflow, but it’s one that has been incredibly successful for me in the past. And, you have the added comfort of knowing that if you bungle things up badly enough, you’ve left yourself an escape hatch in the form of your last good commit (you did commit as I suggested, right?).
See commit 6817fa2
for the results of our refactor, and the preceding 4d4f83a
for the things addressed in our cargo check
efforts.