It’s happening again. I’m building a tool for myself, and I get completely
sidetracked building another, smaller tool that it needs. And honestly? I
couldn’t be happier. Today, I’m publishing xdgdir v0.8.0
, my second little
Rust crate, and the story behind it feels so familiar.
Sidetracked
It all starts with jeth
, my grand project to build an invariant testing
framework for smart contracts. For jeth
to be fast, it needs to cache things.
Specifically, it needs to download and store different versions of the Solidity
(solc
) and Vyper (vyper
) compilers. But where do you put them?
I can’t just dump them in the project directory. I want a global cache, so if I
have ten different jeth
projects, they all share the same compiler binaries.
My first thought was to just hardcode ~/.cache/jeth
. It would work on my
machine, but it feels so brittle. What if someone (future me) is on a system
that organizes things differently?
There’s a right way to do this, a standard. It’s called the XDG Base Directory Specification, a fancy name for a simple set of rules that tell you where to put config, data, and cache files on Unix-like systems.
So, naturally, I went looking for a crate. And I found some great ones!
directories
is probably the most well-known. But for jeth
, which is a
command-line tool I’ll only ever run on my Mac or an Ubuntu server, it felt like
overkill. I didn’t need Windows support. Other crates I found had APIs that just
didn’t quite click with me. I wanted something tiny, with zero dependencies,
that did exactly one thing: tell me the XDG paths.
Building xdgdir
And that familiar thought crept in again: “How hard could it be to build myself?”
First, the name. This is always the hardest part. xdg
was taken, obviously. My
first idea was xdgbase
. It was fine, but a bit clunky. I asked Gemini for some
ideas, and it suggested xdgdir
. Perfect! Simple and to the point. I
immediately went with it, though now I’m having second thoughts. Should it have
been xdg-dir
with a hyphen? The Rust community prefers hyphens. But I really
like typing use xdgdir;
more than use xdg_dir;
. Oh well, xdgdir
it is!
Next up: understanding the spec. I opened it up, and it looked a bit intimidating at first. But after a few minutes, I realized it’s actually incredibly simple. It boils down to a pattern like this:
$XDG_CACHE_HOME
defines the base directory relative to which user-specific non-essential data files should be stored. If$XDG_CACHE_HOME
is either not set or empty, a default equal to$HOME/.cache
should be used.
It’s just a bunch of environment variables with fallback default paths.
$XDG_CONFIG_HOME
defaults to $HOME/.config
, $XDG_DATA_HOME
defaults to
$HOME/.local/share
, and so on. The crate just needs to read these variables,
handle the “not set or empty” cases, and join the paths. Easy enough.
But then I hit a problem I’d solved yesterday
with envfmt
: testing. How do you test code that calls std::env::var
? It’s a
global, mutable dependency. Running tests in parallel could cause them to step
on each other’s toes.
The solution is simple: don’t depend on the concrete implementation; depend on
an abstraction! Just like with envfmt
, I wrote a tiny Context
trait.
trait Context {
fn get(&self, key: &str) -> Option<String>;
}
This little trait is my secret weapon. It says, “I don’t care where the
variables come from, as long as you can give me a value for a key.” My main
logic could now be written against this trait, completely ignorant of
std::env
.
This made my tests beautiful and self-contained. I could just implement the
trait for a HashMap
and pass it in.
#[test]
fn xdg_cache_home_not_set() {
let mut context = HashMap::new();
context.insert("HOME", "/home/user");
let result = BaseDir::from_context(&context).unwrap();
assert_eq!(result.cache, PathBuf::from("/home/user/.cache"));
}
#[test]
fn xdg_cache_home_valid() {
let mut context = HashMap::new();
context.insert("HOME", "/home/user");
context.insert("XDG_CACHE_HOME", "/some/dir");
let result = BaseDir::from_context(&context).unwrap();
assert_eq!(result.cache, PathBuf::from("/some/dir"));
}
No global state, no filesystem access, no flakiness. Just pure, predictable logic. This pattern is so powerful. I feel like I’ve really leveled up as a Rust developer by internalizing it.
With the core logic in place, I focused on the API. I wanted it to be incredibly
simple. My initial design document had a function for_app(name: &str)
, but I
refined it into a struct, BaseDir
, with two constructors.
One for getting the global, raw base directories:
// Gets paths like /home/user/.config
let global_dirs = BaseDir::global()?;
And one for the common case of getting paths for a specific application:
// Gets paths like /home/user/.config/jeth
let jeth_dirs = BaseDir::new("jeth")?;
The implementation of new
is just a simple wrapper around global
that
appends the application name to the relevant paths. It feels so clean. The user
gets a struct with all the paths they could possibly need, and they are
responsible for all the I/O.
// In jeth, I can now do this:
let dirs = xdgdir::BaseDir::new("jeth")?;
let compiler_cache_path = dirs.cache.join("solc");
// ... now I can download compilers to this path
The library does one thing: it resolves paths. No side effects. No dependencies.
After a few hours of coding, it was pretty much done. I spent some time writing
good documentation, a clear README.md
, and setting up a GitHub Actions
workflow to run checks on every push. Then came the final, satisfying step:
choosing a version number. The XDG spec version is “0.8”. So, in a little nod to
the standard I was implementing, I decided to release my crate as v0.8.0
.
And just like that, cargo publish
. It’s live.
This little detour from jeth
turned into another incredibly rewarding
experience. It solidified my understanding of how to write testable code in
Rust, and it resulted in a tiny, focused utility that I’m genuinely proud of. It
does exactly what I need it to, and nothing more.
And now, I can finally go back to jeth
, cargo add xdgdir
, and get on with
building my testing framework.