anystack

Crates.ioanystack
lib.rsanystack
version0.6.0-alpha.3
created_at2025-06-16 16:58:31.715284+00
updated_at2025-06-21 14:18:12.786102+00
descriptionFlexible and comprehensive error handling.
homepage
repositoryhttps://github.com/zakstucke/anystack
max_upload_size
id1714518
size361,413
(zakstucke)

documentation

https://docs.rs/anystack

README

crates.io libs.rs rust-version documentation license

anystack

anystack is a context-aware error-handling library that supports arbitrary attached user data.

Overview

anystack is centered around the idea of building a [Report] of the error as it propagates. A [Report] is made up of two concepts:

  1. Contexts
  2. Attachments

A context is a view of the world, it helps describe how the current section of code interprets the error. This is used to capture how various scopes require differing levels of detail and understanding of the error as it propagates. A [Report] always captures the current context in its generic argument.

As the [Report] is built, various pieces of supporting information can be attached. These can be anything that can be shared between threads whether it be a supporting message or a custom-defined Suggestion struct.

Quick-Start Guide

In a new project

# #![allow(dead_code)]
use anystack::{Report, ResultExt};
// using `thiserror` is not neccessary, but convenient
use thiserror::Error;

// Errors can enumerate variants users care about
// but notably don't need to chain source/inner error manually.
#[derive(Error, Debug)]
enum AppError {
    #[error("serious app error: {consequences}")]
    Serious { consequences: &'static str },
    #[error("trivial app error")]
    Trivial,
}

type AppResult<T> = Result<T, Report<AppError>>;

// Errors can also be a plain `struct`, somewhat like in `anyhow`.
#[derive(Error, Debug)]
#[error("logic error")]
struct LogicError;

type LogicResult<T> = Result<T, Report<LogicError>>;

fn do_logic() -> LogicResult<()> {
    Ok(())
}

fn main() -> AppResult<()> {
    // `anystack` requires developer to properly handle
    // changing error contexts
    do_logic().change_context(AppError::Serious {
        consequences: "math no longer works",
    })?;

    Ok(())
}

Where to use a Report

[Report] has been designed to be used as the [Err] variant of a [Result]:

# fn has_permission(_: (), _: ()) -> bool { true }
# fn get_user() -> Result<(), AccessError> { Ok(()) }
# fn get_resource() -> Result<(), AccessError> { Ok(()) }
# #[derive(Debug)] enum AccessError { PermissionDenied((), ()) }
# impl core::fmt::Display for AccessError {
#    fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Ok(()) }
# }
# impl core::error::Error for AccessError {}
use anystack::{ensure, Report};

fn main() -> Result<(), Report<AccessError>> {
    let user = get_user()?;
    let resource = get_resource()?;

    ensure!(
        has_permission(user, resource),
        AccessError::PermissionDenied(user, resource)
    );

    # const _: &str = stringify! {
    ...
    # }; Ok(())
}

Initializing a Report

A [Report] can be created directly from anything that implements Error by using [Report::new()], [IntoReport::into_report()], or through any of the provided macros ([bail!], [ensure!]).

use std::{fs, io, path::Path};

use anystack::Report;

// Note: For demonstration purposes this example does not use `anystack::Result`.
// As can be seen, it's possible to implicitly convert `io::Error` to `Report<io::Error>`
fn read_file(path: impl AsRef<Path>) -> Result<String, Report<io::Error>> {
    let content = fs::read_to_string(path)?;

    # const _: &str = stringify! {
    ...
    # }; Ok(content)
}
# let report = read_file("test.txt").unwrap_err();
# assert!(report.contains::<io::Error>());

Using and Expanding the Report

As mentioned, the library centers around the idea of building a [Report] as it propagates.

Changing Context

The generic parameter in [Report] is called the current context. When creating a new [Report], the Error that's provided will be set as the current context. The current context should encapsulate how the current code interprets the error. As the error propagates, it will cross boundaries where new information is available, and the previous level of detail is no longer applicable. These boundaries will often occur when crossing between major modules, or when execution crosses between crates. At this point the [Report] should start to operate in a new context. To change the context, [Report::change_context()] is used:

(Again, for convenience, using [ResultExt] will do that on the [Err] variant)

# use std::{fmt, fs, io, path::Path};
use core::error::Error;

use anystack::{Report, ResultExt};
# pub type Config = String;

#[derive(Debug)]
struct ParseConfigError;

impl fmt::Display for ParseConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("could not parse configuration file")
    }
}

// It's also possible to implement `Error` instead.
impl Error for ParseConfigError {}

