Skip to main content

pimalaya_cli/
error.rs

1//! Command failure reporting to stdout.
2//!
3//! [`ErrorReport`] renders a failure, with its source chain and
4//! backtrace, as text or JSON. A binary acting as a machine contract
5//! also gives the failures a caller is expected to act on a stable
6//! code: it declares its own [`ErrorCode`] vocabulary, raises a
7//! [`CodedError`] anywhere in the chain, and evaluates its result with
8//! [`ErrorReport::eval_coded`], which prints the code as `code` beside
9//! the message. The message stays for humans and may be reworded at any
10//! time; the code is the contract.
11//!
12//! A command that ran to its end but did not fully succeed prints its
13//! result, then returns a [`PartialOutcome`]: the process exits with its
14//! status, 2 or above, and prints nothing more.
15
16use 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
28/// A binary's vocabulary of stable failure codes.
29///
30/// Implemented for every type with the needed bounds. The vocabulary
31/// belongs to the binary, but its values are kebab-case across the
32/// family, so a code enum derives `Serialize` with
33/// `#[serde(rename_all = "kebab-case")]`.
34pub 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/// The empty vocabulary of a binary whose failures carry no code.
39#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
40pub enum NoErrorCode {}
41
42/// A failure carrying an [`ErrorCode`], anywhere in an error chain.
43#[derive(Debug)]
44pub struct CodedError<C> {
45    code: C,
46    message: String,
47}
48
49impl<C> CodedError<C> {
50    /// Builds a failure with its code and its human message.
51    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
67/// The exit status of a failed command, which printed the failure
68/// object.
69pub const EXIT_FAILURE: i32 = 1;
70
71/// The exit status of a command that ran to its end and printed its
72/// result, whose outcome is not a full success. A command may document
73/// higher statuses for other such outcomes.
74pub const EXIT_PARTIAL: i32 = 2;
75
76/// The outcome of a command that printed its result but did not fully
77/// succeed, such as a check with a failed backend or a refusal from a
78/// server.
79///
80/// A command returns it as its error once its result is printed:
81/// [`ErrorReport::eval_coded`] then exits with its status without
82/// printing anything more.
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub struct PartialOutcome {
85    status: i32,
86}
87
88impl PartialOutcome {
89    /// Builds the outcome exiting with the given status.
90    ///
91    /// # Panics
92    ///
93    /// When the status is below [`EXIT_PARTIAL`], which would read as a
94    /// success or as a failure object.
95    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    /// The exit status.
104    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
123/// The [`PartialOutcome`] in the chain, if any.
124pub fn partial_outcome_of(err: &Error) -> Option<PartialOutcome> {
125    err.chain()
126        .find_map(|cause| cause.downcast_ref::<PartialOutcome>())
127        .copied()
128}
129
130/// The code of the outermost [`CodedError`] in the chain, if any.
131pub 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
137/// Wraps an [`anyhow::Error`] to render it, with its source chain,
138/// backtrace and code, as text or JSON.
139pub struct ErrorReport<C = NoErrorCode> {
140    error: Error,
141    code: Option<C>,
142}
143
144impl ErrorReport {
145    /// Unwraps a result, or prints the error to the printer and exits
146    /// with status 1.
147    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    /// Unwraps a result, or prints the error to the printer, with the
154    /// code of the chain when it carries one, and exits with status 1.
155    ///
156    /// A [`PartialOutcome`] in the chain means the result is already
157    /// printed: the process exits with its status, printing nothing more.
158    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// NOTE: the one shape both the serialization and the schema of
261// ErrorReport come from. Its doc comment below is the schema description.
262
263/// The failure object a command prints instead of its output.
264#[derive(Serialize, JsonSchema)]
265#[serde(rename_all = "camelCase")]
266struct ErrorReportOutput<C> {
267    /// The stable code of the failure, `null` when the binary gave it
268    /// none.
269    code: Option<C>,
270    /// The outermost error message.
271    error: String,
272    /// The messages of the causes, outermost first.
273    sources: Vec<String>,
274    /// The captured backtrace, when backtraces are enabled.
275    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}