diff --git a/Cargo.lock b/Cargo.lock index 452d95f2424..469362e82ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,9 +1945,12 @@ dependencies = [ "expect-test", "eyre", "futures", + "futures-core", + "futures-util", "insta", "once_cell", "owo-colors", + "pin-project-lite", "regex", "rustc_version", "serde", @@ -1974,18 +1977,6 @@ dependencies = [ "tracing-error", ] -[[package]] -name = "error-stack-experimental" -version = "0.0.0-reserved" -dependencies = [ - "error-stack 0.5.0", - "futures-core", - "futures-util", - "pin-project-lite", - "rustc_version", - "tokio", -] - [[package]] name = "error-stack-macros" version = "0.0.0-reserved" diff --git a/Cargo.toml b/Cargo.toml index 10bbd0a20bf..236f28ac714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ members = [ "libs/deer/macros", "libs/error-stack", "libs/error-stack/macros", - "libs/error-stack/experimental", "libs/sarif", ] default-members = [ diff --git a/libs/error-stack/CHANGELOG.md b/libs/error-stack/CHANGELOG.md index c4c0ee0dce3..f7fd6941477 100644 --- a/libs/error-stack/CHANGELOG.md +++ b/libs/error-stack/CHANGELOG.md @@ -11,11 +11,13 @@ All notable changes to `error-stack` will be documented in this file. ### Features - Report has been split into `Report` and `Report<[C]>` to distinguish between a group of related errors and a single error. These errors can still be nested. +- Introduce a new `unstable` flag, which is used to enable unstable features, these features are not covered by semver and may be modified or removed at any time. ### Breaking Changes - `Extend` is no longer implemented by `Report`, instead it is implemented on `Report<[C]>`, either use `From` or `Report::expand` to convert between `Report` into `Report<[C]>`. - `extend_one` has been renamed to `push` and is only implemented on `Report<[C]>`. +- `bail!(report,)` has been removed, one must now use `bail!(report)`. This is in preparation for the unstable `bail!` macro that allows to construct `Report<[C]>`. ## [0.5.0](https://github.com/hashintel/hash/tree/error-stack%400.5.0/libs/error-stack) - 2024-07-12 diff --git a/libs/error-stack/Cargo.toml b/libs/error-stack/Cargo.toml index 198f26a7cbe..ac973685355 100644 --- a/libs/error-stack/Cargo.toml +++ b/libs/error-stack/Cargo.toml @@ -20,8 +20,10 @@ exclude = ["package.json", "macros", "experimental"] anyhow = { version = ">=1.0.73", public = true, default-features = false, optional = true } eyre = { version = ">=0.6", public = true, default-features = false, optional = true } serde = { version = ">=1", default-features = false, public = true, optional = true } +futures-core = { workspace = true, public = true, optional = true } # Private workspace dependencies +pin-project-lite = { workspace = true, optional = true } # Private third-party dependencies spin = { version = ">=0.9", default-features = false, optional = true, features = ['rwlock', 'once'] } @@ -42,6 +44,7 @@ supports-color = { workspace = true } supports-unicode = { workspace = true } owo-colors = { workspace = true } thiserror = { workspace = true } +futures-util.workspace = true [build-dependencies] rustc_version = { workspace = true } @@ -59,6 +62,10 @@ hooks = ['dep:spin'] # Enables hooks on `no-std` platforms using spin locks anyhow = ["dep:anyhow"] # Provides `into_report` to convert `anyhow::Error` to `Report` eyre = ["dep:eyre", "std"] # Provides `into_report` to convert `eyre::Report` to `Report` +futures = ["dep:futures-core", "dep:pin-project-lite"] # Provides support for `futures` types, such as stream. + +unstable = [] # Enables unstable features that are not covered under any stability guarantees + [lints] workspace = true diff --git a/libs/error-stack/experimental/Cargo.toml b/libs/error-stack/experimental/Cargo.toml deleted file mode 100644 index 8528cd4d097..00000000000 --- a/libs/error-stack/experimental/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "error-stack-experimental" -version = "0.0.0-reserved" -authors = { workspace = true } -edition = "2021" -rust-version = "1.63.0" -license = "MIT OR Apache-2.0" -description = "Experimental features for error-stack" -documentation = "https://docs.rs/error-stack-experimental" -readme = "README.md" -repository = "https://github.com/hashintel/hash/tree/main/libs/error-stack" -keywords = ["errorstack", "error-handling", "experimental"] -categories = ["rust-patterns", "no-std"] -publish = false - -[dependencies] -# Public workspace dependencies -error-stack = { path = "..", public = true } -futures-core = { workspace = true, public = true, optional = true } -pin-project-lite = { workspace = true, optional = true } - -[build-dependencies] -rustc_version = "0.4.1" - -[lints] -workspace = true - -[features] -stream = ["dep:futures-core", "dep:pin-project-lite"] - -[dev-dependencies] -futures-util.workspace = true -tokio = { workspace = true, features = ["rt-multi-thread"] } - -[package.metadata.docs.rs] -all-features = true -cargo-args = ["-Z", "unstable-options", "-Z", "rustdoc-scrape-examples"] -targets = ["x86_64-unknown-linux-gnu"] diff --git a/libs/error-stack/experimental/LICENSE.md b/libs/error-stack/experimental/LICENSE.md deleted file mode 100644 index 8b31346dd55..00000000000 --- a/libs/error-stack/experimental/LICENSE.md +++ /dev/null @@ -1,5 +0,0 @@ -# License - -Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option. - -For more information about contributing to this crate, see our top-level [CONTRIBUTING](https://github.com/hashintel/hash/blob/main/.github/CONTRIBUTING.md) policy. diff --git a/libs/error-stack/experimental/README.md b/libs/error-stack/experimental/README.md deleted file mode 100644 index 95776c84cdb..00000000000 --- a/libs/error-stack/experimental/README.md +++ /dev/null @@ -1,19 +0,0 @@ -[crates.io]: https://crates.io/crates/error-stack-experimental -[libs.rs]: https://lib.rs/crates/error-stack-experimental -[rust-version]: https://www.rust-lang.org -[documentation]: https://docs.rs/error-stack-macros -[license]: https://github.com/hashintel/hash/blob/main/libs/error-stack/LICENSE.md - -[![crates.io](https://img.shields.io/crates/v/error-stack-experimental)][crates.io] -[![libs.rs](https://img.shields.io/badge/libs.rs-error--stack--experimental-orange)][libs.rs] -[![rust-version](https://img.shields.io/static/v1?label=Rust&message=1.63.0/nightly-2024-09-16&color=blue)][rust-version] -[![documentation](https://img.shields.io/docsrs/error-stack-experimental)][documentation] -[![license](https://img.shields.io/crates/l/error-stack)][license] - -[Open issues](https://github.com/hashintel/hash/issues?q=is%3Aissue+is%3Aopen+label%3AA-error-stack) / [Discussions](https://github.com/hashintel/hash/discussions?discussions_q=label%3AA-error-stack) - -# error-stack-experimental - -`error-stack-experimental` serves as a testing ground for novel features and concepts that are not yet ready for inclusion in the main `error-stack` crate. This separate crate allows us to explore and refine new ideas without impacting the stability of the core library. - -While `error-stack-experimental` is designed for experimentation, it adheres to semantic versioning principles to maintain a degree of reliability for users. However, it's important to note that features introduced in this crate may be subject to removal or integration into the main crate in future updates. As such, users should approach the experimental features with caution and not rely on them as permanent components of the error handling ecosystem. To ease the transition of features from `error-stack-experimental` to `error-stack`, we aim to maintain compatibility between the two crates as much as possible, using techniques such as re-exports in case of feature promotion to allow for a deprecation window. diff --git a/libs/error-stack/experimental/build.rs b/libs/error-stack/experimental/build.rs deleted file mode 100644 index 16fd215e00b..00000000000 --- a/libs/error-stack/experimental/build.rs +++ /dev/null @@ -1,10 +0,0 @@ -use rustc_version::{version_meta, Channel}; - -fn main() { - let version_meta = version_meta().expect("Could not get Rust version"); - - println!("cargo:rustc-check-cfg=cfg(nightly)"); - if version_meta.channel == Channel::Nightly { - println!("cargo:rustc-cfg=nightly"); - } -} diff --git a/libs/error-stack/experimental/package.json b/libs/error-stack/experimental/package.json deleted file mode 100644 index 1c66ef7213f..00000000000 --- a/libs/error-stack/experimental/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@rust/error-stack-experimental", - "version": "0.0.0-reserved-private", - "private": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@rust/error-stack": "0.5.0" - } -} diff --git a/libs/error-stack/experimental/src/lib.rs b/libs/error-stack/experimental/src/lib.rs deleted file mode 100644 index 5c2d965b2ed..00000000000 --- a/libs/error-stack/experimental/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![doc = include_str!("../README.md")] -#![cfg_attr(all(doc, nightly), feature(doc_auto_cfg))] - -#[cfg(feature = "stream")] -pub use self::stream::TryReportStreamExt; -pub use self::{iter::TryReportIteratorExt, result::ResultMultiExt, tuple::TryReportTupleExt}; - -mod iter; -mod result; -#[cfg(feature = "stream")] -mod stream; -mod tuple; diff --git a/libs/error-stack/experimental/src/result.rs b/libs/error-stack/experimental/src/result.rs deleted file mode 100644 index 90205ba2c64..00000000000 --- a/libs/error-stack/experimental/src/result.rs +++ /dev/null @@ -1,102 +0,0 @@ -use error_stack::{Report, Result}; - -/// Extension trait for accumulating errors in a `Result`. -pub trait ResultMultiExt { - /// The type of the successful value in the `Result`. - type Output; - - /// Accumulates an error into the `Result`. - /// - /// If the `Result` is `Ok`, it replaces it with an `Err` containing the new error. - /// If it's already `Err`, it appends the new error to the existing list. - /// - /// # Examples - /// - /// ``` - /// use error_stack::{Report, Result}; - /// use error_stack_experimental::ResultMultiExt; - /// - /// #[derive(Debug)] - /// struct CustomError; - /// - /// impl std::fmt::Display for CustomError { - /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - /// write!(f, "Custom error") - /// } - /// } - /// - /// impl std::error::Error for CustomError {} - /// - /// let mut result: Result<(), [CustomError]> = Ok(()); - /// result.accumulate(Report::new(CustomError)); - /// assert!(result.is_err()); - /// - /// result.accumulate(Report::new(CustomError)); - /// assert_eq!(result.unwrap_err().current_contexts().count(), 2); - /// ``` - fn accumulate(&mut self, report: R) - where - R: Into>; -} - -impl ResultMultiExt for Result { - type Output = T; - - fn accumulate(&mut self, report: R) - where - R: Into>, - { - match self { - Ok(_) => { - *self = Err(report.into()); - } - Err(reports) => { - reports.append(report.into()); - } - } - } -} -#[cfg(test)] -mod tests { - use error_stack::{Report, Result}; - - use crate::ResultMultiExt; - - #[derive(Debug)] - struct TestError; - - impl core::fmt::Display for TestError { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(fmt, "Test error") - } - } - - impl core::error::Error for TestError {} - - #[test] - fn accumulate_on_ok() { - let mut result: Result<(), [TestError]> = Ok(()); - result.accumulate(Report::new(TestError)); - assert!(result.is_err()); - } - - #[test] - fn accumulate_multiple_errors() { - let mut result: Result<(), [TestError]> = Ok(()); - result.accumulate(Report::new(TestError)); - result.accumulate(Report::new(TestError)); - result.accumulate(Report::new(TestError)); - - let report = result.expect_err("should have failed"); - assert_eq!(report.current_contexts().count(), 3); - } - - #[test] - fn accumulate_on_err() { - let mut result: Result<(), [TestError]> = Err(Report::new(TestError).expand()); - result.accumulate(Report::new(TestError)); - - let report = result.expect_err("should have failed"); - assert_eq!(report.current_contexts().count(), 2); - } -} diff --git a/libs/error-stack/experimental/src/iter.rs b/libs/error-stack/src/ext/iter.rs similarity index 87% rename from libs/error-stack/experimental/src/iter.rs rename to libs/error-stack/src/ext/iter.rs index ac41e38dd2b..a75bda9d736 100644 --- a/libs/error-stack/experimental/src/iter.rs +++ b/libs/error-stack/src/ext/iter.rs @@ -1,4 +1,4 @@ -use error_stack::{Context, Report, Result}; +use crate::{Context, Report, Result}; // inspired by the implementation in `std`, see: https://doc.rust-lang.org/1.81.0/src/core/iter/adapters/mod.rs.html#157 // except with the removal of the Try trait, as it is unstable. @@ -81,10 +81,22 @@ where report.map_or_else(|| Ok(value), |report| Err(report)) } -/// An extension trait for iterators that allows collecting items while handling errors. +/// An extension trait for iterators that enables error-aware collection of items. /// -/// This trait provides additional functionality to iterators that yield `Result` items, -/// allowing them to be collected into a container while propagating any errors encountered. +/// This trait enhances iterators yielding `Result` items by providing methods to +/// collect successful items into a container while aggregating encountered errors. +/// +/// # Performance Considerations +/// +/// These methods may have performance implications as they potentially iterate +/// through the entire collection, even after encountering errors. +/// +/// # Unstable Feature +/// +/// This trait is currently available only under the `unstable` feature flag and +/// does not adhere to semver guarantees. Its API may change in future releases. +/// +/// [`Report`]: crate::Report pub trait TryReportIteratorExt { /// The type of the successful items in the iterator. type Ok; @@ -104,9 +116,8 @@ pub trait TryReportIteratorExt { /// # Examples /// /// ``` - /// use error_stack::{Result, ResultExt, Report}; + /// use error_stack::{Result, Report, TryReportIteratorExt}; /// use std::io; - /// use error_stack_experimental::TryReportIteratorExt; /// /// fn fetch_fail() -> Result { /// # stringify! { @@ -140,9 +151,8 @@ pub trait TryReportIteratorExt { /// # Examples /// /// ``` - /// use error_stack::{Result, ResultExt, Report}; + /// use error_stack::{Result, Report, TryReportIteratorExt}; /// use std::io; - /// use error_stack_experimental::TryReportIteratorExt; /// /// fn fetch_fail() -> Result { /// # stringify! { @@ -187,8 +197,8 @@ where #[cfg(test)] mod tests { #![allow(clippy::integer_division_remainder_used)] + use alloc::{collections::BTreeSet, vec::Vec}; use core::fmt; - use std::collections::HashSet; use super::*; @@ -216,7 +226,7 @@ mod tests { let result: Result, [CustomError]> = iter.try_collect_reports(); let report = result.expect_err("should have failed"); - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 2); assert!(contexts.contains(&CustomError(1))); assert!(contexts.contains(&CustomError(3))); @@ -235,7 +245,7 @@ mod tests { let result: Result, [CustomError]> = iter.try_collect_reports_bounded(3); let report = result.expect_err("should have failed"); - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 3); assert!(contexts.contains(&CustomError(1))); assert!(contexts.contains(&CustomError(3))); @@ -265,7 +275,7 @@ mod tests { let result: Result, [CustomError]> = iter.try_collect_reports(); let report = result.expect_err("should have failed"); - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 2); assert!(contexts.contains(&CustomError(1))); assert!(contexts.contains(&CustomError(3))); diff --git a/libs/error-stack/src/ext/mod.rs b/libs/error-stack/src/ext/mod.rs new file mode 100644 index 00000000000..78b602f9410 --- /dev/null +++ b/libs/error-stack/src/ext/mod.rs @@ -0,0 +1,19 @@ +//! Extension traits for `Report` and `Result`. +//! +//! These traits are currently unstable and require the `unstable` feature flag to be enabled. +//! They provide additional functionality and convenience methods for error handling and +//! manipulation. +//! +//! # Note +//! +//! The traits and methods in this module are subject to change and may be modified or +//! removed in future versions. Use them with caution in production environments. + +pub(crate) mod iter; +#[cfg(feature = "futures")] +pub(crate) mod stream; +pub(crate) mod tuple; + +#[cfg(feature = "futures")] +pub use self::stream::{TryCollectReports, TryReportStreamExt}; +pub use self::{iter::TryReportIteratorExt, tuple::TryReportTupleExt}; diff --git a/libs/error-stack/experimental/src/stream.rs b/libs/error-stack/src/ext/stream.rs similarity index 80% rename from libs/error-stack/experimental/src/stream.rs rename to libs/error-stack/src/ext/stream.rs index dd8f64cbe3c..79f2957d45e 100644 --- a/libs/error-stack/experimental/src/stream.rs +++ b/libs/error-stack/src/ext/stream.rs @@ -5,10 +5,11 @@ use core::{ task::{ready, Context, Poll}, }; -use error_stack::{Report, Result}; use futures_core::{FusedFuture, FusedStream, TryStream}; use pin_project_lite::pin_project; +use crate::{Report, Result}; + pin_project! { /// Future for the [`try_collect_reports`](TryReportStreamExt::try_collect_reports) /// and [`try_collect_reports_bounded`](TryReportStreamExt::try_collect_reports_bounded) methods. @@ -91,8 +92,28 @@ where } } -/// Trait extending `TryStream` with methods for collecting error-stack results in a fail-slow +/// Trait extending [`TryStream`] with methods for collecting error-stack results in a fail-slow /// manner. +/// +/// This trait provides additional functionality to [`TryStream`]s, allowing for the collection of +/// successful items while accumulating errors. It's particularly useful when you want to continue +/// processing a stream even after encountering errors, gathering all successful results and errors +/// until the stream is exhausted or a specified error limit is reached. +/// +/// The fail-slow approach means that the stream processing continues after encountering errors, +/// unlike traditional error handling that might stop at the first error. +/// +/// # Performance Considerations +/// +/// These methods may have performance implications as they potentially iterate +/// through the entire stream, even after encountering errors. +/// +/// # Note +/// +/// This trait is only available behind the `unstable` flag and is not covered by semver guarantees. +/// It may be subject to breaking changes in future releases. +/// +/// [`TryStream`]: futures_core::stream::TryStream pub trait TryReportStreamExt: TryStream>> { /// Collects all successful items from the stream into a collection, accumulating all errors. /// @@ -102,9 +123,8 @@ pub trait TryReportStreamExt: TryStream>> { /// # Examples /// /// ``` - /// # use error_stack::{Report, Result}; + /// # use error_stack::{Report, Result, TryReportStreamExt}; /// # use futures_util::stream; - /// # use error_stack_experimental::TryReportStreamExt; /// /// #[derive(Debug, Clone, PartialEq, Eq)] /// pub struct UnknownError; @@ -132,7 +152,7 @@ pub trait TryReportStreamExt: TryStream>> { /// assert_eq!(report.current_contexts().count(), 2); /// # } /// # - /// # tokio::runtime::Runtime::new().unwrap().block_on(example()); + /// # futures::executor::block_on(example()); /// ``` fn try_collect_reports(self) -> TryCollectReports where @@ -149,9 +169,8 @@ pub trait TryReportStreamExt: TryStream>> { /// once the number of accumulated errors reaches the specified `bound`. /// /// ``` - /// # use error_stack::{Report, Result}; + /// # use error_stack::{Report, Result, TryReportStreamExt}; /// # use futures_util::stream; - /// # use error_stack_experimental::TryReportStreamExt; /// /// #[derive(Debug, Clone, PartialEq, Eq)] /// pub struct UnknownError; @@ -179,7 +198,7 @@ pub trait TryReportStreamExt: TryStream>> { /// assert_eq!(report.current_contexts().count(), 1); /// # } /// # - /// # tokio::runtime::Runtime::new().unwrap().block_on(example()); + /// # futures::executor::block_on(example()); /// ``` fn try_collect_reports_bounded(self, bound: usize) -> TryCollectReports where diff --git a/libs/error-stack/experimental/src/tuple.rs b/libs/error-stack/src/ext/tuple.rs similarity index 91% rename from libs/error-stack/experimental/src/tuple.rs rename to libs/error-stack/src/ext/tuple.rs index 3cdc0245dc9..340d1744487 100644 --- a/libs/error-stack/experimental/src/tuple.rs +++ b/libs/error-stack/src/ext/tuple.rs @@ -1,4 +1,4 @@ -use error_stack::{Report, Result}; +use crate::{Report, Result}; /// Extends tuples with error-handling capabilities. /// @@ -6,6 +6,11 @@ use error_stack::{Report, Result}; /// containing a tuple of the successful values, or an error if any of the results failed. /// /// The trait is implemented for tuples of up to 16 elements. +/// +/// # Stability +/// +/// This trait is only available behind the `unstable` feature flag and is not covered by +/// semver guarantees. It may change or be removed in future versions without notice. pub trait TryReportTupleExt { /// The type of the successful output, typically a tuple of the inner types of the `Result`s. type Output; @@ -20,8 +25,7 @@ pub trait TryReportTupleExt { /// # Examples /// /// ``` - /// use error_stack::{Report, Result}; - /// use error_stack_experimental::TryReportTupleExt; + /// use error_stack::{Report, Result, TryReportTupleExt}; /// /// #[derive(Debug)] /// struct CustomError; @@ -120,12 +124,11 @@ all_the_tuples!(impl_ext); #[cfg(test)] mod test { + use alloc::{borrow::ToOwned, collections::BTreeSet, string::String}; use core::{error::Error, fmt::Display}; - use std::collections::HashSet; - - use error_stack::{Report, Result}; use super::TryReportTupleExt; + use crate::{Report, Result}; #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct TestError(usize); @@ -147,7 +150,7 @@ mod test { let combined = (result1, result2, result3).try_collect(); let report = combined.expect_err("should have error"); - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 1); assert!(contexts.contains(&TestError(0))); } @@ -176,7 +179,7 @@ mod test { let report = combined.expect_err("should have error"); // order of contexts is not guaranteed - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 1); assert!(contexts.contains(&TestError(0))); } @@ -191,7 +194,7 @@ mod test { let report = combined.expect_err("should have error"); // order of contexts is not guaranteed - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 2); assert!(contexts.contains(&TestError(0))); assert!(contexts.contains(&TestError(1))); @@ -207,7 +210,7 @@ mod test { let report = combined.expect_err("should have error"); // order of contexts is not guaranteed - let contexts: HashSet<_> = report.current_contexts().collect(); + let contexts: BTreeSet<_> = report.current_contexts().collect(); assert_eq!(contexts.len(), 2); assert!(contexts.contains(&TestError(0))); assert!(contexts.contains(&TestError(1))); diff --git a/libs/error-stack/src/lib.rs b/libs/error-stack/src/lib.rs index 6e4fd496ba0..6352c7d3b92 100644 --- a/libs/error-stack/src/lib.rs +++ b/libs/error-stack/src/lib.rs @@ -472,6 +472,8 @@ //! `serde` | Enables serialization support for [`Report`] | disabled //! `anyhow` | Provides `into_report` to convert [`anyhow::Error`] to [`Report`] | disabled //! `eyre` | Provides `into_report` to convert [`eyre::Report`] to [`Report`] | disabled +//! `futures` | Enables support for [`Stream`], requires `unstable` | disabled +//! `unstable` | Enables unstable features, these features are not covered by semver | disabled //! //! //! [`set_debug_hook`]: Report::set_debug_hook @@ -482,12 +484,14 @@ //! [`Display`]: core::fmt::Display //! [`Debug`]: core::fmt::Debug //! [`SpanTrace`]: tracing_error::SpanTrace +//! [`Stream`]: futures_core::Stream #![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr( nightly, feature(error_generic_member_access), allow(clippy::incompatible_msrv) )] +#![cfg_attr(all(nightly, feature = "unstable"), feature(try_trait_v2))] #![cfg_attr(all(doc, nightly), feature(doc_auto_cfg))] #![cfg_attr(all(nightly, feature = "std"), feature(backtrace_frames))] #![cfg_attr( @@ -514,12 +518,22 @@ mod result; mod context; mod error; +#[cfg(feature = "unstable")] +pub mod ext; pub mod fmt; #[cfg(any(feature = "std", feature = "hooks"))] mod hook; #[cfg(feature = "serde")] mod serde; +#[cfg(feature = "unstable")] +mod sink; +#[cfg(all(feature = "unstable", feature = "futures"))] +pub use self::ext::stream::TryReportStreamExt; +#[cfg(feature = "unstable")] +pub use self::ext::{iter::TryReportIteratorExt, tuple::TryReportTupleExt}; +#[cfg(feature = "unstable")] +pub use self::sink::ReportSink; pub use self::{ compat::IntoReportCompat, context::Context, diff --git a/libs/error-stack/src/macros.rs b/libs/error-stack/src/macros.rs index fc04cb15132..7bc80087910 100644 --- a/libs/error-stack/src/macros.rs +++ b/libs/error-stack/src/macros.rs @@ -190,13 +190,96 @@ macro_rules! report { /// } /// # Ok(()) /// ``` +#[cfg(not(feature = "unstable"))] #[macro_export] macro_rules! bail { - ($err:expr $(,)?) => {{ + ($err:expr) => {{ return $crate::Result::Err($crate::report!($err)); }}; } +/// Creates a [`Report`] and returns it as [`Result`]. +/// +/// Shorthand for `return `Err`(`[`report!(...)`]`)` +/// +/// [`Report`]: crate::Report +/// [`report!(...)`]: report +/// +/// # Examples +/// +/// Create a [`Report`] from [`Error`]: +/// +/// [`Error`]: core::error::Error +/// +/// ``` +/// use std::fs; +/// +/// use error_stack::bail; +/// # fn wrapper() -> error_stack::Result<(), impl core::fmt::Debug> { +/// match fs::read_to_string("/path/to/file") { +/// Ok(content) => println!("file contents: {content}"), +/// Err(err) => bail!(err), +/// } +/// # Ok(()) } +/// # assert!(wrapper().unwrap_err().contains::()); +/// ``` +/// +/// Create a [`Report`] from [`Context`]: +/// +/// [`Context`]: crate::Context +/// +/// ```rust +/// # fn has_permission(_: &u32, _: &u32) -> bool { true } +/// # type User = u32; +/// # let user = 0; +/// # type Resource = u32; +/// # let resource = 0; +/// use core::fmt; +/// +/// use error_stack::{bail, Context}; +/// +/// #[derive(Debug)] +/// # #[allow(dead_code)] +/// struct PermissionDenied(User, Resource); +/// +/// impl fmt::Display for PermissionDenied { +/// # #[allow(unused_variables)] +/// fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { +/// # const _: &str = stringify! { +/// ... +/// # }; Ok(())} +/// } +/// +/// impl Context for PermissionDenied {} +/// +/// if !has_permission(&user, &resource) { +/// bail!(PermissionDenied(user, resource)); +/// } +/// # Ok(()) +/// ``` +#[cfg(feature = "unstable")] +#[macro_export] +macro_rules! bail { + ($err:expr) => {{ + return $crate::Result::Err($crate::report!($err)); + }}; + + [$($err:expr),+ $(,)?] => {{ + let mut sink = $crate::ReportSink::new(); + + $( + sink.capture($err); + )+ + + let error = match sink.finish() { + Ok(()) => unreachable!(), + Err(error) => error, + }; + + return core::result::Result::Err(error); + }}; +} + /// Ensures `$cond` is met, otherwise return an error. /// /// Shorthand for `if !$cond { `[`bail!(...)`]`) }` diff --git a/libs/error-stack/src/sink.rs b/libs/error-stack/src/sink.rs new file mode 100644 index 00000000000..bf68b9caf35 --- /dev/null +++ b/libs/error-stack/src/sink.rs @@ -0,0 +1,645 @@ +use core::{ + convert::Infallible, + ops::{FromResidual, Try}, +}; + +use crate::Report; + +/// The `Bomb` type is used to enforce proper usage of `ReportSink` at runtime. +/// +/// It addresses a limitation of the `#[must_use]` attribute, which becomes ineffective +/// when methods like `&mut self` are called, marking the value as used prematurely. +/// +/// By moving this check to runtime, `Bomb` ensures that `ReportSink` is properly +/// consumed. Depending on its configuration, it will either: +/// - Panic if the `ReportSink` is dropped without being used (when set to `Panic` mode) +/// - Emit a warning to stderr (when in `Warn` mode, which is the default) +/// - Do nothing if properly defused (i.e., when `ReportSink` is correctly used) +/// +/// This runtime check complements the compile-time `#[must_use]` attribute, +/// providing a more robust mechanism to prevent `ReportSink` not being consumed. +#[derive(Debug, Default)] +enum BombState { + Panic, + #[default] + Warn, + Defused, +} + +#[derive(Debug, Default)] +struct Bomb(BombState); + +impl Bomb { + const fn panic() -> Self { + Self(BombState::Panic) + } + + const fn warn() -> Self { + Self(BombState::Warn) + } + + fn defuse(&mut self) { + self.0 = BombState::Defused; + } +} + +impl Drop for Bomb { + fn drop(&mut self) { + // If we're in release mode, we don't need to do anything + if !cfg!(debug_assertions) { + return; + } + + match self.0 { + BombState::Panic => panic!("ReportSink was dropped without being consumed"), + #[allow(clippy::print_stderr)] + BombState::Warn => { + #[cfg(all(not(target_arch = "wasm32"), feature = "std"))] + eprintln!("ReportSink was dropped without being consumed"); + } + BombState::Defused => {} + } + } +} +/// A sink for collecting multiple [`Report`]s into a single [`Result`]. +/// +/// [`ReportSink`] allows you to accumulate multiple errors or reports and then +/// finalize them into a single `Result`. This is particularly useful when you +/// need to collect errors from multiple operations before deciding whether to +/// proceed or fail. +/// +/// The sink is equipped with a "bomb" mechanism to ensure proper usage, +/// if the sink hasn't been finished when dropped, it will emit a warning or panic, +/// depending on the constructor used. +/// +/// # Examples +/// +/// ``` +/// use error_stack::{ReportSink, Result}; +/// +/// #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// struct InternalError; +/// +/// impl core::fmt::Display for InternalError { +/// fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +/// f.write_str("Internal error") +/// } +/// } +/// +/// impl core::error::Error for InternalError {} +/// +/// fn operation1() -> Result { +/// // ... +/// # Ok(42) +/// } +/// +/// fn operation2() -> Result<(), InternalError> { +/// // ... +/// # Ok(()) +/// } +/// +/// fn process_data() -> Result<(), [InternalError]> { +/// let mut sink = ReportSink::new(); +/// +/// if let Some(value) = sink.attempt(operation1()) { +/// // process value +/// # let _value = value; +/// } +/// +/// if let Err(e) = operation2() { +/// sink.add(e); +/// } +/// +/// sink.finish() +/// } +/// # let _result = process_data(); +/// ``` +#[must_use] +pub struct ReportSink { + report: Option>, + bomb: Bomb, +} + +impl ReportSink { + /// Creates a new [`ReportSink`]. + /// + /// If the sink hasn't been finished when dropped, it will emit a warning. + pub const fn new() -> Self { + Self { + report: None, + bomb: Bomb::warn(), + } + } + + /// Creates a new [`ReportSink`]. + /// + /// If the sink hasn't been finished when dropped, it will panic. + pub const fn new_armed() -> Self { + Self { + report: None, + bomb: Bomb::panic(), + } + } + + /// Adds a [`Report`] to the sink. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::{ReportSink, Report}; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// sink.add(Report::new(io::Error::new( + /// io::ErrorKind::Other, + /// "I/O error", + /// ))); + /// ``` + pub fn add(&mut self, report: impl Into>) { + let report = report.into(); + + match self.report.as_mut() { + Some(existing) => existing.append(report), + None => self.report = Some(report), + } + } + + /// Captures a single error or report in the sink. + /// + /// This method is similar to [`add`], but allows for bare errors without prior [`Report`] + /// creation. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// sink.capture(io::Error::new(io::ErrorKind::Other, "I/O error")); + /// ``` + /// + /// [`add`]: ReportSink::add + pub fn capture(&mut self, error: impl Into>) { + let report = error.into(); + + match self.report.as_mut() { + Some(existing) => existing.push(report), + None => self.report = Some(report.into()), + } + } + + /// Attempts to execute a fallible operation and collect any errors. + /// + /// This method takes a [`Result`] and returns an [`Option`]: + /// - If the [`Result`] is [`Ok`], it returns [`Some(T)`] with the successful value. + /// - If the [`Result`] is [`Err`], it captures the error in the sink and returns [`None`]. + /// + /// This is useful for concisely handling operations that may fail, allowing you to + /// collect errors while continuing execution. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// fn fallible_operation() -> Result { + /// // ... + /// # Ok(42) + /// } + /// + /// let mut sink = ReportSink::new(); + /// let value = sink.attempt(fallible_operation()); + /// if let Some(v) = value { + /// // Use the successful value + /// # let _v = v; + /// } + /// // Any errors are now collected in the sink + /// # let _result = sink.finish(); + /// ``` + pub fn attempt(&mut self, result: Result) -> Option + where + R: Into>, + { + match result { + Ok(value) => Some(value), + Err(error) => { + self.capture(error); + None + } + } + } + + /// Finishes the sink and returns a [`Result`]. + /// + /// This method consumes the sink, and returns `Ok(())` if no errors + /// were collected, or `Err(Report<[C]>)` containing all collected errors otherwise. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// # // needed for type inference + /// # sink.capture(io::Error::new(io::ErrorKind::Other, "I/O error")); + /// // ... add errors ... + /// let result = sink.finish(); + /// # let _result = result; + /// ``` + pub fn finish(mut self) -> Result<(), Report<[C]>> { + self.bomb.defuse(); + self.report.map_or(Ok(()), Err) + } + + /// Finishes the sink and returns a [`Result`] with a custom success value. + /// + /// Similar to [`finish`], but allows specifying a function to generate the success value. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// # // needed for type inference + /// # sink.capture(io::Error::new(io::ErrorKind::Other, "I/O error")); + /// // ... add errors ... + /// let result = sink.finish_with(|| "Operation completed"); + /// # let _result = result; + /// ``` + /// + /// [`finish`]: ReportSink::finish + pub fn finish_with(mut self, ok: impl FnOnce() -> T) -> Result> { + self.bomb.defuse(); + self.report.map_or_else(|| Ok(ok()), Err) + } + + /// Finishes the sink and returns a [`Result`] with a default success value. + /// + /// Similar to [`finish`], but uses `T::default()` as the success value. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// # // needed for type inference + /// # sink.capture(io::Error::new(io::ErrorKind::Other, "I/O error")); + /// // ... add errors ... + /// let result: Result, _> = sink.finish_with_default(); + /// # let _result = result; + /// ``` + /// + /// [`finish`]: ReportSink::finish + pub fn finish_with_default(mut self) -> Result> { + self.bomb.defuse(); + self.report.map_or_else(|| Ok(T::default()), Err) + } + + /// Finishes the sink and returns a [`Result`] with a provided success value. + /// + /// Similar to [`finish`], but allows specifying a concrete value for the success case. + /// + /// # Examples + /// + /// ``` + /// # use error_stack::ReportSink; + /// # use std::io; + /// let mut sink = ReportSink::new(); + /// # // needed for type inference + /// # sink.capture(io::Error::new(io::ErrorKind::Other, "I/O error")); + /// // ... add errors ... + /// let result = sink.finish_with_value(42); + /// # let _result = result; + /// ``` + /// + /// [`finish`]: ReportSink::finish + pub fn finish_with_value(mut self, ok: T) -> Result> { + self.bomb.defuse(); + self.report.map_or(Ok(ok), Err) + } +} + +impl Default for ReportSink { + fn default() -> Self { + Self::new() + } +} + +#[cfg(nightly)] +impl FromResidual for ReportSink { + fn from_residual(residual: ::Residual) -> Self { + match residual { + Err(report) => Self { + report: Some(report), + bomb: Bomb::default(), + }, + } + } +} + +#[cfg(nightly)] +impl Try for ReportSink { + type Output = (); + // needs to be infallible, not `!` because of the `Try` of `Result` + type Residual = Result>; + + fn from_output((): ()) -> Self { + Self { + report: None, + bomb: Bomb::default(), + } + } + + fn branch(mut self) -> core::ops::ControlFlow { + self.bomb.defuse(); + self.report.map_or( + core::ops::ControlFlow::Continue(()), // + |report| core::ops::ControlFlow::Break(Err(report)), + ) + } +} + +#[cfg(test)] +mod test { + use alloc::collections::BTreeSet; + use core::fmt::Display; + + use crate::{sink::ReportSink, Report}; + + #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] + struct TestError(u8); + + impl Display for TestError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + fmt.write_str("TestError(")?; + core::fmt::Display::fmt(&self.0, fmt)?; + fmt.write_str(")") + } + } + + impl core::error::Error for TestError {} + + #[test] + fn add_single() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + + let report = sink.finish().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 1); + assert!(contexts.contains(&TestError(0))); + } + + #[test] + fn add_multiple() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + let report = sink.finish().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn capture_single() { + let mut sink = ReportSink::new(); + + sink.capture(TestError(0)); + + let report = sink.finish().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 1); + assert!(contexts.contains(&TestError(0))); + } + + #[test] + fn capture_multiple() { + let mut sink = ReportSink::new(); + + sink.capture(TestError(0)); + sink.capture(TestError(1)); + + let report = sink.finish().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn new_does_not_panic() { + let _sink: ReportSink = ReportSink::new(); + } + + #[cfg(nightly)] + #[test] + fn try_none() { + fn sink() -> Result<(), Report<[TestError]>> { + let sink = ReportSink::new(); + + sink?; + + Ok(()) + } + + sink().expect("should not have failed"); + } + + #[cfg(nightly)] + #[test] + fn try_single() { + fn sink() -> Result<(), Report<[TestError]>> { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + + sink?; + Ok(()) + } + + let report = sink().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 1); + assert!(contexts.contains(&TestError(0))); + } + + #[cfg(nightly)] + #[test] + fn try_multiple() { + fn sink() -> Result<(), Report<[TestError]>> { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + sink?; + Ok(()) + } + + let report = sink().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[cfg(nightly)] + #[test] + fn try_arbitrary_return() { + fn sink() -> Result> { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + + sink?; + Ok(8) + } + + let report = sink().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 1); + assert!(contexts.contains(&TestError(0))); + } + + #[test] + #[should_panic(expected = "without being consumed")] + fn panic_on_unused() { + #[allow(clippy::unnecessary_wraps)] + fn sink() -> Result<(), Report<[TestError]>> { + let mut sink = ReportSink::new_armed(); + + sink.add(Report::new(TestError(0))); + + Ok(()) + } + + let _result = sink(); + } + + #[test] + fn panic_on_unused_with_defuse() { + #[allow(clippy::unnecessary_wraps)] + fn sink() -> Result<(), Report<[TestError]>> { + let mut sink = ReportSink::new_armed(); + + sink.add(Report::new(TestError(0))); + + sink?; + Ok(()) + } + + let report = sink().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 1); + assert!(contexts.contains(&TestError(0))); + } + + #[test] + fn finish() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + let report = sink.finish().expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn finish_ok() { + let sink: ReportSink = ReportSink::new(); + + sink.finish().expect("should have succeeded"); + } + + #[test] + fn finish_with() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + let report = sink.finish_with(|| 8).expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn finish_with_ok() { + let sink: ReportSink = ReportSink::new(); + + let value = sink.finish_with(|| 8).expect("should have succeeded"); + assert_eq!(value, 8); + } + + #[test] + fn finish_with_default() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + let report = sink + .finish_with_default::() + .expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn finish_with_default_ok() { + let sink: ReportSink = ReportSink::new(); + + let value = sink + .finish_with_default::() + .expect("should have succeeded"); + assert_eq!(value, 0); + } + + #[test] + fn finish_with_value() { + let mut sink = ReportSink::new(); + + sink.add(Report::new(TestError(0))); + sink.add(Report::new(TestError(1))); + + let report = sink.finish_with_value(8).expect_err("should have failed"); + + let contexts: BTreeSet<_> = report.current_contexts().collect(); + assert_eq!(contexts.len(), 2); + assert!(contexts.contains(&TestError(0))); + assert!(contexts.contains(&TestError(1))); + } + + #[test] + fn finish_with_value_ok() { + let sink: ReportSink = ReportSink::new(); + + let value = sink.finish_with_value(8).expect("should have succeeded"); + assert_eq!(value, 8); + } +} diff --git a/libs/error-stack/tests/ui/macro_invalid_args.stderr b/libs/error-stack/tests/ui/macro_invalid_args.stderr index 15e42dd26a5..83eacea6057 100644 --- a/libs/error-stack/tests/ui/macro_invalid_args.stderr +++ b/libs/error-stack/tests/ui/macro_invalid_args.stderr @@ -19,7 +19,7 @@ error: unexpected end of macro invocation note: while trying to match meta-variable `$err:expr` --> src/macros.rs | - | ($err:expr $(,)?) => {{ + | ($err:expr) => {{ | ^^^^^^^^^ error: unexpected end of macro invocation