// For clarification, this example is not using `anystack::Result`.
fn parse_config(path: impl AsRef<Path>) -> Result<Config, Report<ParseConfigError>> {
    let content = fs::read_to_string(path.as_ref())
        .change_context(ParseConfigError)?;

    # const _: &str = stringify! {
    ...
    # }; Ok(content)
}
# let report = parse_config("test.txt").unwrap_err();
# assert!(report.contains::<io::Error>());
# assert!(report.contains::<ParseConfigError>());

Building up the Report - Attachments

Module/crate boundaries are not the only places where information can be embedded within the [Report] however. Additional information can be attached within the current context, whether this be a string, or any thread-safe object. These attachments are added by using [Report::attach()] and [Report::attach_printable()]:

# use std::{fs, path::Path};
# use anystack::{Report, ResultExt};
# pub type Config = String;
# #[derive(Debug)] struct ParseConfigError;
# impl ParseConfigError { pub fn new() -> Self { Self } }
# impl std::fmt::Display for ParseConfigError {
#     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#         f.write_str("could not parse configuration file")
#     }
# }
# impl core::error::Error for ParseConfigError {}
# #[derive(Debug, PartialEq)]
struct Suggestion(&'static str);

fn parse_config(path: impl AsRef<Path>) -> Result<Config, Report<ParseConfigError>> {
    let path = path.as_ref();

    let content = fs::read_to_string(path)
        .change_context(ParseConfigError::new())
        .attach(Suggestion("use a file you can read next time!"))
        .attach_printable_lazy(|| format!("could not read file {path:?}"))?;

    Ok(content)
}
# let report = parse_config("test.txt").unwrap_err();
# assert!(report.contains::<std::io::Error>());
# assert_eq!(report.downcast_ref::<Suggestion>().unwrap(), &Suggestion("use a file you can read next time!"));
# assert_eq!(report.downcast_ref::<Suggestion>().unwrap(), &Suggestion("use a file you can read next time!"));
# assert_eq!(report.downcast_ref::<String>().unwrap(), "could not read file \"test.txt\"");
# assert!(report.contains::<ParseConfigError>());
#
# Report::set_color_mode(anystack::fmt::ColorMode::Emphasis);
# fn render(value: String) -> String {
#     let backtrace = regex::Regex::new(r"backtrace no\. (\d+)\n(?:  .*\n)*  .*").unwrap();
#     let backtrace_info = regex::Regex::new(r"backtrace( with (\d+) frames)? \((\d+)\)").unwrap();
#
#     let value = backtrace.replace_all(&value, "backtrace no. $1\n  [redacted]");
#     let value = backtrace_info.replace_all(value.as_ref(), "backtrace ($3)");
#
#     ansi_to_html::convert(value.as_ref()).unwrap()
# }
#
# expect_test::expect_file![concat!(env!("CARGO_MANIFEST_DIR"), "/tests/snapshots/doc/lib__suggestion.snap")].assert_eq(&render(format!("{report:?}")));

As seen above, there are ways on attaching more information to the [Report]: attach and attach_printable. These two functions behave similar, but the latter has a more restrictive bound on the attachment: Display and Debug. Depending on the function used, printing the [Report] will also use the Display and Debug traits to describe the attachment.

This outputs something like:

#![cfg_attr(doc, doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/snapshots/doc/lib__suggestion.snap")))]

The Suggestion which was added via attach is not shown directly and only increases the counter of opaque attachments for the containing Error. The message which was passed to attach_printable, however, is displayed in full. To be able to show attachments that have been added via attach, one must make use of hooks instead.

Multiple Errors

[Report] provides native support for combining and propagating multiple errors. This feature is particularly useful in scenarios such as parallel processing, where multiple errors might occur independently. In these cases, you can utilize the [Extend] trait implementation and the push() method to aggregate and propagate all encountered errors, rather than just a single one.

anystack is designed to be explicit about the presence of single or multiple current contexts. This distinction is reflected in the generic type parameter:

  • [Report<C>] indicates that a single current context is present.
  • [Report<[C]>] signifies that at least one current context is present, with the possibility of multiple contexts.

You can seamlessly convert between these representations using [Report::expand] to transform a single-context report into a multi-context one. Using [Report::change_context] will transform a [Report<[C]>] to a [Report<C2>], where C2 is a new context type.

# use std::{fs, path::Path};
# use anystack::Report;
# pub type Config = String;

fn parse_configs(paths: &[impl AsRef<Path>]) -> Result<Vec<Config>, Report<[std::io::Error]>> {
    let mut configs = Vec::new();
    let mut error: Option<Report<[std::io::Error]>> = None;

    for path in paths {
        let path = path.as_ref();

        match fs::read_to_string(path) {
            Ok(ok) => {
                configs.push(ok);
            }
            Err(err) => {
                if let Some(error) = error.as_mut() {
                    error.push(Report::from(err));
                } else {
                    error = Some(Report::from(err).expand());
                }
            }
        }
    }

    if let Some(error) = error {
        return Err(error);
    }

    Ok(configs)
}

# let report = parse_configs(&["test.txt", "test2.txt", "test3.txt"]).unwrap_err();
# assert!(report.contains::<std::io::Error>());

In-Depth Explanation

Crate Philosophy

This crate adds some development overhead in comparison to other error handling strategies, especially around creating custom root-errors (specifically anystack does not allow using string-like types). The intention is that this reduces overhead at other parts of the process, whether that be implementing error-handling, debugging, or observability. The idea that underpins this is that errors should happen in well-scoped environments like reading a file or parsing a string into an integer. For these errors, a well-defined error type should be used (i.e. io::Error or ParseIntError) instead of creating an error from a string. Requiring a well-defined type forces users to be conscious about how they classify and group their custom error types, which improves their usability in error-handling.

Improving Result::Err Types

By capturing the current Error in the type parameter, return types in function signatures continue to explicitly capture the perspective of the current code. This means that more often than not the user is forced to re-describe the error when entering a substantially different part of the code because the constraints of typed return types will require it. This will happen most often when crossing module/crate boundaries.

An example of this is a ConfigParseError when produced when parsing a configuration file at a high-level in the code vs. the lower-level io::Error that occurs when reading the file from disk. The io::Error may no longer be valuable at the level of the code that's handling parsing a config, and re-framing the error in a new type allows the user to incorporate contextual information that's only available higher-up in the stack.

Compatibility with other Libraries

anystack uses the standard Error type which makes it compatible with almost all other libraries that use that trait.

This has the added benefit that migrating from other error libraries can often be incremental, as a lot of popular error library types will work within the [Report] struct.

In addition, anystack supports converting errors generated from the [anyhow] or [eyre] crate via [IntoReportCompat].

Doing more

Beyond making new Error types, the library supports the attachment of arbitrary thread-safe data. These attachments can be requested through [Report::downcast_ref()] or [Report::downcast_iter()]. This gives a novel way to expand standard error-handling approaches, without decreasing the ergonomics of creating the actual error variants:

# struct Suggestion(&'static str);
# fn parse_config(_: &str) -> Result<(), anystack::Report<std::io::Error>> { Ok(()) }
fn main() {
    if let Err(report) = parse_config("config.json") {
        for suggestion in report.downcast_iter::<Suggestion>() {
            eprintln!("suggestion: {}", suggestion.0);
        }
    }
}

Additional Features

The above examples will probably cover 90% of the common use case. This crate does have additional features for more specific scenarios:

Automatic Backtraces

[Report] will try to capture a Backtrace if RUST_BACKTRACE or RUST_BACKTRACE_LIB is set and the backtrace feature is enabled (by default this is the case).

Unlike some other approaches, this does not require the user modifying their custom error types to be aware of backtraces, and doesn't require manual implementations to forward calls down any wrapped errors.

No-Std compatible

The complete crate is written for no-std environments, which can be used by setting default-features = false in Cargo.toml.

Macros for Convenience

Two macros are provided to simplify the generation of a [Report].

  • [bail!] acts like calling [IntoReport::into_report()] but also immediately returns the [Report] as [Err] variant.
  • [ensure!] will check an expression and if it's evaluated to false, it will act like [bail!].

Span Traces

The crate comes with built-in support for tracings SpanTrace. If the spantrace feature is enabled and an ErrorLayer is set, a SpanTrace is either used when provided by the root Error or will be captured when creating the [Report].

Debug Hooks

One can provide hooks for types added as attachments when the std feature is enabled. These hooks are then used while formatting [Report]. This functionality is also used internally by anystack to render Backtrace, and SpanTrace, which means overwriting and customizing them is as easy as providing another hook.

You can add new hooks with [Report::install_debug_hook]. Refer to the module-level documentation of [fmt] for further information.

Additional Adaptors

[ResultExt] is a convenient wrapper around Result<_, impl Error> and Result<_, Report<impl Error>. It offers attach and change_context on the [Result] directly, but also a lazy variant that receives a function which is only called if an error happens.

In addition to [ResultExt], this crate also comes with [FutureExt], which provides the same functionality for Futures.

Colored output and charset selection

You can override the color support by using the [Report::set_color_mode]. To override the charset used, you can use [Report::set_charset]. The default color mode is emphasis. The default charset is UTF-8.

To automatically detect support if your target output supports unicode and colors you can check out the detect.rs example.

Feature Flags

Feature Description default

std | Enables support for Error | enabled backtrace | Enables automatic capturing of Backtraces | enabled spantrace | Enables automatic capturing of SpanTraces | disabled hooks | Enables hooks on no-std platforms using spin locks | disabled 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


License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Commit count: 0

cargo fmt