1use std::{env, fmt::Write as _, thread};
5
6pub const FATAL_BANNER: &str = "======================== REIFYDB FATAL ========================";
7pub const FATAL_FOOTER: &str = "==============================================================";
8pub const ISSUE_URL: &str = "https://github.com/reifydb/reifydb/issues";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FatalKind {
12 Panic,
13 Invariant,
14 Error,
15}
16
17impl FatalKind {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 FatalKind::Panic => "panic",
21 FatalKind::Invariant => "invariant violated",
22 FatalKind::Error => "unexpected error",
23 }
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Origin {
29 pub file: String,
30 pub line: u32,
31 pub column: u32,
32}
33
34impl Origin {
35 pub fn new(file: impl Into<String>, line: u32, column: u32) -> Self {
36 Self {
37 file: file.into(),
38 line,
39 column,
40 }
41 }
42
43 pub fn error_id(&self) -> String {
44 let stem = self.file.rsplit('/').next().unwrap_or(&self.file).trim_end_matches(".rs");
45 format!("ERR-{}:{}", stem, self.line)
46 }
47}
48
49#[derive(Debug, Clone)]
50pub struct FatalReport {
51 pub kind: FatalKind,
52 pub component: Option<String>,
53 pub reason: String,
54 pub origin: Option<Origin>,
55 pub thread_name: String,
56 pub backtrace: Option<String>,
57 pub context: Vec<(String, String)>,
58}
59
60impl FatalReport {
61 pub fn new(kind: FatalKind, reason: impl Into<String>) -> Self {
62 Self {
63 kind,
64 component: None,
65 reason: reason.into(),
66 origin: None,
67 thread_name: current_thread_name(),
68 backtrace: None,
69 context: Vec::new(),
70 }
71 }
72
73 pub fn component(mut self, component: impl Into<String>) -> Self {
74 self.component = Some(component.into());
75 self
76 }
77
78 pub fn origin(mut self, origin: Origin) -> Self {
79 self.origin = Some(origin);
80 self
81 }
82
83 pub fn backtrace(mut self, backtrace: impl Into<String>) -> Self {
84 self.backtrace = Some(backtrace.into());
85 self
86 }
87
88 pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
89 self.context.push((key.into(), value.into()));
90 self
91 }
92
93 pub fn error_id(&self) -> String {
94 self.origin.as_ref().map(Origin::error_id).unwrap_or_else(|| "ERR-unknown".to_string())
95 }
96
97 pub fn render(&self) -> String {
98 let mut out = String::with_capacity(1024);
99 let _ = writeln!(out, "{}", FATAL_BANNER);
100 let _ = writeln!(out, "id: {}", self.error_id());
101 let _ = writeln!(out, "kind: {}", self.kind.as_str());
102 if let Some(component) = &self.component {
103 let _ = writeln!(out, "component: {}", component);
104 }
105 let _ = writeln!(out, "reason: {}", self.reason);
106 if let Some(origin) = &self.origin {
107 let _ = writeln!(out, "location: {}:{}:{}", origin.file, origin.line, origin.column);
108 }
109 let _ = writeln!(out, "thread: {}", self.thread_name);
110 for (key, value) in &self.context {
111 let _ = writeln!(out, "{:<10} {}", format!("{}:", key), value);
112 }
113 let _ = writeln!(out, "version: {}", env!("CARGO_PKG_VERSION"));
114 let _ = writeln!(
115 out,
116 "build: {} ({})",
117 option_env!("GIT_HASH").unwrap_or("unknown"),
118 option_env!("BUILD_DATE").unwrap_or("unknown")
119 );
120 let _ = writeln!(out, "platform: {} {}", env::consts::OS, env::consts::ARCH);
121 match &self.backtrace {
122 Some(backtrace) => {
123 let _ = writeln!(out, "backtrace:\n{}", backtrace.trim_end());
124 }
125 None => {
126 let _ = writeln!(out, "backtrace: <unavailable, set RUST_BACKTRACE=1>");
127 }
128 }
129 let _ = writeln!(out, "\nThis is a bug in ReifyDB. Please report it with everything above:");
130 let _ = writeln!(out, "{}", ISSUE_URL);
131 let _ = write!(out, "{}", FATAL_FOOTER);
132 out
133 }
134}
135
136fn current_thread_name() -> String {
137 let current = thread::current();
138 match current.name() {
139 Some(name) => format!("{} ({:?})", name, current.id()),
140 None => format!("<unnamed> ({:?})", current.id()),
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn the_error_id_pins_the_source_line_so_two_tickets_from_one_seam_collapse() {
150 let origin = Origin::new("crates/store-operator/src/commit/buffer.rs", 214, 9);
153
154 assert_eq!(origin.error_id(), "ERR-buffer:214");
155 assert_eq!(
156 Origin::new("crates/store-operator/src/commit/buffer.rs", 214, 77).error_id(),
157 "ERR-buffer:214",
158 "the column must not enter the id, or a formatting change would fork the ticket"
159 );
160 }
161
162 #[test]
163 fn a_report_without_an_origin_still_renders_an_id_rather_than_dying() {
164 let report = FatalReport::new(FatalKind::Panic, "something impossible");
166
167 assert_eq!(report.error_id(), "ERR-unknown");
168 assert!(report.render().contains("id: ERR-unknown"));
169 }
170
171 #[test]
172 fn the_render_carries_every_field_a_ticket_needs() {
173 let rendered = FatalReport::new(FatalKind::Invariant, "watermark moved backwards: 9 -> 4")
176 .component("flow supervisor")
177 .origin(Origin::new("crates/flow/src/x.rs", 12, 3))
178 .backtrace("0: reifydb_flow::x::apply\n1: reifydb_runtime::pool::run")
179 .with("flow", "7")
180 .render();
181
182 assert!(rendered.contains(FATAL_BANNER), "the banner is what makes the block greppable in a log");
183 assert!(rendered.contains("id: ERR-x:12"));
184 assert!(rendered.contains("kind: invariant violated"));
185 assert!(rendered.contains("component: flow supervisor"));
186 assert!(rendered.contains("reason: watermark moved backwards: 9 -> 4"));
187 assert!(rendered.contains("location: crates/flow/src/x.rs:12:3"));
188 assert!(rendered.contains("flow: 7"), "caller context must survive into the report");
189 assert!(rendered.contains("0: reifydb_flow::x::apply"));
190 assert!(rendered.contains(ISSUE_URL), "a report nobody can file is not a report");
191 assert!(rendered.contains(env!("CARGO_PKG_VERSION")));
192 assert!(rendered.trim_end().ends_with(FATAL_FOOTER));
193 }
194
195 #[test]
196 fn a_missing_backtrace_says_how_to_get_one_instead_of_going_silent() {
197 let rendered = FatalReport::new(FatalKind::Error, "storage flush failed").render();
199
200 assert!(rendered.contains("RUST_BACKTRACE=1"));
201 }
202
203 #[test]
204 fn the_thread_is_recorded_because_the_hook_fires_far_from_the_caller() {
205 let rendered = FatalReport::new(FatalKind::Panic, "boom").render();
208
209 assert!(rendered.contains("thread: "));
210 }
211}