rtb_error/hook.rs
1//! Hook installation helpers.
2//!
3//! `miette` stores its error hook in a process-global [`OnceLock`], which
4//! means `miette::set_hook` succeeds only on the first call and refuses
5//! subsequent attempts with `InstallError`. To give callers mutable,
6//! idempotent semantics — swap the footer at any time, call
7//! `install_*` twice without panicking — we install a single wrapper
8//! handler that reads from our own, mutable footer slot at render time.
9//!
10//! The net effect for callers: all three `install_*` functions are safe
11//! to call in any order, any number of times.
12
13use std::fmt;
14use std::sync::{OnceLock, RwLock};
15
16use miette::{Diagnostic, GraphicalReportHandler, ReportHandler};
17
18type Footer = Box<dyn Fn() -> String + Send + Sync + 'static>;
19
20/// Global footer slot. Read on every diagnostic render.
21static FOOTER: OnceLock<RwLock<Option<Footer>>> = OnceLock::new();
22
23fn footer_slot() -> &'static RwLock<Option<Footer>> {
24 FOOTER.get_or_init(|| RwLock::new(None))
25}
26
27/// Install the default `miette` graphical report handler.
28///
29/// Idempotent. Safe to call from `main()` before `tokio::main`
30/// expansion or from inside an `Application::run()` invocation.
31///
32/// If another caller (including a previous call to this function, to
33/// [`install_with_footer`], or to `miette::set_hook` directly) has
34/// already installed a hook, this call is a no-op — the existing hook
35/// is preserved.
36pub fn install_report_handler() {
37 // Prime the footer slot so concurrent callers can't observe a
38 // half-initialised state when we install the hook below.
39 let _ = footer_slot();
40
41 let _ = miette::set_hook(Box::new(|_| Box::new(RtbReportHandler::new())));
42}
43
44/// Install the `miette` panic hook, routing panics through the same
45/// graphical report pipeline.
46///
47/// Idempotent — `std::panic::set_hook` simply overwrites any previous
48/// hook, so calling twice leaves miette's renderer in place.
49pub fn install_panic_hook() {
50 miette::set_panic_hook();
51}
52
53/// Install the report handler (if not already) and register a closure
54/// that appends a tool-specific support footer to every rendered
55/// diagnostic.
56///
57/// `footer` is called on every diagnostic render and may return an
58/// empty string to suppress the footer for that render. Replacing the
59/// footer is permitted — the most recent call wins.
60pub fn install_with_footer<F>(footer: F)
61where
62 F: Fn() -> String + Send + Sync + 'static,
63{
64 install_report_handler();
65 let mut guard =
66 footer_slot().write().expect("footer lock poisoned — another thread panicked mid-update");
67 *guard = Some(Box::new(footer));
68}
69
70/// `rtb-error`'s `ReportHandler` implementation.
71///
72/// Delegates the structural render to `miette`'s `GraphicalReportHandler`
73/// and appends the currently-registered footer, if any. The footer is
74/// resolved at render time, not install time, so post-install updates
75/// are visible immediately.
76struct RtbReportHandler {
77 inner: GraphicalReportHandler,
78}
79
80impl RtbReportHandler {
81 fn new() -> Self {
82 Self { inner: GraphicalReportHandler::new() }
83 }
84}
85
86thread_local! {
87 /// Re-entry guard for [`RtbReportHandler::debug`]. When a footer
88 /// closure panics and the installed `miette` panic hook is
89 /// active, the hook may render the panic *through this same
90 /// handler* — which would re-invoke the (panicking) footer and
91 /// produce a double-panic abort. This flag makes the nested
92 /// render skip the footer step entirely.
93 static IN_RENDER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
94}
95
96impl ReportHandler for RtbReportHandler {
97 fn debug(&self, diagnostic: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 self.inner.render_report(f, diagnostic)?;
99
100 // Skip the footer if we're already inside a render (we're
101 // being re-entered by miette's panic hook rendering a panic
102 // that occurred inside *our* footer closure).
103 if IN_RENDER.with(std::cell::Cell::get) {
104 return Ok(());
105 }
106
107 // Capture the footer closure's output with `catch_unwind` so a
108 // panicking closure cannot poison the `FOOTER` RwLock. The
109 // read guard is released before we emit to the formatter.
110 let maybe_text = {
111 let Some(slot) = FOOTER.get() else {
112 return Ok(());
113 };
114 let Ok(guard) = slot.read() else {
115 // Lock poisoned by a concurrent writer; suppress.
116 return Ok(());
117 };
118 let Some(footer) = guard.as_ref() else {
119 return Ok(());
120 };
121
122 // Mark re-entry so a panic during the footer call, if it
123 // recurses through the installed panic hook, short-
124 // circuits without invoking the footer again.
125 IN_RENDER.with(|flag| flag.set(true));
126 // clippy::redundant_closure is triggered here because
127 // `footer` is callable, but we need to wrap in a closure
128 // so `catch_unwind` has a `FnOnce() -> String` of the
129 // correct shape (footer is `&Box<dyn Fn() -> String>`).
130 #[allow(clippy::redundant_closure)]
131 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| footer()));
132 IN_RENDER.with(|flag| flag.set(false));
133
134 outcome.ok()
135 };
136
137 if let Some(text) = maybe_text {
138 if !text.is_empty() {
139 writeln!(f)?;
140 writeln!(f, "{text}")?;
141 }
142 }
143 Ok(())
144 }
145}