scoped-error 0.1.3

Structured error handling with semantic context trees. Zero proc-macros. Zero backtrace overhead.
Documentation
// Copyright (C) 2026 Kan-Ru Chen <kanru@kanru.info>
//
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

//! Error report formatting with tree structure support.
//!
//! This module provides [`ErrorReport`], which formats error chains
//! for human-readable output. It supports both linear chains (single
//! cause via [`Error::source`]) and tree structures (multiple causes
//! from [`Many`](crate::Many)).
//!
//! The formatting uses ASCII characters for broad terminal compatibility:
//! - `|--` for branches that continue (have siblings after)
//! - '`--` for final branches
//! - `|` for vertical continuation lines

use std::error::Error;
use std::fmt;
use std::slice::Iter;

use crate::Many;

/// A formatted error report.
///
/// `ErrorReport` wraps a reference to a `'static` error and formats it
/// with its full causal chain. It is created via
/// [`ErrorExt::report`](crate::ErrorExt::report).
///
/// The report automatically detects [`Many`](crate::Many)
/// and renders them as a tree structure. Linear error chains are rendered
/// with the same tree characters for visual consistency.
///
/// # Output Format
///
/// **Linear chain:**
/// ```text
/// Failed to start service, at src/main.rs:42:10
/// |-- Failed to initialize database, at src/db.rs:15:5
/// `-- connection refused
/// ```
///
/// **Many causes (tree):**
/// ```text
/// Batch operation failed, at src/main.rs:20:5 (3 causes)
/// |-- Task A failed, at src/worker.rs:8:9
/// |   `-- I/O error: file not found
/// |-- Task B failed, at src/worker.rs:12:9
/// |   `-- network timeout
/// `-- Task C failed, at src/worker.rs:16:9
///     `-- invalid input
/// ```
///
/// **Nested Many:**
/// ```text
/// Distributed operation failed, at src/cluster.rs:50:5 (2 causes)
/// |-- Node A batch failed, at src/node.rs:25:10 (2 causes)
/// |   |-- Task 1 failed
/// |   `-- Task 2 failed
/// `-- Node B batch failed, at src/node.rs:30:10 (1 cause)
///     `-- Task 3 failed
/// ```
///
/// # Example
///
/// ```
/// use scoped_error::{Error, expect_error, ErrorExt};
///
/// let err: Error = expect_error("Database connection failed", || {
///     std::fs::read_to_string("nonexistent")?;
///     Ok(())
/// }).unwrap_err();
///
/// println!("{}", err.report());
/// // Output:
/// // Database connection failed, at src/main.rs:3:5
/// // `-- No such file or directory (os error 2)
/// ```
pub struct ErrorReport<'a>(pub &'a (dyn Error + 'static));

impl<'a> ErrorReport<'a> {
    /// Create a new error report from an error reference.
    ///
    /// The error must be `'static` to allow downcasting for
    /// [`Many`] detection.
    pub fn new(error: &'a (dyn Error + 'static)) -> Self {
        Self(error)
    }
}

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

enum UnifiedChildren<'a> {
    Slice(&'a [Box<dyn Error + Send + Sync + 'static>]),
    Single(Option<&'a (dyn Error + 'static)>),
}

enum ErrorIter<'a> {
    Slice(Iter<'a, Box<dyn Error + Send + Sync + 'static>>),
    Single(Option<&'a (dyn Error + 'static)>),
}

impl<'a> Iterator for ErrorIter<'a> {
    type Item = &'a (dyn Error + 'static);

    fn next(&mut self) -> Option<Self::Item> {
        match self {
            ErrorIter::Slice(iter) => iter.next().map(|b| b.as_ref() as _),
            ErrorIter::Single(opt) => opt.take(),
        }
    }
}

impl<'a> UnifiedChildren<'a> {
    fn from(error: &'a (dyn Error + 'static)) -> Self {
        if let Some(multi) = error.downcast_ref::<Many>() {
            UnifiedChildren::Slice(multi.causes())
        } else {
            // Linear chain
            UnifiedChildren::Single(error.source())
        }
    }
    fn len(&self) -> usize {
        match self {
            UnifiedChildren::Slice(s) => s.len(),
            UnifiedChildren::Single(opt) => opt.iter().len(),
        }
    }
    fn iter(&self) -> ErrorIter<'a> {
        match *self {
            UnifiedChildren::Slice(slice) => ErrorIter::Slice(slice.iter()),
            UnifiedChildren::Single(opt) => ErrorIter::Single(opt),
        }
    }
}

/// Write an error with its causal chain in tree format.
pub fn write_error(
    f: &mut fmt::Formatter<'_>,
    error: &(dyn Error + 'static),
    level: usize,
    prefix: &str,
) -> fmt::Result {
    write!(f, "{}", error)?;

    // Check for Many to render as tree
    let children = UnifiedChildren::from(error);
    let children_len = children.len();

    for (i, child) in children.iter().enumerate() {
        let child_child_len = UnifiedChildren::from(child).len();
        let is_linear = level == 0 && children_len == 1 && child_child_len == 1;

        if i != children_len - 1 || is_linear {
            write!(f, "\n{}|-- ", prefix)?;
        } else {
            write!(f, "\n{}`-- ", prefix)?;
        }

        if is_linear {
            write_error(f, child, 0, prefix)?;
        } else if i < children_len - 1 {
            write_error(f, child, level + 1, &format!("{}|   ", prefix))?;
        } else {
            write_error(f, child, level + 1, &format!("{}    ", prefix))?;
        }
    }

    Ok(())
}