envfmt v0.1.0

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:

TOML
[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:

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

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

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

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

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

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

Check it out on GitHub or crates.io .

Topics