xdgdir v0.8.0

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.

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

Rust
#[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:

Rust
// Gets paths like /home/user/.config
let global_dirs = BaseDir::global()?;

And one for the common case of getting paths for a specific application:

Rust
// 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.

Rust
// 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.

Check it out on GitHub, crates.io or docs.rs.

Topics