1use std::{
17 backtrace::{Backtrace, BacktraceStatus},
18 fmt, process,
19};
20
21use anyhow::{Error, Result};
22use log::{Level, log_enabled};
23use schemars::JsonSchema;
24use serde::{Serialize, Serializer};
25
26use crate::printer::Printer;
27
28pub trait ErrorCode: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static {}
35
36impl<T: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static> ErrorCode for T {}
37
38#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
40pub enum NoErrorCode {}
41
42#[derive(Debug)]
44pub struct CodedError<C> {
45 code: C,
46 message: String,
47}
48
49impl<C> CodedError<C> {
50 pub fn new(code: C, message: impl Into<String>) -> Self {
52 Self {
53 code,
54 message: message.into(),
55 }
56 }
57}
58
59impl<C> fmt::Display for CodedError<C> {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(&self.message)
62 }
63}
64
65impl<C: fmt::Debug> std::error::Error for CodedError<C> {}
66
67pub const EXIT_FAILURE: i32 = 1;
70
71pub const EXIT_PARTIAL: i32 = 2;
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub struct PartialOutcome {
85 status: i32,
86}
87
88impl PartialOutcome {
89 pub fn new(status: i32) -> Self {
96 assert!(
97 status >= EXIT_PARTIAL,
98 "partial outcome status {status} < 2"
99 );
100 Self { status }
101 }
102
103 pub fn status(&self) -> i32 {
105 self.status
106 }
107}
108
109impl Default for PartialOutcome {
110 fn default() -> Self {
111 Self::new(EXIT_PARTIAL)
112 }
113}
114
115impl fmt::Display for PartialOutcome {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 write!(f, "Command ended with exit status {}", self.status)
118 }
119}
120
121impl std::error::Error for PartialOutcome {}
122
123pub fn partial_outcome_of(err: &Error) -> Option<PartialOutcome> {
125 err.chain()
126 .find_map(|cause| cause.downcast_ref::<PartialOutcome>())
127 .copied()
128}
129
130pub fn code_of<C: ErrorCode>(err: &Error) -> Option<C> {
132 err.chain()
133 .find_map(|cause| cause.downcast_ref::<CodedError<C>>())
134 .map(|coded| coded.code.clone())
135}
136
137pub struct ErrorReport<C = NoErrorCode> {
140 error: Error,
141 code: Option<C>,
142}
143
144impl ErrorReport {
145 pub fn eval<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
148 Self::eval_coded(printer, result)
149 }
150}
151
152impl<C: ErrorCode> ErrorReport<C> {
153 pub fn eval_coded<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
159 match result {
160 Ok(res) => res,
161 Err(err) => {
162 if let Some(outcome) = partial_outcome_of(&err) {
163 process::exit(outcome.status());
164 }
165
166 let _ = printer.out(Self::from(err));
167 process::exit(EXIT_FAILURE);
168 }
169 }
170 }
171
172 fn sources(&self) -> impl Iterator<Item = String> + '_ {
173 self.error.chain().skip(1).map(ToString::to_string)
174 }
175
176 fn suggestions(&self) -> Option<&str> {
177 if !log_enabled!(Level::Debug) {
178 Some("Run with --log-level to enable more verbose logs")
179 } else {
180 None
181 }
182 }
183
184 fn backtrace(&self) -> Option<&Backtrace> {
185 let backtrace = self.error.backtrace();
186
187 if let BacktraceStatus::Captured = backtrace.status() {
188 Some(backtrace)
189 } else {
190 None
191 }
192 }
193}
194
195impl<C: ErrorCode> From<Error> for ErrorReport<C> {
196 fn from(error: Error) -> Self {
197 let code = code_of(&error);
198 Self { error, code }
199 }
200}
201
202impl<C: ErrorCode> fmt::Display for ErrorReport<C> {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 write!(f, "Error: {}", self.error)?;
205
206 let mut header_printed = false;
207 for err in self.sources() {
208 if !header_printed {
209 writeln!(f)?;
210 writeln!(f)?;
211 write!(f, "Caused by:")?;
212 header_printed = true;
213 }
214
215 writeln!(f)?;
216 write!(f, " - {}", err.trim())?;
217 }
218
219 if let Some(backtrace) = self.backtrace() {
220 writeln!(f)?;
221 writeln!(f)?;
222 writeln!(f, "Backtrace:")?;
223 write!(f, "{backtrace}")?;
224 }
225
226 if let Some(suggestion) = self.suggestions() {
227 writeln!(f)?;
228 writeln!(f)?;
229 writeln!(f, "Suggestions:")?;
230 write!(f, " - {suggestion}")?;
231 }
232
233 writeln!(f)
234 }
235}
236
237impl<C: ErrorCode> Serialize for ErrorReport<C> {
238 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
239 let output = ErrorReportOutput {
240 code: self.code.clone(),
241 error: self.error.to_string(),
242 sources: self.sources().collect(),
243 backtrace: self.backtrace().map(ToString::to_string),
244 };
245
246 output.serialize(serializer)
247 }
248}
249
250impl<C: ErrorCode> JsonSchema for ErrorReport<C> {
251 fn schema_name() -> std::borrow::Cow<'static, str> {
252 "ErrorReport".into()
253 }
254
255 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
256 ErrorReportOutput::<C>::json_schema(generator)
257 }
258}
259
260#[derive(Serialize, JsonSchema)]
265#[serde(rename_all = "camelCase")]
266struct ErrorReportOutput<C> {
267 code: Option<C>,
270 error: String,
272 sources: Vec<String>,
274 backtrace: Option<String>,
276}
277
278#[cfg(test)]
279mod tests {
280 use crate::clap::commands::output_schema;
281 use anyhow::{Context, anyhow};
282 use serde::Serialize;
283 use serde_json::json;
284
285 use super::*;
286
287 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
288 #[serde(rename_all = "kebab-case")]
289 enum TestCode {
290 BodyPending,
291 }
292
293 fn pending() -> Result<(), Error> {
294 Err(CodedError::new(TestCode::BodyPending, "not downloaded yet").into())
295 }
296
297 #[test]
298 fn a_coded_error_keeps_its_code_under_context() {
299 let err = pending().context("Read message 3").unwrap_err();
300 assert_eq!(code_of(&err), Some(TestCode::BodyPending));
301 }
302
303 #[test]
304 fn the_json_report_carries_the_code_beside_the_message() {
305 let err = pending().unwrap_err();
306 let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
307
308 assert_eq!(json["code"], "body-pending");
309 assert_eq!(json["error"], "not downloaded yet");
310 }
311
312 #[test]
313 fn an_uncoded_error_prints_a_null_code() {
314 let err = anyhow!("boom").context("Read message 3");
315 let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
316
317 assert_eq!(
318 json,
319 json!({ "code": null, "error": "Read message 3", "sources": ["boom"], "backtrace": null })
320 );
321 }
322
323 #[test]
324 fn the_schema_types_the_code_by_the_vocabulary() {
325 let schema = output_schema::<ErrorReport<TestCode>>();
326 let required = schema["required"].as_array().unwrap();
327
328 assert!(required.contains(&json!("code")), "{schema}");
329 assert!(required.contains(&json!("error")), "{schema}");
330 assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["body-pending"]));
331 }
332
333 #[test]
334 fn a_partial_outcome_keeps_its_status_under_context() {
335 let err = Error::from(PartialOutcome::new(3)).context("Check account");
336 assert_eq!(partial_outcome_of(&err).map(|o| o.status()), Some(3));
337 assert_eq!(partial_outcome_of(&anyhow!("boom")), None);
338 }
339
340 #[test]
341 #[should_panic]
342 fn a_partial_outcome_cannot_read_as_a_failure() {
343 PartialOutcome::new(1);
344 }
345}