diff --git a/CHANGELOG.md b/CHANGELOG.md index 4419af2..5c9bb4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.12.0] - Unreleased + +### Added + +- Added support for formatting the error sources for a context [#94](https://github.com/rootcause-rs/rootcause/pull/94). + - This adds new fields to the `ContextFormattingStyle` and the `DefaultReportFormatter`. ## [0.11.0] - 2025-12-12 @@ -151,7 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/rootcause-rs/rootcause/compare/v0.11.0...HEAD +[0.12.0]: https://github.com/rootcause-rs/rootcause/compare/v0.11.0...HEAD [0.11.0]: https://github.com/rootcause-rs/rootcause/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/rootcause-rs/rootcause/compare/v0.9.1...v0.10.0 [0.9.1]: https://github.com/rootcause-rs/rootcause/compare/v0.9.0...v0.9.1 diff --git a/Cargo.lock b/Cargo.lock index 8872bc5..819f2da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rootcause" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "derive_more", @@ -254,7 +254,7 @@ dependencies = [ [[package]] name = "rootcause-backtrace" -version = "0.11.0" +version = "0.12.0" dependencies = [ "backtrace", "regex", @@ -264,7 +264,7 @@ dependencies = [ [[package]] name = "rootcause-internals" -version = "0.11.0" +version = "0.12.0" dependencies = [ "static_assertions", "triomphe", diff --git a/Cargo.toml b/Cargo.toml index ec7c9a6..ab98443 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rootcause" -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT/Apache-2.0" categories = ["rust-patterns", "no-std"] @@ -35,7 +35,7 @@ error-stack05 = { package = "error-stack", version = "0.5.0", default-features = eyre = { version = "0.6.12", default-features = false, optional = true } # Internal dependencies -rootcause-internals = { path = "rootcause-internals", version = "=0.11.0" } +rootcause-internals = { path = "rootcause-internals", version = "=0.12.0" } [dev-dependencies] derive_more = { version = "2.1.0", default-features = false, features = [ diff --git a/README.md b/README.md index 859764b..c779d82 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ Once you're comfortable with the basics, rootcause offers powerful features for - [`retry_with_collection.rs`](examples/retry_with_collection.rs) - Collecting multiple retry attempts - [`batch_processing.rs`](examples/batch_processing.rs) - Gathering errors from parallel operations - [`inspecting_errors.rs`](examples/inspecting_errors.rs) - Programmatic tree traversal and data extraction for analytics +- [`following_error_sources.rs`](examples/following_error_sources.rs) - Displaying full error source chains for debugging third-party library errors - [`custom_handler.rs`](examples/custom_handler.rs) - Customizing error formatting and data collection - [`formatting_hooks.rs`](examples/formatting_hooks.rs) - Advanced formatting customization diff --git a/examples/README.md b/examples/README.md index 9beab7f..2233289 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,6 +48,7 @@ Demonstrations of rootcause features and patterns. ## Hooks & Formatting - [`formatting_hooks.rs`](formatting_hooks.rs) - Global formatting overrides: placement, priority, custom context display +- [`following_error_sources.rs`](following_error_sources.rs) - Display full error source chains: useful for debugging third-party library errors with deep cause chains - [`report_creation_hook.rs`](report_creation_hook.rs) - Automatic attachment on creation: simple collectors vs conditional logic - [`conditional_formatting.rs`](conditional_formatting.rs) - Conditional formatting based on runtime context (environment, feature flags, etc.) diff --git a/examples/following_error_sources.rs b/examples/following_error_sources.rs new file mode 100644 index 0000000..92a08a0 --- /dev/null +++ b/examples/following_error_sources.rs @@ -0,0 +1,148 @@ +//! Demonstrates how to display error source chains in reports. +//! +//! By default, rootcause only displays the immediate error context. This +//! example shows how to enable source chain traversal to display the full error +//! chain, providing better diagnostic information. + +use rootcause::{ + ReportRef, + hooks::{Hooks, context_formatter::ContextFormatterHook}, + markers::{Dynamic, Local, Uncloneable}, + prelude::*, +}; +use rootcause_internals::handlers::{ContextFormattingStyle, ContextHandler, FormattingFunction}; + +// A simple error type that can chain to other errors +#[derive(Debug)] +struct ChainedError { + message: String, + source: Option>, +} + +impl ChainedError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + source: None, + } + } + + fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self { + self.source = Some(Box::new(source)); + self + } +} + +impl std::fmt::Display for ChainedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ChainedError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + if let Some(source) = &self.source { + Some(&**source) + } else { + None + } + } +} + +fn main() { + // Create an error with a source chain + let error = ChainedError::new("request failed").with_source( + ChainedError::new("connection error") + .with_source(ChainedError::new("TLS handshake failed")), + ); + + println!("=== Method 1: Using a ContextHandler ===\n"); + println!("Use this approach when the error type itself should control formatting."); + println!("Best for:"); + println!(" • Library authors defining how their error types are displayed"); + println!(" • Different behavior for different error types"); + println!(" • When the decision is inherent to what the error represents\n"); + + // Define a custom handler that enables source chain following. + // This associates the behavior directly with the ChainedError type. + struct ErrorWithSourcesHandler; + impl ContextHandler for ErrorWithSourcesHandler { + fn source(value: &ChainedError) -> Option<&(dyn std::error::Error + 'static)> { + std::error::Error::source(value) + } + + fn display( + value: &ChainedError, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + std::fmt::Display::fmt(value, formatter) + } + + fn debug( + value: &ChainedError, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + std::fmt::Debug::fmt(value, formatter) + } + + fn preferred_formatting_style( + _value: &ChainedError, + formatting_function: FormattingFunction, + ) -> ContextFormattingStyle { + ContextFormattingStyle { + function: formatting_function, + // Enable source chain traversal + follow_source: true, + // Note: Set follow_source_depth to Some(n) to limit chain depth + // No depth limit (show all) + follow_source_depth: None, + } + } + } + + let report = Report::new_sendsync_custom::(error) + .context("Failed to fetch data"); + println!("{report}"); + + println!("\n=== Method 2: Using a ContextFormatterHook ===\n"); + println!("Use this approach for application-wide configuration of a specific type."); + println!("Best for:"); + println!(" • Configuring third-party error types you don't control"); + println!(" • Environment-based behavior (dev vs production)"); + println!(" • Changing formatting without modifying where errors are created"); + println!(" • Centralizing configuration instead of specifying at each creation site\n"); + + // Install a global hook that enables source chain following for ChainedError. + // Unlike the handler approach, this applies to ALL ChainedError instances + // in your application, even when created with Report::new() instead of + // Report::new_sendsync_custom(). + struct ChainedErrorFormatter; + impl ContextFormatterHook for ChainedErrorFormatter { + fn preferred_context_formatting_style( + &self, + _report: ReportRef<'_, Dynamic, Uncloneable, Local>, + _report_formatting_function: FormattingFunction, + ) -> ContextFormattingStyle { + ContextFormattingStyle { + function: FormattingFunction::Display, + follow_source: true, + follow_source_depth: None, + } + } + } + + Hooks::new() + .context_formatter::(ChainedErrorFormatter) + .install() + .ok(); + + // Create a new error (same structure as before) + let error2 = ChainedError::new("request failed").with_source( + ChainedError::new("connection error") + .with_source(ChainedError::new("TLS handshake failed")), + ); + + // No custom handler needed - the hook applies globally + let report2 = report!(error2).context("Failed to fetch data"); + println!("{report2}"); +} diff --git a/rootcause-backtrace/Cargo.toml b/rootcause-backtrace/Cargo.toml index 5dad417..abe26e8 100644 --- a/rootcause-backtrace/Cargo.toml +++ b/rootcause-backtrace/Cargo.toml @@ -1,16 +1,10 @@ [package] name = "rootcause-backtrace" -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT/Apache-2.0" categories = ["rust-patterns"] -keywords = [ - "error", - "error-handling", - "ergonomic", - "library", - "backtrace", -] +keywords = ["error", "error-handling", "ergonomic", "library", "backtrace"] description = "Backtraces support for the rootcause error reporting library" repository = "https://github.com/rootcause-rs/rootcause" documentation = "https://docs.rs/rootcause-backtrace" @@ -25,4 +19,4 @@ regex = { version = "1.12.2", default-features = false } unicode-ident = "1.0.22" # Internal dependencies -rootcause = { path = "../", version = "=0.11.0" } +rootcause = { path = "../", version = "=0.12.0" } diff --git a/rootcause-internals/Cargo.toml b/rootcause-internals/Cargo.toml index 5fe39c5..f27ce51 100644 --- a/rootcause-internals/Cargo.toml +++ b/rootcause-internals/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rootcause-internals" -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT/Apache-2.0" description = "Internals for the rootcause crate" diff --git a/rootcause-internals/src/handlers.rs b/rootcause-internals/src/handlers.rs index 2ffa737..b64f51c 100644 --- a/rootcause-internals/src/handlers.rs +++ b/rootcause-internals/src/handlers.rs @@ -224,6 +224,8 @@ pub trait ContextHandler: 'static { /// // Use the same formatting as the report /// ContextFormattingStyle { /// function: report_formatting_function, + /// follow_source: false, + /// follow_source_depth: None, /// } /// } /// } @@ -433,12 +435,18 @@ pub trait AttachmentHandler: 'static { /// // Explicitly request debug formatting /// let debug_style = ContextFormattingStyle { /// function: FormattingFunction::Debug, +/// follow_source: false, +/// follow_source_depth: None, /// }; /// ``` #[derive(Copy, Clone, Debug, Default)] pub struct ContextFormattingStyle { /// The preferred formatting function to use pub function: FormattingFunction, + /// Whether to follow the source chain when formatting + pub follow_source: bool, + /// The maximum depth to follow the source chain when formatting + pub follow_source_depth: Option, } /// Formatting preferences for an attachment when displayed in a report. diff --git a/src/compat/anyhow1.rs b/src/compat/anyhow1.rs index e76e6b6..d281ba4 100644 --- a/src/compat/anyhow1.rs +++ b/src/compat/anyhow1.rs @@ -179,6 +179,8 @@ impl ContextHandler for AnyhowHandler { ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } diff --git a/src/compat/boxed_error.rs b/src/compat/boxed_error.rs index fb22ef5..62d5975 100644 --- a/src/compat/boxed_error.rs +++ b/src/compat/boxed_error.rs @@ -168,6 +168,8 @@ impl ContextHandler> for BoxedErrorHandler { ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } @@ -197,6 +199,8 @@ impl ContextHandler> for BoxedErrorHandler { ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } diff --git a/src/compat/error_stack05.rs b/src/compat/error_stack05.rs index 91966ec..516fe98 100644 --- a/src/compat/error_stack05.rs +++ b/src/compat/error_stack05.rs @@ -197,6 +197,8 @@ where ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } diff --git a/src/compat/error_stack06.rs b/src/compat/error_stack06.rs index b40e006..6f1ff1e 100644 --- a/src/compat/error_stack06.rs +++ b/src/compat/error_stack06.rs @@ -190,6 +190,8 @@ where ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } diff --git a/src/compat/eyre06.rs b/src/compat/eyre06.rs index b367a80..b5e1e4d 100644 --- a/src/compat/eyre06.rs +++ b/src/compat/eyre06.rs @@ -179,6 +179,8 @@ impl ContextHandler for EyreHandler { ) -> ContextFormattingStyle { ContextFormattingStyle { function: formatting_function, + follow_source: false, + follow_source_depth: None, } } } diff --git a/src/hooks/builtin_hooks/report_formatter.rs b/src/hooks/builtin_hooks/report_formatter.rs index 3304a6d..edb8d42 100644 --- a/src/hooks/builtin_hooks/report_formatter.rs +++ b/src/hooks/builtin_hooks/report_formatter.rs @@ -215,12 +215,33 @@ pub struct DefaultReportFormatter { /// [`Opaque`]: AttachmentFormattingPlacement::Opaque pub notice_opaque_last_formatting: LineFormatting, - /// Optional separator inserted between attachments and child contexts - pub attachment_child_separator: Option<&'static str>, + /// Optional separator inserted before child contexts + pub pre_child_separator: Option<&'static str>, /// Optional separator inserted between sibling child contexts pub child_child_separator: Option<&'static str>, + /// Formatting for the "Following the error chain" header when it's not the + /// last piece of data for the report node + pub source_chain_header_middle_formatting: NodeConfig, + + /// Formatting for the "Following the error chain" header when it's the + /// last piece of data for the report node + pub source_chain_header_last_formatting: NodeConfig, + + /// Formatting for individual source chain items that are not the last item + pub source_chain_item_middle_formatting: ItemFormatting, + + /// Formatting for the last source chain item + pub source_chain_item_last_formatting: ItemFormatting, + + /// Formatting for the notice when source chain errors are omitted due to + /// depth limit + pub source_chain_omitted_formatting: LineFormatting, + + /// Optional separator between the source chain and attachments/children + pub source_chain_separator: Option<&'static str>, + /// Separator text inserted between multiple reports pub report_report_separator: &'static str, @@ -314,8 +335,36 @@ impl DefaultReportFormatter { notice_see_also_last_formatting: LineFormatting::new(r"|- See ", " below\n"), notice_opaque_middle_formatting: LineFormatting::new("|- ", "\n"), notice_opaque_last_formatting: LineFormatting::new(r"|- ", "\n"), - attachment_child_separator: None, + pre_child_separator: None, child_child_separator: None, + source_chain_header_middle_formatting: NodeConfig::new( + ("| > ", "\n"), + ("| > ", "\n"), + ("| ", "\n"), + ("| ", "\n"), + "| ", + ), + source_chain_header_last_formatting: NodeConfig::new( + (" > ", "\n"), + (" > ", "\n"), + (" ", "\n"), + (" ", "\n"), + " ", + ), + source_chain_item_middle_formatting: ItemFormatting::new( + ("|- ", "\n"), + ("|- ", "\n"), + ("| ", "\n"), + ("| ", "\n"), + ), + source_chain_item_last_formatting: ItemFormatting::new( + (r"|- ", "\n"), + (r"|- ", "\n"), + (" ", "\n"), + (" ", "\n"), + ), + source_chain_omitted_formatting: LineFormatting::new("|- note: ", "\n"), + source_chain_separator: None, report_report_separator: "--\n", report_appendix_separator: "----------------------------------------\n", appendix_appendix_separator: "----------------------------------------\n", @@ -401,8 +450,36 @@ impl DefaultReportFormatter { notice_see_also_last_formatting: LineFormatting::new("╰ See ", " below\n"), notice_opaque_middle_formatting: LineFormatting::new("├ ", "\n"), notice_opaque_last_formatting: LineFormatting::new("╰ ", "\n"), - attachment_child_separator: Some("│\n"), + pre_child_separator: Some("│\n"), child_child_separator: Some("│\n"), + source_chain_header_middle_formatting: NodeConfig::new( + ("│ ╰ ", "\n"), + ("│ ╰ ", "\n"), + ("│ ", "\n"), + ("│ ", "\n"), + "│ ", + ), + source_chain_header_last_formatting: NodeConfig::new( + (" ╰ ", "\n"), + (" ╰ ", "\n"), + (" ", "\n"), + (" ", "\n"), + " ", + ), + source_chain_item_middle_formatting: ItemFormatting::new( + ("├ ", "\n"), + ("├ ", "\n"), + ("│ ", "\n"), + ("│ ", "\n"), + ), + source_chain_item_last_formatting: ItemFormatting::new( + ("╰ ", "\n"), + ("╰ ", "\n"), + (" ", "\n"), + (" ", "\n"), + ), + source_chain_omitted_formatting: LineFormatting::new("│ note: ", "\n"), + source_chain_separator: None, report_report_separator: "━━\n", report_appendix_separator: "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", appendix_appendix_separator: "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", @@ -481,8 +558,36 @@ impl DefaultReportFormatter { notice_see_also_last_formatting: LineFormatting::new("╰ See \x1b[4m", "\x1b[0m below\n"), notice_opaque_middle_formatting: LineFormatting::new("├ ", "\n"), notice_opaque_last_formatting: LineFormatting::new("╰ ", "\n"), - attachment_child_separator: Some("│\n"), + pre_child_separator: Some("│\n"), child_child_separator: Some("│\n"), + source_chain_header_middle_formatting: NodeConfig::new( + ("│ ╰ ", "\n"), + ("│ ╰ ", "\n"), + ("│ ", "\n"), + ("│ ", "\n"), + "│ ", + ), + source_chain_header_last_formatting: NodeConfig::new( + (" ╰ ", "\n"), + (" ╰ ", "\n"), + (" ", "\n"), + (" ", "\n"), + " ", + ), + source_chain_item_middle_formatting: ItemFormatting::new( + ("├ ", "\n"), + ("├ ", "\n"), + ("│ ", "\n"), + ("│ ", "\n"), + ), + source_chain_item_last_formatting: ItemFormatting::new( + ("╰ ", "\n"), + ("╰ ", "\n"), + (" ", "\n"), + (" ", "\n"), + ), + source_chain_omitted_formatting: LineFormatting::new("│ note: ", "\n"), + source_chain_separator: None, report_report_separator: "━━\n", report_appendix_separator: "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", appendix_appendix_separator: "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", @@ -848,15 +953,20 @@ impl<'a, 'b> DefaultFormatterState<'a, 'b> { } else { &self.config.report_node_middle_formatting }; + let context_style = + report.preferred_context_formatting_style(self.report_formatting_function); self.format_node( tmp_value_buffer, formatting, report.format_current_context(), - report - .preferred_context_formatting_style(self.report_formatting_function) - .function, + context_style.function, |this, tmp_value_buffer| { - this.format_node_data(tmp_value_buffer, tmp_attachments_buffer, report) + this.format_node_data( + tmp_value_buffer, + tmp_attachments_buffer, + report, + context_style, + ) }, )?; Ok(()) @@ -867,10 +977,23 @@ impl<'a, 'b> DefaultFormatterState<'a, 'b> { tmp_value_buffer: &mut TmpValueBuffer, tmp_attachments_buffer: &mut TmpAttachmentsBuffer<'a>, report: ReportRef<'a, Dynamic, Uncloneable, Local>, + context_style: rootcause_internals::handlers::ContextFormattingStyle, ) -> fmt::Result { let has_children = !report.children().is_empty(); let has_attachments = !report.attachments().is_empty(); + // Format source chain if enabled + let has_source_chain = if context_style.follow_source { + self.format_source_chain( + tmp_value_buffer, + report, + context_style, + has_attachments || has_children, + )? + } else { + false + }; + let mut opaque_attachment_count = 0; tmp_attachments_buffer.clear(); tmp_attachments_buffer.extend( @@ -927,11 +1050,15 @@ impl<'a, 'b> DefaultFormatterState<'a, 'b> { )?; } - if has_attachments - && has_children - && let Some(attachment_child_separator) = self.config.attachment_child_separator + if has_source_chain + && (has_attachments || has_children) + && let Some(source_chain_separator) = self.config.source_chain_separator { - self.format_with_line_prefix(attachment_child_separator)?; + self.format_with_line_prefix(source_chain_separator)?; + } + + if has_children && let Some(pre_child_separator) = self.config.pre_child_separator { + self.format_with_line_prefix(pre_child_separator)?; } for (report_index, child) in report.children().iter().enumerate() { @@ -1026,6 +1153,98 @@ impl<'a, 'b> DefaultFormatterState<'a, 'b> { Ok(()) } + fn format_source_chain( + &mut self, + tmp_value_buffer: &mut TmpValueBuffer, + report: ReportRef<'a, Dynamic, Uncloneable, Local>, + context_style: rootcause_internals::handlers::ContextFormattingStyle, + has_more_data: bool, + ) -> Result { + // Collect sources up to depth limit + let max_depth = context_style.follow_source_depth.unwrap_or(usize::MAX); + let mut sources = Vec::new(); + let mut source = report.current_context_error_source(); + let mut depth = 0; + + while let Some(err) = source { + if depth >= max_depth { + break; + } + sources.push(err); + source = err.source(); + depth += 1; + } + + if sources.is_empty() { + return Ok(false); + } + + // Check if we have omitted errors + let has_omitted = source.is_some(); + + // Format using format_node to get proper tree structure + let header_formatting = if has_more_data { + &self.config.source_chain_header_middle_formatting + } else { + &self.config.source_chain_header_last_formatting + }; + + self.format_node( + tmp_value_buffer, + header_formatting, + "Following the error chain for the context:", + FormattingFunction::Display, + |this, tmp_value_buffer| { + // Format each source + for (idx, err) in sources.iter().enumerate() { + let is_last_source = idx + 1 == sources.len(); + // Last item in the source chain only if it's the last source AND there's no + // omitted notice + let is_last_in_chain = is_last_source && !has_omitted; + let item_formatting = if is_last_in_chain { + &this.config.source_chain_item_last_formatting + } else { + &this.config.source_chain_item_middle_formatting + }; + + this.format_item( + tmp_value_buffer, + item_formatting, + err, + context_style.function, + )?; + } + + // Count and report omitted errors if we hit depth limit + if has_omitted { + let mut omitted_count = 0; + let mut remaining = source; + while remaining.is_some() { + omitted_count += 1; + remaining = remaining.and_then(|e| e.source()); + } + + // The omitted notice is always the last item in the source chain subtree, + // so we use a modified version with ╰ instead of ├ + this.format_line( + &this + .config + .source_chain_item_last_formatting + .standalone_line, + format_args!( + "note: {} error(s) omitted from source chain.", + omitted_count + ), + )?; + } + + Ok(()) + }, + )?; + + Ok(true) + } + fn format_appendices(&mut self, tmp_value_buffer: &mut TmpValueBuffer) -> fmt::Result { let appendices = core::mem::take(&mut self.appendices);