Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
if: ${{ startsWith(matrix.os, 'windows') }}
run: |
perl -i -pe 's/\//\\/g if /at exn/' exn/tests/snapshots/*.snap
perl -i -pe 's/(?<!\/)\/(?!\/)/\\/g if /examples\/src/' examples/src/*.rs
perl -i -pe 's/(?<!\/)\/(?!\/)/\\/g if /, at/' examples/src/*.rs
shell: bash
- name: Run unit tests
run: cargo x test --no-capture
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

[workspace]
members = ["examples", "exn", "xtask"]
members = ["examples", "exn", "exn-anyhow", "xtask"]
resolver = "3"

[workspace.package]
Expand All @@ -27,6 +27,7 @@ rust-version = "1.85.0"
[workspace.dependencies]
# Workspace dependencies
exn = { path = "exn" }
exn-anyhow = { path = "exn-anyhow" }

# Crates.io dependencies
anyhow = { version = "1.0.100" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ The policy is that the minimum Rust version required to use this crate can be in

## License

This project is licensed under [Apache License, Version 2.0](LICENSE).
This project is licensed under [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
9 changes: 7 additions & 2 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ name = "downcast"
path = "src/downcast.rs"

[[example]]
name = "into-error"
path = "src/into-error.rs"
name = "from-anyhow"
path = "src/from-anyhow.rs"

[[example]]
name = "into-anyhow"
path = "src/into-anyhow.rs"

[[example]]
name = "into-error"
path = "src/into-error.rs"

[[example]]
name = "into-std-error"
path = "src/into-std-error.rs"
Expand All @@ -57,6 +61,7 @@ release = false
anyhow = { workspace = true }
derive_more = { workspace = true }
exn = { workspace = true }
exn-anyhow = { workspace = true }

[lints]
workspace = true
74 changes: 74 additions & 0 deletions examples/src/from-anyhow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2025 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! # Anyhow Interoperate Example - Converting `anyhow::Result<_>` into `exn::Result<_>`
//!
//! This example shows a common pattern:
//! - Legacy code returns `anyhow::Result<T>`.
//! - At the boundary, convert into `exn::Result<T, exn_anyhow::AnyhowError>`.

use derive_more::Display;
use exn::Result;
use exn::ResultExt;
use exn_anyhow::from_anyhow;

fn main() -> Result<(), MainError> {
app::run().or_raise(|| MainError)?;
Ok(())
}

#[derive(Debug, Display)]
#[display("fatal error occurred in application")]
struct MainError;
impl std::error::Error for MainError {}

mod app {
use super::*;

pub fn run() -> Result<(), AppError> {
legacy::load_port()
.map_err(from_anyhow)
.or_raise(|| AppError)?;
Ok(())
}

#[derive(Debug, Display)]
#[display("failed to run app")]
pub struct AppError;
impl std::error::Error for AppError {}
}

mod legacy {
use anyhow::Context;

pub fn load_port() -> anyhow::Result<u16> {
let raw = "not-a-number";

let port = raw
.parse::<u16>()
.with_context(|| format!("PORT must be a number; got {raw:?}"))?;

Ok(port)
}
}

// Output when running `cargo run -p examples --example from-anyhow`:
//
// Error: fatal error occurred in application, at examples/src/from-anyhow.rs:27:16
// |
// |-> failed to run app, at examples/src/from-anyhow.rs:42:14
// |
// |-> PORT must be a number; got "not-a-number", at exn-anyhow/src/lib.rs:51:19
// |
// |-> invalid digit found in string, at exn-anyhow/src/lib.rs:48:19
9 changes: 3 additions & 6 deletions examples/src/into-anyhow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,20 @@
//!
//! This example shows a common pattern:
//! - Using `exn::Result<T, E>` internally.
//! - At the boundary, convert `Exn<E>` into `anyhow::Error`.
//! - At the boundary, convert into `anyhow::Error`.

use std::error::Error;

use derive_more::Display;
use exn::Result;
use exn::ResultExt;
use exn_anyhow::into_anyhow;

fn main() -> anyhow::Result<()> {
app::run().map_err(convert_error)?;
app::run().map_err(into_anyhow)?;
Ok(())
}

fn convert_error<E: Error + Send + Sync + 'static>(err: exn::Exn<E>) -> anyhow::Error {
anyhow::Error::from_boxed(err.into())
}

mod app {
use super::*;

Expand Down
33 changes: 33 additions & 0 deletions exn-anyhow/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2025 FastLabs Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

[package]
name = "exn-anyhow"
version = "0.1.0"

description = "Interop helpers between exn and anyhow."

edition.workspace = true
homepage.workspace = true
license.workspace = true
readme = "README.md"
repository.workspace = true
rust-version.workspace = true

[dependencies]
anyhow = { workspace = true }
exn = { workspace = true }

[lints]
workspace = true
58 changes: 58 additions & 0 deletions exn-anyhow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Interop helpers between `exn` and `anyhow`

[![Crates.io][crates-badge]][crates-url]
[![Documentation][docs-badge]][docs-url]
[![MSRV 1.85][msrv-badge]](https://www.whatrustisit.com)
[![Apache 2.0 licensed][license-badge]][license-url]
[![Build Status][actions-badge]][actions-url]

[crates-badge]: https://img.shields.io/crates/v/exn-anyhow.svg
[crates-url]: https://crates.io/crates/exn-anyhow
[docs-badge]: https://docs.rs/exn-anyhow/badge.svg
[docs-url]: https://docs.rs/exn-anyhow
[msrv-badge]: https://img.shields.io/badge/MSRV-1.85-green?logo=rust
[license-badge]: https://img.shields.io/crates/l/exn-anyhow
[license-url]: https://github.com/fast/exn/blob/main/LICENSE
[actions-badge]: https://github.com/fast/exn/workflows/CI/badge.svg
[actions-url]:https://github.com/fast/exn/actions?query=workflow%3ACI

## Overview

`exn-anyhow` provides explicit boundary conversion helpers:

- `exn_anyhow::into_anyhow`: `exn::Exn<E>` -> `anyhow::Error`
- `exn_anyhow::from_anyhow`: `anyhow::Error` -> `exn::Exn<exn_anyhow::AnyhowError>`

Recommended usage is explicit error conversion at API boundaries via `map_err`:

```rust
use exn_anyhow::from_anyhow;
use exn_anyhow::into_anyhow;

let exn_result = anyhow_result.map_err(from_anyhow);
let anyhow_result = exn_result.map_err(into_anyhow);
```

## Documentation

Read the online documents at https://docs.rs/exn-anyhow.

## Examples

```bash
cargo run -p examples --example from-anyhow
cargo run -p examples --example into-anyhow
```

## Minimum Rust version policy

This crate is built against the latest stable release, and its minimum supported rustc version is 1.85.0.

The policy is that the minimum Rust version required to use this crate can be increased in minor
version updates. For example, if version 1.0 requires Rust 1.60.0, then version 1.0.z for all
values of z will also require Rust 1.60.0 or newer. However, version 1.y for y > 0 may require a
newer minimum version of Rust.

## License

This project is licensed under [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
55 changes: 55 additions & 0 deletions exn-anyhow/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2025 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Interop helpers between [`exn`] and [`anyhow`].

use std::error::Error;
use std::fmt;

use exn::Exn;

/// Convert an [`Exn`] into [`anyhow::Error`].
pub fn into_anyhow<E>(err: Exn<E>) -> anyhow::Error
where
E: Error + Send + Sync + 'static,
{
anyhow::Error::from_boxed(err.into())
}

/// A plain message error frame used while converting from [`anyhow::Error`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnyhowError(String);

impl fmt::Display for AnyhowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

impl Error for AnyhowError {}

/// Convert an [`anyhow::Error`] into [`Exn`], preserving `anyhow`'s cause chain.
pub fn from_anyhow(err: anyhow::Error) -> Exn<AnyhowError> {
let mut chain = err.chain().map(ToString::to_string).collect::<Vec<_>>();
let leaf = chain
.pop()
.expect("anyhow::Error must have at least one error in the chain");
let mut exn = Exn::new(AnyhowError(leaf));

while let Some(message) = chain.pop() {
exn = exn.raise(AnyhowError(message));
}

exn
}
1 change: 1 addition & 0 deletions exn/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ homepage.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true

[package.metadata.docs.rs]
all-features = true
Expand Down
6 changes: 3 additions & 3 deletions exn/src/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,18 +181,18 @@ impl Error for Frame {

impl<E: Error + Send + Sync + 'static> From<Exn<E>> for Box<dyn Error + 'static> {
fn from(exn: Exn<E>) -> Self {
Box::new(exn.frame)
exn.frame
}
}

impl<E: Error + Send + Sync + 'static> From<Exn<E>> for Box<dyn Error + Send + 'static> {
fn from(exn: Exn<E>) -> Self {
Box::new(exn.frame)
exn.frame
}
}

impl<E: Error + Send + Sync + 'static> From<Exn<E>> for Box<dyn Error + Send + Sync + 'static> {
fn from(exn: Exn<E>) -> Self {
Box::new(exn.frame)
exn.frame
}
}
7 changes: 7 additions & 0 deletions exn/tests/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,10 @@ fn ensure_fail() {
let result = foo();
insta::assert_debug_snapshot!(result.unwrap_err());
}

#[test]
fn std_error_roundtrip() {
let err = Exn::new(Error("An error"));
let err = Box::<dyn std::error::Error>::from(err);
assert!(err.downcast_ref::<exn::Frame>().is_some());
}