I’m so incredibly happy to write this. Today, I published my first version of
envfmt
, a tiny Rust crate that I’m genuinely proud of. It’s funny how some
projects start. You’re not aiming to build a new library; you’re just trying to
solve a tiny, annoying problem in a completely different project.
I’m locked in for jeth
, a personal tool that helps me to write invariant tests
for smart contracts. For jeth
to be useful, it needs a configuration file. And
in that config file, I needed a way for users (well, mostly me) to specify API
keys.
I absolutely did not want to hardcode secrets. The obvious solution is environment variables. I wanted to be able to write something like this in my config:
[etherscan]
api_key = "${ETHERSCAN_API_KEY}"
And have jeth
magically expands ${ETHERSCAN_API_KEY}
with the actual value
from my environment variables at runtime. Simple, right? I also wanted it to
support default values, like ${USER:-alice}
, just like my shell does.
I looked around for a crate to do this. I found some, but they either did way too much or had a bunch of dependencies. And a thought popped into my head: “How hard could this be to build myself?”
Famous last words, I know. But this felt like the perfect excuse to learn
something new. And that’s how envfmt
was born.
My first goal was clear: write a function that takes a string and expands
variables using std::env::var
. The problem hit me almost immediately. How on
earth do you write tests for something that depends on the global process
environment?
I could set environment variables in my tests, but that feels messy and fragile. Tests could interfere with each other. It just felt… wrong. I needed a way to mock the environment, to pretend that certain variables were set without actually setting them.
This is where I had a huge “aha!” moment with Rust traits.
I realized I didn’t need to depend on std::env
directly. I could depend on an
abstraction. I could define a contract, and as long as something fulfilled that
contract, my function would work.
So, I wrote this little trait called Context
:
pub trait Context {
fn get(&self, key: &str) -> Option<String>;
}
It’s so simple, but it felt like magic. It just says, “I am a Context
, and I
promise that if you give me a &str
key, I might give you a String
back.”
With this trait, my main formatting logic could be completely decoupled from the real environment. It wouldn’t know or care where the values came from.
This made testing a dream. I could implement the Context
trait for a simple
HashMap
:
impl<K, S> Context for HashMap<K, S>
where
K: Borrow<str> + Eq + Hash,
S: AsRef<str>,
{
fn get(&self, key: &str) -> Option<String> {
self.get(key).map(|s| s.as_ref().to_string())
}
}
(Side note: making this generic over K
and S
so it works with
HashMap<String, String>
and HashMap<&str, &str>
felt like a nice little
ergonomic win!)
And just like that, my tests became clean, predictable, and self-contained:
#[test]
fn var_simple() {
let mut ctx = HashMap::new();
ctx.insert("VAR1".to_string(), "value1".to_string());
assert_eq!(format_with("hello $VAR1", &ctx).unwrap(), "hello value1");
}
No more std::env::set_var
!
This was a huge learning moment for me. Traits aren’t just for polymorphism; they are one of the most powerful tools for writing clean, testable, and abstract code in Rust.
For the “real” use case, I just created a tiny, empty struct and implemented the trait for it:
struct Env;
impl Context for Env {
fn get(&self, key: &str) -> Option<String> {
env::var(key).ok()
}
}
My public API then became two simple functions. A generic one for flexibility and testing, and a simple one for the common case:
// The powerful, generic function
pub fn format_with<C: Context>(input: &str, context: &C) -> Result<String, Error> { ... }
// The simple, convenient wrapper for the 99% use case
pub fn format(input: &str) -> Result<String, Error> {
format_with(input, &Env)
}
I just love how elegant that is. Next, error handling.
Parsing strings means you’re going to run into errors. Unclosed braces, missing variables, invalid names… I wanted my library to give clear, helpful error messages.
This was a great opportunity to finally use thiserror
. I’ve seen it in other
crates, and it always looked so clean. It did not disappoint. Defining my error
type was incredibly straightforward:
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
pub enum Error {
#[error("variable not found: '{0}'")]
VariableNotFound(String),
#[error("invalid variable name: '{0}'")]
InvalidVariableName(String),
#[error("unexpected end of input: missing closing brace '}}'")]
UnclosedBrace,
}
Look at that! I just define the enum variants, and the #[error("...")]
macro
generates all the Display
trait boilerplate for me. It’s so good. Now, when a
user (me in jeth
) messes up, they get a message like
variable not found: 'UNDEFINED_VAR'
instead of some cryptic panic.
With the core logic and testing in place, I moved on to the final steps that make a project feel real.
I spent time writing comprehensive doc comments for every public item in
lib.rs
. I wanted anyone (including future me) to understand how to use it just
by reading the docs. I wrote a README.md
that explained the “why” and showed
simple, practical examples. I set up a basic GitHub Actions workflow to run
fmt --check
, clippy
, build
, and test
on every push. It’s like having a
little robot that checks my work for me. It’s awesome.
After all that, it was time. I ran cargo publish
, and just like that,
envfmt v0.1.0
was live on crates.io.
It started as a tiny feature for another project, but it became this wonderful
learning experience. I got to really appreciate the power of traits for
abstraction, the convenience of thiserror
, and the satisfaction of building a
small, focused, and well-tested library from scratch.
I’m really proud of this little crate. And now, I can finally go back to jeth
and use it!