Skip to main content

rootcause_backtrace/
lib.rs

1#![deny(
2    elided_lifetimes_in_paths,
3    missing_docs,
4    unsafe_code,
5    rustdoc::invalid_rust_codeblocks,
6    rustdoc::broken_intra_doc_links,
7    missing_copy_implementations,
8    unused_doc_comments
9)]
10// Extra checks on nightly
11#![cfg_attr(nightly_extra_checks, feature(rustdoc_missing_doc_code_examples))]
12#![cfg_attr(nightly_extra_checks, forbid(rustdoc::missing_doc_code_examples))]
13
14//! Stack backtrace attachment collector for rootcause error reports.
15//!
16//! This crate provides functionality to automatically capture and attach stack
17//! backtraces to error reports. This is useful for debugging to see the call
18//! stack that led to an error.
19//!
20//! # Quick Start
21//!
22//! ## Using Hooks (Automatic for All Errors)
23//!
24//! Register a backtrace collector as a hook to automatically capture backtraces
25//! for all errors:
26//!
27//! ```
28//! use rootcause::hooks::Hooks;
29//! use rootcause_backtrace::BacktraceCollector;
30//!
31//! // Capture backtraces for all errors
32//! Hooks::new()
33//!     .report_creation_hook(BacktraceCollector::new_from_env())
34//!     .install()
35//!     .expect("failed to install hooks");
36//!
37//! // Now all errors automatically get backtraces!
38//! fn example() -> rootcause::Report {
39//!     rootcause::report!("something went wrong")
40//! }
41//! println!("{}", example().context("additional context"));
42//! ```
43//!
44//! This will print a backtrace similar to the following:
45//! ```text
46//!  ● additional context
47//!  ├ src/main.rs:12
48//!  ├ Backtrace
49//!  │ │ main - /build/src/main.rs:12
50//!  │ │ note: 39 frame(s) omitted. For a complete backtrace, set RUST_BACKTRACE=full.
51//!  │ ╰─
52//!  │
53//!  ● something went wrong
54//!  ├ src/main.rs:10
55//!  ╰ Backtrace
56//!    │ example - /build/src/main.rs:10
57//!    │ main    - /build/src/main.rs:12
58//!    │ note: 40 frame(s) omitted. For a complete backtrace, set RUST_BACKTRACE=full.
59//!    ╰─
60//! ```
61//!
62//! ## Manual Attachment (Per-Error)
63//!
64//! Attach backtraces to specific errors using the extension trait:
65//!
66//! ```
67//! use std::io;
68//!
69//! use rootcause::{Report, report};
70//! use rootcause_backtrace::BacktraceExt;
71//!
72//! fn operation() -> Result<(), Report> {
73//!     Err(report!("operation failed"))
74//! }
75//!
76//! // Attach backtrace to the error in the Result
77//! let result = operation().attach_backtrace();
78//! ```
79//!
80//! # Environment Variables
81//!
82//! - `RUST_BACKTRACE=full` - Disables filtering and shows full paths
83//! - `ROOTCAUSE_BACKTRACE` - Comma-separated options:
84//!   - `leafs` - Only capture backtraces for leaf errors (errors without
85//!     children)
86//!   - `full_paths` - Show full file paths in backtraces
87//!
88//! # Path privacy
89//!
90//! By default, backtrace paths are shortened paths for improved readability,
91//! but this may still expose private file system structure when a path is not
92//! recognized as belonging to a known prefix (e.g., RUST_SRC).
93//!
94//! If exposing private file system paths is a concern, then we recommend using
95//! the `--remap-path-prefix` option of `rustc` to remap source paths to
96//! generic placeholders.
97//!
98//! A good default way to handle this is to set the following environment
99//! variables when building your application for release:
100//!
101//! ```sh
102//! export RUSTFLAGS="--remap-path-prefix=$HOME=/home/user --remap-path-prefix=$PWD=/build"
103//! ```
104//!
105//! # Debugging symbols in release builds
106//!
107//! To ensure that backtraces contain useful symbol and source location
108//! information in release builds, make sure to enable debug symbols in your
109//! `Cargo.toml`:
110//!
111//! ```toml
112//! [profile.release]
113//! strip = false
114//! # You can also set this to "line-tables-only" for smaller binaries
115//! debug = true
116//! ```
117//!
118//! # Filtering
119//!
120//! Control which frames appear in backtraces:
121//!
122//! ```
123//! use rootcause_backtrace::{BacktraceCollector, BacktraceFilter};
124//!
125//! let collector = BacktraceCollector {
126//!     filter: BacktraceFilter {
127//!         skipped_initial_crates: &["rootcause", "rootcause-backtrace"],  // Skip frames from rootcause at start
128//!         skipped_middle_crates: &["tokio"],     // Skip tokio frames in middle
129//!         skipped_final_crates: &["std"],        // Skip std frames at end
130//!         max_entry_count: 15,                   // Limit to 15 frames
131//!         show_full_path: false,                 // Show shortened paths
132//!     },
133//!     capture_backtrace_for_reports_with_children: false,  // Only leaf errors
134//! };
135//! ```
136
137use std::{borrow::Cow, fmt, panic::Location, sync::OnceLock};
138
139use backtrace::BytesOrWideString;
140use rootcause::{
141    Report, ReportMut,
142    handlers::{
143        AttachmentFormattingPlacement, AttachmentFormattingStyle, AttachmentHandler,
144        FormattingFunction,
145    },
146    hooks::report_creation::ReportCreationHook,
147    markers::{self, Dynamic, ObjectMarkerFor},
148    report_attachment::ReportAttachment,
149};
150
151/// Stack backtrace information.
152///
153/// Contains a collection of stack frames representing the call stack
154/// at the point where a report was created.
155///
156/// # Examples
157///
158/// Capture a backtrace manually:
159///
160/// ```
161/// use rootcause_backtrace::{Backtrace, BacktraceFilter};
162///
163/// let backtrace = Backtrace::capture(&BacktraceFilter::DEFAULT);
164/// if let Some(bt) = backtrace {
165///     println!("Captured {} frames", bt.entries.len());
166/// }
167/// ```
168#[derive(Debug, Clone)]
169pub struct Backtrace {
170    /// The entries in the backtrace, ordered from most recent to oldest.
171    pub entries: Vec<BacktraceEntry>,
172    /// Total number of frames that were omitted due to filtering.
173    pub total_omitted_frames: usize,
174}
175
176/// A single entry in a stack backtrace.
177///
178/// # Examples
179///
180/// ```
181/// use rootcause_backtrace::{Backtrace, BacktraceEntry, BacktraceFilter};
182///
183/// if let Some(bt) = Backtrace::capture(&BacktraceFilter::DEFAULT) {
184///     for entry in &bt.entries {
185///         match entry {
186///             BacktraceEntry::Frame(_) => { /* a real call */ }
187///             BacktraceEntry::OmittedFrames { count, skipped_crate } => {
188///                 println!("Omitted {count} frames from {skipped_crate}");
189///             }
190///         }
191///     }
192/// }
193/// ```
194#[derive(Debug, Clone)]
195pub enum BacktraceEntry {
196    /// A normal stack frame.
197    Frame(Frame),
198    /// A group of omitted frames from a specific crate.
199    OmittedFrames {
200        /// Number of omitted frames.
201        count: usize,
202        /// The name of the crate whose frames were omitted.
203        skipped_crate: &'static str,
204    },
205}
206
207/// A single stack frame in a backtrace.
208///
209/// Represents one function call in the call stack, including symbol information
210/// and source location if available.
211///
212/// # Examples
213///
214/// ```
215/// use rootcause_backtrace::{Backtrace, BacktraceEntry, BacktraceFilter};
216///
217/// if let Some(bt) = Backtrace::capture(&BacktraceFilter::DEFAULT) {
218///     for entry in &bt.entries {
219///         if let BacktraceEntry::Frame(frame) = entry {
220///             println!("{}", frame.sym_demangled);
221///         }
222///     }
223/// }
224/// ```
225#[derive(Debug, Clone)]
226pub struct Frame {
227    /// The demangled symbol name for this frame.
228    pub sym_demangled: String,
229    /// File path information for this frame, if available.
230    pub frame_path: Option<FramePath>,
231    /// Line number in the source file, if available.
232    pub lineno: Option<u32>,
233}
234
235/// File path information for a stack frame.
236///
237/// Contains the raw path and processed components for better display
238/// formatting.
239///
240/// # Examples
241///
242/// ```
243/// use rootcause_backtrace::{Backtrace, BacktraceEntry, BacktraceFilter};
244///
245/// if let Some(bt) = Backtrace::capture(&BacktraceFilter::DEFAULT) {
246///     for entry in &bt.entries {
247///         if let BacktraceEntry::Frame(frame) = entry {
248///             if let Some(path) = &frame.frame_path {
249///                 println!("{}", path.raw_path);
250///             }
251///         }
252///     }
253/// }
254/// ```
255#[derive(Debug, Clone)]
256pub struct FramePath {
257    /// The raw file path from the debug information.
258    pub raw_path: String,
259    /// The crate name if detected from the path.
260    pub crate_name: Option<Cow<'static, str>>,
261    /// Common path prefix information for shortening display.
262    pub split_path: Option<FramePrefix>,
263}
264
265/// A common prefix for a frame path.
266///
267/// This struct represents a decomposed file path where a known prefix
268/// has been identified and separated from the rest of the path.
269///
270/// # Examples
271///
272/// ```
273/// use rootcause_backtrace::{Backtrace, BacktraceEntry, BacktraceFilter};
274///
275/// if let Some(bt) = Backtrace::capture(&BacktraceFilter::DEFAULT) {
276///     for entry in &bt.entries {
277///         if let BacktraceEntry::Frame(frame) = entry {
278///             if let Some(prefix) = frame
279///                 .frame_path
280///                 .as_ref()
281///                 .and_then(|p| p.split_path.as_ref())
282///             {
283///                 println!("[{}] {}", prefix.prefix_kind, prefix.suffix);
284///             }
285///         }
286///     }
287/// }
288/// ```
289#[derive(Debug, Clone)]
290pub struct FramePrefix {
291    /// The kind of prefix used to identify this prefix.
292    ///
293    /// Examples: `"RUST_SRC"` for Rust standard library paths,
294    /// `"CARGO"` for Cargo registry crate paths,
295    /// `"ROOTCAUSE"` for rootcause library paths.
296    pub prefix_kind: &'static str,
297    /// The full prefix path that was removed from the original path.
298    ///
299    /// Example: `"/home/user/.cargo/registry/src/index.crates.
300    /// io-1949cf8c6b5b557f"`
301    pub prefix: String,
302    /// The remaining path after the prefix was removed.
303    ///
304    /// Example: `"indexmap-2.12.1/src/map/core/entry.rs"`
305    pub suffix: String,
306}
307
308/// Handler for formatting [`Backtrace`] attachments.
309///
310/// The const generic `SHOW_FULL_PATH` controls whether file paths are shown
311/// in full or with common prefixes shortened.
312///
313/// # Examples
314///
315/// ```
316/// use rootcause::report_attachment::ReportAttachment;
317/// use rootcause_backtrace::{Backtrace, BacktraceFilter, BacktraceHandler};
318///
319/// let backtrace = Backtrace::capture(&BacktraceFilter::DEFAULT)
320///     .unwrap_or(Backtrace { entries: Vec::new(), total_omitted_frames: 0 });
321///
322/// // SHOW_FULL_PATH = false: shortened paths
323/// let _ = ReportAttachment::new_sendsync_custom::<BacktraceHandler<false>>(backtrace);
324/// ```
325#[derive(Copy, Clone)]
326pub struct BacktraceHandler<const SHOW_FULL_PATH: bool>;
327
328fn get_function_name(s: &str) -> &str {
329    let mut word_start = 0usize;
330    let mut word_end = 0usize;
331    let mut angle_nesting_level = 0u64;
332    let mut curly_nesting_level = 0u64;
333    let mut potential_function_arrow = false;
334    let mut inside_word = false;
335
336    for (i, c) in s.char_indices() {
337        if curly_nesting_level == 0 && angle_nesting_level == 0 {
338            if !inside_word && unicode_ident::is_xid_start(c) {
339                word_start = i;
340                inside_word = true;
341            } else if inside_word && !unicode_ident::is_xid_continue(c) {
342                word_end = i;
343                inside_word = false;
344            }
345        }
346
347        let was_potential_function_arrow = potential_function_arrow;
348        potential_function_arrow = c == '-';
349
350        if c == '<' {
351            angle_nesting_level = angle_nesting_level.saturating_add(1);
352        } else if c == '>' && !was_potential_function_arrow {
353            angle_nesting_level = angle_nesting_level.saturating_sub(1);
354        } else if c == '{' {
355            curly_nesting_level = curly_nesting_level.saturating_add(1);
356            if !inside_word && curly_nesting_level == 1 && angle_nesting_level == 0 {
357                word_start = i;
358                inside_word = true;
359            }
360        } else if c == '}' {
361            curly_nesting_level = curly_nesting_level.saturating_sub(1);
362            if inside_word && curly_nesting_level == 0 {
363                word_end = i + 1;
364                inside_word = false;
365            }
366        }
367    }
368
369    if word_start < word_end {
370        &s[word_start..word_end]
371    } else {
372        // We started at word start but never found an end; return rest of string
373        &s[word_start..]
374    }
375}
376
377impl<const SHOW_FULL_PATH: bool> AttachmentHandler<Backtrace> for BacktraceHandler<SHOW_FULL_PATH> {
378    fn display(value: &Backtrace, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        const MAX_UNWRAPPED_SYM_LENGTH: usize = 25;
380        let mut max_seen_length = 0;
381        for entry in &value.entries {
382            if let BacktraceEntry::Frame(frame) = entry {
383                let sym = get_function_name(&frame.sym_demangled);
384                if sym.len() <= MAX_UNWRAPPED_SYM_LENGTH && sym.len() > max_seen_length {
385                    max_seen_length = sym.len();
386                }
387            }
388        }
389
390        for entry in &value.entries {
391            match entry {
392                BacktraceEntry::OmittedFrames {
393                    count,
394                    skipped_crate,
395                } => {
396                    writeln!(
397                        f,
398                        "... omitted {count} frame(s) from crate '{skipped_crate}' ..."
399                    )?;
400                    continue;
401                }
402                BacktraceEntry::Frame(frame) => {
403                    let sym = get_function_name(&frame.sym_demangled);
404
405                    if sym.len() <= MAX_UNWRAPPED_SYM_LENGTH {
406                        write!(f, "{:<max_seen_length$} - ", sym)?;
407                    } else {
408                        write!(f, "{sym}\n   - ")?;
409                    }
410
411                    if let Some(path) = &frame.frame_path {
412                        if SHOW_FULL_PATH {
413                            write!(f, "{}", path.raw_path)?;
414                        } else if let Some(split_path) = &path.split_path {
415                            write!(f, "[..]/{}", split_path.suffix)?;
416                        } else {
417                            write!(f, "{}", path.raw_path)?;
418                        }
419
420                        if let Some(lineno) = frame.lineno {
421                            write!(f, ":{lineno}")?;
422                        }
423                    }
424                    writeln!(f)?;
425                }
426            }
427        }
428
429        if value.total_omitted_frames > 0 {
430            writeln!(
431                f,
432                "note: {} frame(s) omitted. For a complete backtrace, set RUST_BACKTRACE=full.",
433                value.total_omitted_frames
434            )?;
435        }
436
437        Ok(())
438    }
439
440    fn debug(value: &Backtrace, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
441        std::fmt::Debug::fmt(value, formatter)
442    }
443
444    fn preferred_formatting_style(
445        backtrace: &Backtrace,
446        _report_formatting_function: FormattingFunction,
447    ) -> AttachmentFormattingStyle {
448        AttachmentFormattingStyle {
449            placement: if backtrace.entries.is_empty() {
450                AttachmentFormattingPlacement::Hidden
451            } else {
452                AttachmentFormattingPlacement::InlineWithHeader {
453                    header: "Backtrace",
454                }
455            },
456            // No reason to every print the Backtrace in the report
457            // as anything other than display.
458            function: FormattingFunction::Display,
459            priority: 10,
460        }
461    }
462}
463
464/// Attachment collector for capturing stack backtraces.
465///
466/// When registered as a report creation hook, this collector automatically
467/// captures the current stack backtrace and attaches it as a [`Backtrace`]
468/// attachment.
469///
470/// # Examples
471///
472/// Basic usage with default settings:
473///
474/// ```
475/// use rootcause::hooks::Hooks;
476/// use rootcause_backtrace::BacktraceCollector;
477///
478/// Hooks::new()
479///     .report_creation_hook(BacktraceCollector::new_from_env())
480///     .install()
481///     .expect("failed to install hooks");
482/// ```
483///
484/// Custom configuration:
485///
486/// ```
487/// use rootcause::hooks::Hooks;
488/// use rootcause_backtrace::{BacktraceCollector, BacktraceFilter};
489///
490/// let collector = BacktraceCollector {
491///     filter: BacktraceFilter {
492///         skipped_initial_crates: &[],
493///         skipped_middle_crates: &[],
494///         skipped_final_crates: &[],
495///         max_entry_count: 30,
496///         show_full_path: true,
497///     },
498///     capture_backtrace_for_reports_with_children: true,
499/// };
500///
501/// Hooks::new()
502///     .report_creation_hook(collector)
503///     .install()
504///     .expect("failed to install hooks");
505/// ```
506#[derive(Copy, Clone)]
507pub struct BacktraceCollector {
508    /// Configuration for filtering and formatting backtrace frames.
509    pub filter: BacktraceFilter,
510
511    /// If set to true, a backtrace is captured for every report creation,
512    /// including reports that have child reports (i.e., reports created with
513    /// existing children). If set to false, a backtrace is captured only
514    /// for reports created without any children. Reports created without
515    /// children always receive a backtrace regardless of this setting.
516    pub capture_backtrace_for_reports_with_children: bool,
517}
518
519/// Configuration for filtering frames from certain crates in a backtrace.
520///
521/// # Examples
522///
523/// Use default filtering:
524///
525/// ```
526/// use rootcause_backtrace::BacktraceFilter;
527///
528/// let filter = BacktraceFilter::DEFAULT;
529/// ```
530///
531/// Custom filtering to focus on application code:
532///
533/// ```
534/// use rootcause_backtrace::BacktraceFilter;
535///
536/// let filter = BacktraceFilter {
537///     // Hide rootcause crate frames at the start
538///     skipped_initial_crates: &["rootcause", "rootcause-backtrace"],
539///     // Hide framework frames in the middle
540///     skipped_middle_crates: &["tokio", "hyper", "tower"],
541///     // Hide runtime frames at the end
542///     skipped_final_crates: &["std", "tokio"],
543///     // Show only the most relevant 10 frames
544///     max_entry_count: 10,
545///     // Show shortened paths
546///     show_full_path: false,
547/// };
548/// ```
549#[derive(Copy, Clone, Debug)]
550pub struct BacktraceFilter {
551    /// Set of crate names whose frames should be hidden when they appear
552    /// at the beginning of a backtrace.
553    pub skipped_initial_crates: &'static [&'static str],
554    /// Set of crate names whose frames should be hidden when they appear
555    /// in the middle of a backtrace.
556    pub skipped_middle_crates: &'static [&'static str],
557    /// Set of crate names whose frames should be hidden when they appear
558    /// at the end of a backtrace.
559    pub skipped_final_crates: &'static [&'static str],
560    /// Maximum number of entries to include in the backtrace.
561    pub max_entry_count: usize,
562    /// Whether to show full file paths in the backtrace frames.
563    pub show_full_path: bool,
564}
565
566impl BacktraceFilter {
567    /// Default backtrace filter settings.
568    pub const DEFAULT: Self = Self {
569        skipped_initial_crates: &[
570            "backtrace",
571            "rootcause",
572            "rootcause-backtrace",
573            "core",
574            "std",
575            "alloc",
576        ],
577        skipped_middle_crates: &["std", "core", "alloc", "tokio"],
578        skipped_final_crates: &["std", "core", "alloc", "tokio"],
579        max_entry_count: 20,
580        show_full_path: false,
581    };
582}
583
584impl Default for BacktraceFilter {
585    fn default() -> Self {
586        Self::DEFAULT
587    }
588}
589
590#[derive(Debug)]
591struct RootcauseEnvOptions {
592    rust_backtrace_full: bool,
593    backtrace_leafs_only: bool,
594    show_full_path: bool,
595}
596
597impl RootcauseEnvOptions {
598    fn get() -> &'static Self {
599        static ROOTCAUSE_FLAGS: OnceLock<RootcauseEnvOptions> = OnceLock::new();
600
601        ROOTCAUSE_FLAGS.get_or_init(|| {
602            let rust_backtrace_full =
603                std::env::var_os("RUST_BACKTRACE").is_some_and(|var| var == "full");
604            let mut show_full_path = rust_backtrace_full;
605            let mut backtrace_leafs_only = false;
606            if let Some(var) = std::env::var_os("ROOTCAUSE_BACKTRACE") {
607                for v in var.to_string_lossy().split(',') {
608                    if v.eq_ignore_ascii_case("leafs") {
609                        backtrace_leafs_only = true;
610                    } else if v.eq_ignore_ascii_case("full_paths") {
611                        show_full_path = true;
612                    }
613                }
614            }
615            RootcauseEnvOptions {
616                rust_backtrace_full,
617                backtrace_leafs_only,
618                show_full_path,
619            }
620        })
621    }
622}
623
624impl BacktraceCollector {
625    /// Creates a new [`BacktraceCollector`] with default settings.
626    ///
627    /// Configuration is controlled by environment variables. By default,
628    /// filtering is applied and backtraces are only captured for reports
629    /// without children.
630    ///
631    /// # Environment Variables
632    ///
633    /// - `RUST_BACKTRACE=full` - Disables all filtering and shows all frames
634    /// - `ROOTCAUSE_BACKTRACE` - Comma-separated options:
635    ///   - `leafs` - Only capture backtraces for leaf errors (errors without
636    ///     children)
637    ///   - `full_paths` - Show full file paths instead of shortened paths
638    ///
639    /// The `RUST_BACKTRACE=full` setting implies `full_paths` unless explicitly
640    /// overridden by `ROOTCAUSE_BACKTRACE`.
641    ///
642    /// # Examples
643    ///
644    /// ```
645    /// use rootcause::hooks::Hooks;
646    /// use rootcause_backtrace::BacktraceCollector;
647    ///
648    /// // Respects RUST_BACKTRACE and ROOTCAUSE_BACKTRACE environment variables
649    /// Hooks::new()
650    ///     .report_creation_hook(BacktraceCollector::new_from_env())
651    ///     .install()
652    ///     .expect("failed to install hooks");
653    /// ```
654    pub fn new_from_env() -> Self {
655        let env_options = RootcauseEnvOptions::get();
656        let capture_backtrace_for_reports_with_children = !env_options.backtrace_leafs_only;
657
658        Self {
659            filter: if env_options.rust_backtrace_full {
660                BacktraceFilter {
661                    skipped_initial_crates: &[],
662                    skipped_middle_crates: &[],
663                    skipped_final_crates: &[],
664                    max_entry_count: usize::MAX,
665                    show_full_path: env_options.show_full_path,
666                }
667            } else {
668                BacktraceFilter {
669                    show_full_path: env_options.show_full_path,
670                    ..BacktraceFilter::DEFAULT
671                }
672            },
673            capture_backtrace_for_reports_with_children,
674        }
675    }
676}
677
678impl ReportCreationHook for BacktraceCollector {
679    fn on_local_creation(&self, mut report: ReportMut<'_, Dynamic, markers::Local>) {
680        let do_capture =
681            self.capture_backtrace_for_reports_with_children || report.children().is_empty();
682        if do_capture && let Some(backtrace) = Backtrace::capture(&self.filter) {
683            let attachment = if self.filter.show_full_path {
684                ReportAttachment::new_custom::<BacktraceHandler<true>>(backtrace)
685            } else {
686                ReportAttachment::new_custom::<BacktraceHandler<false>>(backtrace)
687            };
688            report.attachments_mut().push(attachment.into_dynamic());
689        }
690    }
691
692    fn on_sendsync_creation(&self, mut report: ReportMut<'_, Dynamic, markers::SendSync>) {
693        let do_capture =
694            self.capture_backtrace_for_reports_with_children || report.children().is_empty();
695        if do_capture && let Some(backtrace) = Backtrace::capture(&self.filter) {
696            let attachment = if self.filter.show_full_path {
697                ReportAttachment::new_custom::<BacktraceHandler<true>>(backtrace)
698            } else {
699                ReportAttachment::new_custom::<BacktraceHandler<false>>(backtrace)
700            };
701            report.attachments_mut().push(attachment.into_dynamic());
702        }
703    }
704}
705
706const fn get_rootcause_backtrace_matcher(
707    location: &'static Location<'static>,
708) -> Option<(&'static str, usize)> {
709    let file = location.file();
710
711    let Some(prefix_len) = file.len().checked_sub("/src/lib.rs".len()) else {
712        return None;
713    };
714
715    let (prefix, suffix) = file.split_at(prefix_len);
716    // Detect the path separator from the actual Location::caller() path rather
717    // than from a build-script-generated value. In cross-compilation environments
718    // the build script and compiler may run on different platforms, so the build
719    // script's idea of the host separator may not match what Location::caller()
720    // produces.
721    let sep = suffix.as_bytes()[0];
722    if sep == b'/' {
723        assert!(suffix.eq_ignore_ascii_case("/src/lib.rs"));
724    } else {
725        assert!(suffix.eq_ignore_ascii_case(r#"\src\lib.rs"#));
726    }
727
728    let (matcher_prefix, _) = file.split_at(prefix_len + 4);
729
730    let mut splitter_prefix = prefix;
731    while !splitter_prefix.is_empty() {
732        let (new_prefix, last_char) = splitter_prefix.split_at(splitter_prefix.len() - 1);
733        splitter_prefix = new_prefix;
734        if last_char.as_bytes()[0] == sep {
735            break;
736        }
737    }
738
739    Some((matcher_prefix, splitter_prefix.len()))
740}
741
742const ROOTCAUSE_BACKTRACE_MATCHER: Option<(&str, usize)> =
743    get_rootcause_backtrace_matcher(Location::caller());
744const ROOTCAUSE_MATCHER: Option<(&str, usize)> =
745    get_rootcause_backtrace_matcher(rootcause::__private::ROOTCAUSE_LOCATION);
746
747impl Backtrace {
748    /// Captures the current stack backtrace, applying optional filtering.
749    ///
750    /// Returns `None` if a backtrace could not be captured.
751    ///
752    /// # Examples
753    ///
754    /// ```
755    /// use rootcause_backtrace::{Backtrace, BacktraceFilter};
756    ///
757    /// if let Some(bt) = Backtrace::capture(&BacktraceFilter::DEFAULT) {
758    ///     println!("Captured {} frames", bt.entries.len());
759    /// }
760    /// ```
761    pub fn capture(filter: &BacktraceFilter) -> Option<Self> {
762        let mut initial_filtering = !filter.skipped_initial_crates.is_empty();
763        let mut entries: Vec<BacktraceEntry> = Vec::new();
764        let mut total_omitted_frames = 0;
765
766        let mut delayed_omitted_frame: Option<Frame> = None;
767        let mut currently_omitted_crate_name: Option<&'static str> = None;
768        let mut currently_omitted_frames = 0;
769
770        backtrace::trace(|frame| {
771            backtrace::resolve_frame(frame, |symbol| {
772                // Don't consider frames without symbol names or filenames.
773                let (Some(sym), Some(filename_raw)) = (symbol.name(), symbol.filename_raw()) else {
774                    return;
775                };
776
777                if entries.len() >= filter.max_entry_count {
778                    total_omitted_frames += 1;
779                    return;
780                }
781
782                let frame_path = FramePath::new(filename_raw);
783
784                if initial_filtering {
785                    if let Some(cur_crate_name) = &frame_path.crate_name
786                        && filter.skipped_initial_crates.contains(&&**cur_crate_name)
787                    {
788                        total_omitted_frames += 1;
789                        return;
790                    } else {
791                        initial_filtering = false;
792                    }
793                }
794
795                if let Some(cur_crate_name) = &frame_path.crate_name
796                    && let Some(currently_omitted_crate_name) = &currently_omitted_crate_name
797                    && cur_crate_name == currently_omitted_crate_name
798                {
799                    delayed_omitted_frame = None;
800                    currently_omitted_frames += 1;
801                    total_omitted_frames += 1;
802                    return;
803                }
804
805                if let Some(currently_omitted_crate_name) = currently_omitted_crate_name.take() {
806                    if let Some(delayed_frame) = delayed_omitted_frame.take() {
807                        entries.push(BacktraceEntry::Frame(delayed_frame));
808                    } else {
809                        entries.push(BacktraceEntry::OmittedFrames {
810                            count: currently_omitted_frames,
811                            skipped_crate: currently_omitted_crate_name,
812                        });
813                    }
814                    currently_omitted_frames = 0;
815                }
816
817                if let Some(cur_crate_name) = &frame_path.crate_name
818                    && let Some(skipped_crate) = filter
819                        .skipped_middle_crates
820                        .iter()
821                        .find(|&crate_name| crate_name == cur_crate_name)
822                {
823                    currently_omitted_crate_name = Some(skipped_crate);
824                    currently_omitted_frames = 1;
825                    total_omitted_frames += 1;
826                    delayed_omitted_frame = Some(Frame {
827                        sym_demangled: format!("{sym:#}"),
828                        frame_path: Some(frame_path),
829                        lineno: symbol.lineno(),
830                    });
831                    return;
832                }
833
834                entries.push(BacktraceEntry::Frame(Frame {
835                    sym_demangled: format!("{sym:#}"),
836                    frame_path: Some(frame_path),
837                    lineno: symbol.lineno(),
838                }));
839            });
840
841            true
842        });
843
844        if let Some(currently_omitted_crate_name) = currently_omitted_crate_name.take() {
845            if let Some(delayed_frame) = delayed_omitted_frame.take() {
846                entries.push(BacktraceEntry::Frame(delayed_frame));
847            } else {
848                entries.push(BacktraceEntry::OmittedFrames {
849                    count: currently_omitted_frames,
850                    skipped_crate: currently_omitted_crate_name,
851                });
852            }
853        }
854
855        while let Some(last) = entries.last() {
856            match last {
857                BacktraceEntry::Frame(frame) => {
858                    let mut skip = false;
859                    if let Some(frame_path) = &frame.frame_path
860                        && let Some(crate_name) = &frame_path.crate_name
861                        && filter.skipped_final_crates.contains(&&**crate_name)
862                    {
863                        skip = true;
864                    } else if frame.sym_demangled == "__libc_start_call_main"
865                        || frame.sym_demangled == "__libc_start_main_impl"
866                    {
867                        skip = true;
868                    } else if let Some(frame_path) = &frame.frame_path
869                        && frame.sym_demangled == "_start"
870                        && frame_path.raw_path.contains("zig/libc/glibc")
871                    {
872                        skip = true;
873                    }
874
875                    if skip {
876                        total_omitted_frames += 1;
877                        entries.pop();
878                    } else {
879                        break;
880                    }
881                }
882                BacktraceEntry::OmittedFrames {
883                    skipped_crate,
884                    count,
885                } => {
886                    if filter.skipped_final_crates.contains(skipped_crate) {
887                        total_omitted_frames += count;
888                        entries.pop();
889                    } else {
890                        break;
891                    }
892                }
893            }
894        }
895
896        if entries.is_empty() && total_omitted_frames == 0 {
897            None
898        } else {
899            Some(Self {
900                entries,
901                total_omitted_frames,
902            })
903        }
904    }
905}
906
907/// Matches Rust standard library source paths and returns the crate name and byte offset.
908///
909/// Recognised paths:
910/// - `/lib/rustlib/src/rust/library/{std|core|alloc}/src/…`
911/// - `^/rustc/{40-hex-char hash}/library/{std|core|alloc}/src/…`
912fn match_std_library_path(path: &str) -> Option<(&'static str, usize)> {
913    const STD_CRATES: [&str; 3] = ["std", "core", "alloc"];
914
915    let (prefix, after_library) = path.split_once("/library/")?;
916
917    // The prefix must end with "/lib/rustlib/src/rust" or *be* "/rustc/{40 hex chars}"
918    let valid_prefix = prefix.ends_with("/lib/rustlib/src/rust")
919        || prefix
920            .strip_prefix("/rustc/")
921            .is_some_and(|hash| hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()));
922
923    if !valid_prefix {
924        return None;
925    }
926
927    // after_library is "{std|core|alloc}/src/…"; split on "/src/" to isolate the crate name.
928    let (crate_name, _) = after_library.split_once("/src/")?;
929    let crate_name = STD_CRATES
930        .iter()
931        .copied()
932        .find(|&name| name == crate_name)?;
933
934    let crate_start = prefix.len() + "/library/".len();
935    Some((crate_name, crate_start))
936}
937
938/// Matches Cargo registry source paths and returns the crate name and its byte offset.
939///
940/// Recognised paths:
941/// - `/.cargo/registry/src/{index}-{16-hex-char hash}/{crate}-{version}/src/…`
942fn match_cargo_registry_path(path: &str) -> Option<(&str, usize)> {
943    let (before_registry, after_registry) = path.split_once("/registry/src/")?;
944
945    // Consume the "{index}-{16-hex-char}" directory component.
946    let (index_hash, after_index) = after_registry.split_once('/')?;
947    let (_, hash) = index_hash.rsplit_once('-')?;
948    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
949        return None;
950    }
951
952    // after_index is "{crate}-{version}/src/…"; split on "/src/" to isolate "{crate}-{version}".
953    let (crate_version, _) = after_index.split_once("/src/")?;
954
955    // create names + version numbers can be really silly. However crate names can't contain dots
956    // So find the first dot and split (crate-name-0.1.0-alpha into crate-name-0)
957    // Then right split on dash (crate-name-0 to crate-name)
958    let (crate_name, _) = crate_version.split_once('.')?;
959    let (crate_name, _) = crate_name.rsplit_once('-')?;
960
961    let crate_start_abs = before_registry.len() + "/registry/src/".len() + index_hash.len() + 1;
962    Some((crate_name, crate_start_abs))
963}
964
965impl FramePath {
966    fn new(path: BytesOrWideString<'_>) -> Self {
967        let path_str = path.to_string();
968
969        if let Some((crate_name, crate_start)) = match_std_library_path(&path_str) {
970            let raw_path = path.to_str_lossy().into_owned();
971            let (prefix, suffix) = (&path_str[..crate_start - 1], &path_str[crate_start..]);
972
973            Self {
974                raw_path,
975                split_path: Some(FramePrefix {
976                    prefix_kind: "RUST_SRC",
977                    prefix: prefix.to_string(),
978                    suffix: suffix.to_string(),
979                }),
980                crate_name: Some(crate_name.to_string().into()),
981            }
982        } else if let Some((crate_name, crate_start)) = match_cargo_registry_path(&path_str) {
983            let raw_path = path.to_str_lossy().into_owned();
984            let (prefix, suffix) = (&path_str[..crate_start - 1], &path_str[crate_start..]);
985
986            Self {
987                raw_path,
988                crate_name: Some(crate_name.to_string().into()),
989                split_path: Some(FramePrefix {
990                    prefix_kind: "CARGO",
991                    prefix: prefix.to_string(),
992                    suffix: suffix.to_string(),
993                }),
994            }
995        } else if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) =
996            ROOTCAUSE_MATCHER
997            && path_str.starts_with(rootcause_matcher_prefix)
998        {
999            let raw_path = path.to_str_lossy().into_owned();
1000            let (prefix, suffix) = (
1001                &path_str[..rootcause_splitter_prefix_len],
1002                &path_str[rootcause_splitter_prefix_len + 1..],
1003            );
1004            Self {
1005                raw_path,
1006                split_path: Some(FramePrefix {
1007                    prefix_kind: "ROOTCAUSE",
1008                    prefix: prefix.to_string(),
1009                    suffix: suffix.to_string(),
1010                }),
1011                crate_name: Some(Cow::Borrowed("rootcause")),
1012            }
1013        } else if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) =
1014            ROOTCAUSE_BACKTRACE_MATCHER
1015            && path_str.starts_with(rootcause_matcher_prefix)
1016        {
1017            let raw_path = path.to_str_lossy().into_owned();
1018            let (prefix, suffix) = (
1019                &path_str[..rootcause_splitter_prefix_len],
1020                &path_str[rootcause_splitter_prefix_len + 1..],
1021            );
1022            Self {
1023                raw_path,
1024                split_path: Some(FramePrefix {
1025                    prefix_kind: "ROOTCAUSE",
1026                    prefix: prefix.to_string(),
1027                    suffix: suffix.to_string(),
1028                }),
1029                crate_name: Some(Cow::Borrowed("rootcause-backtrace")),
1030            }
1031        } else {
1032            let raw_path = path.to_str_lossy().into_owned();
1033            Self {
1034                raw_path,
1035                crate_name: None,
1036                split_path: None,
1037            }
1038        }
1039    }
1040}
1041
1042/// Extension trait for attaching backtraces to reports.
1043///
1044/// This trait provides methods to easily attach a captured backtrace to a
1045/// report or to the error contained within a `Result`.
1046///
1047/// # Examples
1048///
1049/// Attach backtrace to a report:
1050///
1051/// ```
1052/// use std::io;
1053///
1054/// use rootcause::report;
1055/// use rootcause_backtrace::BacktraceExt;
1056///
1057/// let report = report!(io::Error::other("An error occurred")).attach_backtrace();
1058/// ```
1059///
1060/// Attach backtrace to a `Result`:
1061///
1062/// ```
1063/// use std::io;
1064///
1065/// use rootcause::{Report, report};
1066/// use rootcause_backtrace::BacktraceExt;
1067///
1068/// fn might_fail() -> Result<(), Report> {
1069///     Err(report!(io::Error::other("operation failed")).into_dynamic())
1070/// }
1071///
1072/// let result = might_fail().attach_backtrace();
1073/// ```
1074///
1075/// Use a custom filter:
1076///
1077/// ```
1078/// use std::io;
1079///
1080/// use rootcause::report;
1081/// use rootcause_backtrace::{BacktraceExt, BacktraceFilter};
1082///
1083/// let filter = BacktraceFilter {
1084///     skipped_initial_crates: &[],
1085///     skipped_middle_crates: &[],
1086///     skipped_final_crates: &[],
1087///     max_entry_count: 50,
1088///     show_full_path: true,
1089/// };
1090///
1091/// let report = report!(io::Error::other("detailed error")).attach_backtrace_with_filter(&filter);
1092/// ```
1093pub trait BacktraceExt: Sized {
1094    /// Attaches a captured backtrace to the report using the default filter.
1095    ///
1096    /// # Examples
1097    ///
1098    /// ```
1099    /// use std::io;
1100    ///
1101    /// use rootcause::report;
1102    /// use rootcause_backtrace::BacktraceExt;
1103    ///
1104    /// let report = report!(io::Error::other("error")).attach_backtrace();
1105    /// ```
1106    fn attach_backtrace(self) -> Self {
1107        self.attach_backtrace_with_filter(&BacktraceFilter::DEFAULT)
1108    }
1109
1110    /// Attaches a captured backtrace to the report using the specified filter.
1111    ///
1112    /// # Examples
1113    ///
1114    /// ```
1115    /// use std::io;
1116    ///
1117    /// use rootcause::report;
1118    /// use rootcause_backtrace::{BacktraceExt, BacktraceFilter};
1119    ///
1120    /// let filter = BacktraceFilter {
1121    ///     max_entry_count: 10,
1122    ///     ..BacktraceFilter::DEFAULT
1123    /// };
1124    ///
1125    /// let report = report!(io::Error::other("error")).attach_backtrace_with_filter(&filter);
1126    /// ```
1127    fn attach_backtrace_with_filter(self, filter: &BacktraceFilter) -> Self;
1128}
1129
1130impl<C: ?Sized, T> BacktraceExt for Report<C, markers::Mutable, T>
1131where
1132    Backtrace: ObjectMarkerFor<T>,
1133{
1134    fn attach_backtrace_with_filter(mut self, filter: &BacktraceFilter) -> Self {
1135        if let Some(backtrace) = Backtrace::capture(filter) {
1136            if filter.show_full_path {
1137                self = self.attach_custom::<BacktraceHandler<true>, _>(backtrace);
1138            } else {
1139                self = self.attach_custom::<BacktraceHandler<false>, _>(backtrace);
1140            }
1141        }
1142        self
1143    }
1144}
1145
1146impl<C: ?Sized, V, T> BacktraceExt for Result<V, Report<C, markers::Mutable, T>>
1147where
1148    Backtrace: ObjectMarkerFor<T>,
1149{
1150    fn attach_backtrace_with_filter(self, filter: &BacktraceFilter) -> Self {
1151        match self {
1152            Ok(v) => Ok(v),
1153            Err(report) => Err(report.attach_backtrace_with_filter(filter)),
1154        }
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161
1162    // ── match_std_library_path ────────────────────────────────────────────────
1163
1164    #[test]
1165    fn std_path_rustlib_std() {
1166        let path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/io/mod.rs";
1167        let (name, pos) = match_std_library_path(path).expect("should match");
1168        assert_eq!(name, "std");
1169        assert_eq!(&path[pos..], "std/src/io/mod.rs");
1170    }
1171
1172    #[test]
1173    fn std_path_rustlib_core() {
1174        let path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/option.rs";
1175        let (name, pos) = match_std_library_path(path).expect("should match");
1176        assert_eq!(name, "core");
1177        assert_eq!(&path[pos..], "core/src/option.rs");
1178    }
1179
1180    #[test]
1181    fn std_path_rustlib_alloc() {
1182        let path = "/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs";
1183        let (name, pos) = match_std_library_path(path).expect("should match");
1184        assert_eq!(name, "alloc");
1185        assert_eq!(&path[pos..], "alloc/src/vec/mod.rs");
1186    }
1187
1188    #[test]
1189    fn std_path_rustc_hash() {
1190        let hash = "a".repeat(40);
1191        let path = format!("/rustc/{hash}/library/std/src/panicking.rs");
1192        let (name, pos) = match_std_library_path(&path).expect("should match");
1193        assert_eq!(name, "std");
1194        assert_eq!(&path[pos..], "std/src/panicking.rs");
1195    }
1196
1197    #[test]
1198    fn std_path_no_match_unknown_crate() {
1199        let path = "/lib/rustlib/src/rust/library/unknown/src/lib.rs";
1200        assert!(match_std_library_path(path).is_none());
1201    }
1202
1203    #[test]
1204    fn std_path_no_match_rustc_hash_too_short() {
1205        let short_hash = "a".repeat(39);
1206        let path = format!("/rustc/{short_hash}/library/std/src/lib.rs");
1207        assert!(match_std_library_path(&path).is_none());
1208    }
1209
1210    #[test]
1211    fn std_path_no_match_rustc_hash_not_at_root() {
1212        let hash = "a".repeat(40);
1213        let path = format!("/prefix/rustc/{hash}/library/std/src/lib.rs");
1214        assert!(match_std_library_path(&path).is_none());
1215    }
1216
1217    #[test]
1218    fn std_path_no_match_rustc_hash_non_hex() {
1219        let hash = "z".repeat(40);
1220        let path = format!("/rustc/{hash}/library/std/src/lib.rs");
1221        assert!(match_std_library_path(&path).is_none());
1222    }
1223
1224    #[test]
1225    fn std_path_no_match_missing_src_segment() {
1226        let path = "/lib/rustlib/src/rust/library/std/nosrc/io.rs";
1227        assert!(match_std_library_path(path).is_none());
1228    }
1229
1230    #[test]
1231    fn std_path_no_match_cargo_registry_path() {
1232        // A cargo registry path should not match the std matcher.
1233        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.210/src/lib.rs";
1234        assert!(match_std_library_path(path).is_none());
1235    }
1236
1237    // ── match_cargo_registry_path ─────────────────────────────────────────────
1238
1239    #[test]
1240    fn cargo_path_simple_crate() {
1241        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.210/src/lib.rs";
1242        let (name, pos) = match_cargo_registry_path(path).expect("should match");
1243        assert_eq!(name, "serde");
1244        assert_eq!(&path[pos..], "serde-1.0.210/src/lib.rs");
1245    }
1246
1247    #[test]
1248    fn cargo_path_hyphenated_crate_name() {
1249        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/my-cool-crate-0.3.1/src/lib.rs";
1250        let (name, pos) = match_cargo_registry_path(path).expect("should match");
1251        assert_eq!(name, "my-cool-crate");
1252        assert_eq!(&path[pos..], "my-cool-crate-0.3.1/src/lib.rs");
1253    }
1254
1255    #[test]
1256    fn cargo_path_prerelease_version() {
1257        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cratename-1.0.0-2beta/src/lib.rs";
1258        let (name, pos) = match_cargo_registry_path(path).expect("should match");
1259        assert_eq!(name, "cratename");
1260        assert_eq!(&path[pos..], "cratename-1.0.0-2beta/src/lib.rs");
1261    }
1262
1263    #[test]
1264    fn cargo_path_no_match_hash_too_short() {
1265        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba1500/serde-1.0.210/src/lib.rs";
1266        assert!(match_cargo_registry_path(path).is_none());
1267    }
1268
1269    #[test]
1270    fn cargo_path_no_match_hash_non_hex() {
1271        let path = "/home/user/.cargo/registry/src/index.crates.io-zzzzzzzzzzzzzzzz/serde-1.0.210/src/lib.rs";
1272        assert!(match_cargo_registry_path(path).is_none());
1273    }
1274
1275    #[test]
1276    fn cargo_path_no_match_missing_src_segment() {
1277        let path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.210/nosrc/lib.rs";
1278        assert!(match_cargo_registry_path(path).is_none());
1279    }
1280
1281    #[test]
1282    fn cargo_path_no_match_no_version() {
1283        let path =
1284            "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde/src/lib.rs";
1285        assert!(match_cargo_registry_path(path).is_none());
1286    }
1287
1288    #[test]
1289    fn cargo_path_no_match_std_library_path() {
1290        let path = "/lib/rustlib/src/rust/library/std/src/io/mod.rs";
1291        assert!(match_cargo_registry_path(path).is_none());
1292    }
1293}