diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d4419d..d839d30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/(?` into `exn::Result<_>` +//! +//! This example shows a common pattern: +//! - Legacy code returns `anyhow::Result`. +//! - At the boundary, convert into `exn::Result`. + +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 { + let raw = "not-a-number"; + + let port = raw + .parse::() + .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 diff --git a/examples/src/into-anyhow.rs b/examples/src/into-anyhow.rs index ff9e253..4ee9693 100644 --- a/examples/src/into-anyhow.rs +++ b/examples/src/into-anyhow.rs @@ -16,23 +16,20 @@ //! //! This example shows a common pattern: //! - Using `exn::Result` internally. -//! - At the boundary, convert `Exn` 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(err: exn::Exn) -> anyhow::Error { - anyhow::Error::from_boxed(err.into()) -} - mod app { use super::*; diff --git a/exn-anyhow/Cargo.toml b/exn-anyhow/Cargo.toml new file mode 100644 index 0000000..48ed5e3 --- /dev/null +++ b/exn-anyhow/Cargo.toml @@ -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 diff --git a/exn-anyhow/README.md b/exn-anyhow/README.md new file mode 100644 index 0000000..0bc5e38 --- /dev/null +++ b/exn-anyhow/README.md @@ -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` -> `anyhow::Error` +- `exn_anyhow::from_anyhow`: `anyhow::Error` -> `exn::Exn` + +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). diff --git a/exn-anyhow/src/lib.rs b/exn-anyhow/src/lib.rs new file mode 100644 index 0000000..139c4a8 --- /dev/null +++ b/exn-anyhow/src/lib.rs @@ -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(err: Exn) -> 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 { + let mut chain = err.chain().map(ToString::to_string).collect::>(); + 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 +} diff --git a/exn/Cargo.toml b/exn/Cargo.toml index 05ad073..b67da34 100644 --- a/exn/Cargo.toml +++ b/exn/Cargo.toml @@ -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 diff --git a/exn/src/impls.rs b/exn/src/impls.rs index be0690d..6e5c029 100644 --- a/exn/src/impls.rs +++ b/exn/src/impls.rs @@ -181,18 +181,18 @@ impl Error for Frame { impl From> for Box { fn from(exn: Exn) -> Self { - Box::new(exn.frame) + exn.frame } } impl From> for Box { fn from(exn: Exn) -> Self { - Box::new(exn.frame) + exn.frame } } impl From> for Box { fn from(exn: Exn) -> Self { - Box::new(exn.frame) + exn.frame } } diff --git a/exn/tests/main.rs b/exn/tests/main.rs index 321e7ef..bb90424 100644 --- a/exn/tests/main.rs +++ b/exn/tests/main.rs @@ -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::::from(err); + assert!(err.downcast_ref::().is_some()); +}