After logging, the next kind of repetition that starts bothering me is usually this:
- adding context to errors
- converting typed values into JSON request bodies
Neither of those is dramatic on its own.
But if I leave them totally inline, they spread everywhere.
That is why src/utils/helpers.rs exists.
WithLocation
This is the first helper:
pub trait WithLocation<T> {
fn with_location(self) -> anyhow::Result<T>;
fn with_location_msg(self, msg: &str) -> anyhow::Result<T>;
}
impl<T, E> WithLocation<T> for std::result::Result<T, E>
where
E: Into<anyhow::Error>,
{
#[track_caller]
fn with_location(self) -> anyhow::Result<T> {
let loc = Location::caller();
self.map_err(Into::into)
.with_context(|| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
}
#[track_caller]
fn with_location_msg(self, msg: &str) -> anyhow::Result<T> {
let loc = Location::caller();
self.map_err(Into::into)
.with_context(|| format!("{} at {}:{}:{}", msg, loc.file(), loc.line(), loc.column()))
}
}
I like this because it captures something I personally care about when debugging:
not only what failed, but where I decided to wrap or reinterpret that failure.
That is often the more useful location for me.
Why I still keep it as a helper
Yes, I could just write .context(...) everywhere.
Sometimes I do.
But I know myself well enough: if the repetitive part feels slightly annoying, I will postpone it in one or two places, and then the code becomes uneven.
This helper lowers that friction just enough that I actually use it.
That is a recurring theme in this utils crate, honestly. I am not searching for elegance as much as I am trying to remove small excuses.
IntoJsonValue
The second helper is very small:
pub trait IntoJsonValue {
fn json_value(self) -> serde_json::Result<Value>
where
Self: Sized + Serialize,
{
serde_json::to_value(self)
}
}
impl<T> IntoJsonValue for T {}
This is almost embarrassingly tiny.
Still, I like it.
It lets me write:
Some(subscription_request.json_value()?)
instead of:
Some(serde_json::to_value(subscription_request)?)
That difference is not huge.
But I think a lot of utility code lives exactly in that zone: not essential, not magical, just a little nicer to use.
TrackError
The same file also has a very small custom error type:
#[derive(Debug)]
pub struct TrackError {
pub location: &'static std::panic::Location<'static>,
pub message: String,
}
impl TrackError {
#[track_caller]
pub fn new(message: impl Into<String>) -> Self {
Self {
location: std::panic::Location::caller(),
message: message.into(),
}
}
}
I keep it around because sometimes I want something between:
- a full error enum
- and a generic error chain
Sometimes I just want a normal error value with a message and a caller location.
This gives me that, without pretending to be more important than it is.
Moving to Part 3
Once logs and helper traits are in place, the next problem is usually state.
Small apps still need to save things. Tokens, config-like values, maybe a bit of local persistence.
That is where save_load.rs comes in.
Top comments (0)