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, 3 or above, and prints nothing more. A command line the grammar
15//! refuses exits 2, its failure object coded `usage-invalid` under
16//! `--json` (see [`crate::clap::parse`]).
17//!
18//! A command acting on several items lists the failure of each single
19//! item as an [`ItemError`] in its result's `errors`, then exits 3.
20
21use std::{
22    backtrace::{Backtrace, BacktraceStatus},
23    fmt, process,
24};
25
26use anyhow::{Error, Result};
27use log::{Level, log_enabled};
28use schemars::JsonSchema;
29use serde::{Serialize, Serializer};
30use serde_json::Value;
31
32use crate::{columns::Columns, printer::Printer, sanitize};
33
34/// A binary's vocabulary of stable failure codes.
35///
36/// Implemented for every type with the needed bounds. The vocabulary
37/// belongs to the binary, but its values are kebab-case across the
38/// family, so a code enum derives `Serialize` with
39/// `#[serde(rename_all = "kebab-case")]`.
40pub trait ErrorCode: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static {}
41
42impl<T: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static> ErrorCode for T {}
43
44/// The empty vocabulary of a binary whose failures carry no code.
45#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
46pub enum NoErrorCode {}
47
48/// The codes pimalaya-cli raises itself, beside every binary's own
49/// vocabulary.
50#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
51#[serde(rename_all = "kebab-case")]
52pub enum CommonCode {
53    /// The command line does not match the grammar: an unknown flag or
54    /// command, a missing argument, a bad value.
55    UsageInvalid,
56}
57
58/// The failure of a command line the grammar refuses, coded
59/// [`CommonCode::UsageInvalid`], with clap's message stripped of its
60/// `error: ` prefix and of its styles.
61pub fn usage_error(err: &clap::Error) -> Error {
62    let rendered = err.render().to_string();
63    let message = rendered.strip_prefix("error: ").unwrap_or(&rendered);
64    usage_invalid(message.trim_end())
65}
66
67/// A failure coded [`CommonCode::UsageInvalid`] with its own message, for
68/// arguments refused before clap sees them.
69pub(crate) fn usage_invalid(message: impl Into<String>) -> Error {
70    CodedError::new(CommonCode::UsageInvalid, message).into()
71}
72
73/// A failure carrying an [`ErrorCode`], anywhere in an error chain.
74#[derive(Debug)]
75pub struct CodedError<C> {
76    code: C,
77    message: String,
78}
79
80impl<C> CodedError<C> {
81    /// Builds a failure with its code and its human message.
82    pub fn new(code: C, message: impl Into<String>) -> Self {
83        Self {
84            code,
85            message: message.into(),
86        }
87    }
88}
89
90impl<C> fmt::Display for CodedError<C> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(&self.message)
93    }
94}
95
96impl<C: fmt::Debug> std::error::Error for CodedError<C> {}
97
98/// The exit status of a failed command, which printed the failure
99/// object.
100pub const EXIT_FAILURE: i32 = 1;
101
102/// The exit status of a command line the grammar refuses, clap's own.
103pub const EXIT_USAGE: i32 = 2;
104
105/// The exit status of a command that ran to its end and printed its
106/// result, whose outcome is not a full success. A command may document
107/// higher statuses for other such outcomes.
108pub const EXIT_PARTIAL: i32 = 3;
109
110/// The outcome of a command that printed its result but did not fully
111/// succeed, such as a check with a failed backend or a refusal from a
112/// server.
113///
114/// A command returns it as its error once its result is printed:
115/// [`ErrorReport::eval_coded`] then exits with its status without
116/// printing anything more.
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub struct PartialOutcome {
119    status: i32,
120}
121
122impl PartialOutcome {
123    /// Builds the outcome exiting with the given status.
124    ///
125    /// # Panics
126    ///
127    /// When the status is below [`EXIT_PARTIAL`], which would read as a
128    /// success, a failure object or a usage error.
129    pub fn new(status: i32) -> Self {
130        assert!(
131            status >= EXIT_PARTIAL,
132            "partial outcome status {status} < {EXIT_PARTIAL}"
133        );
134        Self { status }
135    }
136
137    /// The exit status.
138    pub fn status(&self) -> i32 {
139        self.status
140    }
141}
142
143impl Default for PartialOutcome {
144    fn default() -> Self {
145        Self::new(EXIT_PARTIAL)
146    }
147}
148
149impl fmt::Display for PartialOutcome {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(f, "Command ended with exit status {}", self.status)
152    }
153}
154
155impl std::error::Error for PartialOutcome {}
156
157/// The [`PartialOutcome`] in the chain, if any.
158pub fn partial_outcome_of(err: &Error) -> Option<PartialOutcome> {
159    err.chain()
160        .find_map(|cause| cause.downcast_ref::<PartialOutcome>())
161        .copied()
162}
163
164/// The status a failure exits with: [`EXIT_USAGE`] when its chain is
165/// coded [`CommonCode::UsageInvalid`], a command line refused after clap
166/// parsed it, [`EXIT_FAILURE`] otherwise.
167pub fn failure_status(err: &Error) -> i32 {
168    match code_of::<CommonCode>(err) {
169        Some(CommonCode::UsageInvalid) => EXIT_USAGE,
170        None => EXIT_FAILURE,
171    }
172}
173
174/// `err` as the report reads it, the code `code` finds in its chain
175/// carried by an innermost [`CodedError`].
176///
177/// A binary may read a code off a typed error deep in the chain (a
178/// protocol refusal, a bare 403 or 429), which [`code_of`] does not see.
179/// `err` is returned unchanged when the chain already carries a code of
180/// `C`, `usage-invalid` or a [`PartialOutcome`], or when `code` finds
181/// none; else it is rebuilt with the same messages, outermost first.
182pub fn with_code<C: ErrorCode>(err: Error, code: impl Fn(&Error) -> Option<C>) -> Error {
183    let kept = code_of::<C>(&err).is_some()
184        || code_of::<CommonCode>(&err).is_some()
185        || partial_outcome_of(&err).is_some();
186    if kept {
187        return err;
188    }
189
190    let Some(code) = code(&err) else {
191        return err;
192    };
193
194    let mut messages: Vec<String> = err.chain().map(ToString::to_string).collect();
195    let innermost = messages.pop().unwrap_or_default();
196    let coded = Error::new(CodedError::new(code, innermost));
197
198    messages
199        .into_iter()
200        .rev()
201        .fold(coded, |err, message| err.context(message))
202}
203
204/// The code of the outermost [`CodedError`] in the chain, if any.
205pub fn code_of<C: ErrorCode>(err: &Error) -> Option<C> {
206    err.chain()
207        .find_map(|cause| cause.downcast_ref::<CodedError<C>>())
208        .map(|coded| coded.code.clone())
209}
210
211/// Wraps an [`anyhow::Error`] to render it, with its source chain,
212/// backtrace and code, as text or JSON.
213pub struct ErrorReport<C = NoErrorCode> {
214    error: Error,
215    code: Option<ReportCode<C>>,
216    /// The text hint naming `--log-level` while debug logs are off.
217    pub(crate) log_level_hint: &'static str,
218}
219
220impl ErrorReport {
221    /// Unwraps a result, or prints the error to the printer and exits
222    /// with status 1.
223    pub fn eval<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
224        Self::eval_coded(printer, result)
225    }
226}
227
228impl<C: ErrorCode> ErrorReport<C> {
229    /// Unwraps a result, or prints the error to the printer, with the
230    /// code of the chain when it carries one, and exits with status 1,
231    /// or 2 for a refused command line (see [`failure_status`]).
232    ///
233    /// A [`PartialOutcome`] in the chain means the result is already
234    /// printed: the process exits with its status, printing nothing more.
235    pub fn eval_coded<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
236        match result {
237            Ok(res) => res,
238            Err(err) => {
239                if let Some(outcome) = partial_outcome_of(&err) {
240                    process::exit(outcome.status());
241                }
242
243                let status = failure_status(&err);
244                let _ = printer.out(Self::from(err));
245                process::exit(status);
246            }
247        }
248    }
249
250    fn sources(&self) -> impl Iterator<Item = String> + '_ {
251        self.error.chain().skip(1).map(ToString::to_string)
252    }
253
254    fn suggestions(&self) -> Option<&str> {
255        if !log_enabled!(Level::Debug) {
256            Some(self.log_level_hint)
257        } else {
258            None
259        }
260    }
261
262    fn backtrace(&self) -> Option<&Backtrace> {
263        let backtrace = self.error.backtrace();
264
265        if let BacktraceStatus::Captured = backtrace.status() {
266            Some(backtrace)
267        } else {
268            None
269        }
270    }
271}
272
273impl<C: ErrorCode> From<Error> for ErrorReport<C> {
274    fn from(error: Error) -> Self {
275        let code = code_of(&error)
276            .map(ReportCode::Binary)
277            .or_else(|| code_of::<CommonCode>(&error).map(ReportCode::Common));
278
279        Self {
280            error,
281            code,
282            log_level_hint: "Run with --log-level to enable more verbose logs",
283        }
284    }
285}
286
287impl<C: ErrorCode> fmt::Display for ErrorReport<C> {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        write!(f, "Error: {}", self.error)?;
290
291        let mut header_printed = false;
292        for err in self.sources() {
293            if !header_printed {
294                writeln!(f)?;
295                writeln!(f)?;
296                write!(f, "Caused by:")?;
297                header_printed = true;
298            }
299
300            writeln!(f)?;
301            write!(f, " - {}", err.trim())?;
302        }
303
304        if let Some(backtrace) = self.backtrace() {
305            writeln!(f)?;
306            writeln!(f)?;
307            writeln!(f, "Backtrace:")?;
308            write!(f, "{backtrace}")?;
309        }
310
311        if let Some(suggestion) = self.suggestions() {
312            writeln!(f)?;
313            writeln!(f)?;
314            writeln!(f, "Suggestions:")?;
315            write!(f, " - {suggestion}")?;
316        }
317
318        writeln!(f)
319    }
320}
321
322impl<C: ErrorCode> Serialize for ErrorReport<C> {
323    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
324        let output = ErrorReportOutput {
325            code: self.code.clone(),
326            error: self.error.to_string(),
327            sources: self.sources().collect(),
328            backtrace: self.backtrace().map(ToString::to_string),
329        };
330
331        output.serialize(serializer)
332    }
333}
334
335impl<C: ErrorCode> JsonSchema for ErrorReport<C> {
336    fn schema_name() -> std::borrow::Cow<'static, str> {
337        "ErrorReport".into()
338    }
339
340    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
341        ErrorReportOutput::<C>::json_schema(generator)
342    }
343}
344
345/// The error of one item a command could not list or act on.
346#[derive(Clone, Debug, Serialize, JsonSchema)]
347pub struct ItemError<C = NoErrorCode> {
348    /// The item's id as the command prints ids, `null` when unknown.
349    pub id: Option<String>,
350    /// The stable code of the failure, `null` when none was given.
351    pub code: Option<C>,
352    /// The outermost error message.
353    pub error: String,
354    /// The messages of the causes, outermost first.
355    pub sources: Vec<String>,
356}
357
358impl<C: ErrorCode> ItemError<C> {
359    /// Builds the error of the item `id` from `err`, coded by the
360    /// outermost [`CodedError`] of its chain.
361    pub fn new(id: Option<&str>, err: &Error) -> Self {
362        Self {
363            id: id.map(String::from),
364            code: code_of(err),
365            error: err.to_string(),
366            sources: err.chain().skip(1).map(ToString::to_string).collect(),
367        }
368    }
369}
370
371impl<C: ErrorCode> ItemError<C> {
372    /// The id, the code and the message, sanitized, an absent one `-`.
373    fn cells(&self) -> [String; 3] {
374        let id = sanitize::text(self.id.as_deref().unwrap_or("-")).into_owned();
375
376        let code = match &self.code {
377            Some(code) => serde_json::to_value(code).unwrap_or(Value::Null),
378            None => Value::Null,
379        };
380        let code = code.as_str().unwrap_or("-").to_owned();
381
382        [id, code, sanitize::text(&self.error).into_owned()]
383    }
384}
385
386impl<C: ErrorCode> fmt::Display for ItemError<C> {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        let [id, code, error] = self.cells();
389        write!(f, "{id}  {code}  {error}")
390    }
391}
392
393/// The item errors a command prints last, one aligned `Error:` line each.
394///
395/// A command prints them after its output and one blank line, and prints
396/// nothing, blank line included, when there are none.
397#[derive(Clone, Debug, Default, Eq, PartialEq)]
398pub struct ItemErrors {
399    /// The id, code and message cells of each error, in print order.
400    pub rows: Vec<[String; 3]>,
401}
402
403impl ItemErrors {
404    /// Appends the line of one item error.
405    pub fn push<C: ErrorCode>(&mut self, error: &ItemError<C>) {
406        self.rows.push(error.cells());
407    }
408
409    /// Whether no error was pushed.
410    pub fn is_empty(&self) -> bool {
411        self.rows.is_empty()
412    }
413
414    /// Writes one blank line then the errors, nothing when there are none.
415    pub fn write_after(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        if self.is_empty() {
417            return Ok(());
418        }
419
420        writeln!(f)?;
421        write!(f, "{self}")
422    }
423}
424
425impl fmt::Display for ItemErrors {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        let mut columns = Columns::default();
428
429        for [id, code, error] in &self.rows {
430            columns.push(["Error:", id, code, error]);
431        }
432
433        write!(f, "{columns}")
434    }
435}
436
437/// The code of a failure object: the binary's own, or one pimalaya-cli
438/// raises itself.
439#[derive(Clone, Debug, Serialize, JsonSchema)]
440#[serde(untagged)]
441enum ReportCode<C> {
442    Binary(C),
443    Common(CommonCode),
444}
445
446// NOTE: the one shape both the serialization and the schema of
447// ErrorReport come from. Its doc comment below is the schema description.
448
449/// The failure object a command prints instead of its output.
450#[derive(Serialize, JsonSchema)]
451#[serde(rename_all = "camelCase")]
452struct ErrorReportOutput<C> {
453    /// The stable code of the failure: the binary's own, `usage-invalid`
454    /// for a command line the grammar refuses, `null` when none was given.
455    code: Option<ReportCode<C>>,
456    /// The outermost error message.
457    error: String,
458    /// The messages of the causes, outermost first.
459    sources: Vec<String>,
460    /// The captured backtrace, when backtraces are enabled.
461    backtrace: Option<String>,
462}
463
464#[cfg(test)]
465mod tests {
466    use anyhow::{Context, anyhow};
467    use serde::Serialize;
468    use serde_json::json;
469
470    use crate::{clap::commands::json_schema::output_schema, error::*};
471
472    #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
473    #[serde(rename_all = "kebab-case")]
474    enum TestCode {
475        BodyPending,
476    }
477
478    fn pending() -> Result<(), Error> {
479        Err(CodedError::new(TestCode::BodyPending, "not downloaded yet").into())
480    }
481
482    /// A code read off a typed error deep in the chain reaches the
483    /// report, every message kept; a chain already coded is left alone.
484    #[test]
485    fn a_code_read_deep_in_the_chain_reaches_the_report() {
486        #[derive(Debug)]
487        struct Refused;
488
489        impl fmt::Display for Refused {
490            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491                f.write_str("403 Forbidden")
492            }
493        }
494
495        impl std::error::Error for Refused {}
496
497        let code = |err: &Error| {
498            err.chain()
499                .any(|cause| cause.is::<Refused>())
500                .then_some(TestCode::BodyPending)
501        };
502
503        let err = Error::new(Refused)
504            .context("Cannot fetch")
505            .context("Cannot sync");
506        let coded = with_code(err, code);
507
508        assert_eq!(code_of(&coded), Some(TestCode::BodyPending));
509        let messages: Vec<String> = coded.chain().map(ToString::to_string).collect();
510        assert_eq!(messages, ["Cannot sync", "Cannot fetch", "403 Forbidden"]);
511
512        let refused = usage_invalid("both on stdin");
513        let kept = with_code(refused, |_| Some(TestCode::BodyPending));
514        assert_eq!(code_of::<TestCode>(&kept), None);
515        assert_eq!(failure_status(&kept), EXIT_USAGE);
516    }
517
518    /// A command line refused after clap parsed it exits 2, as clap's own
519    /// refusals do, wherever the usage error sits in the chain; any other
520    /// failure exits 1.
521    #[test]
522    fn a_refused_command_line_exits_two() {
523        let conflict = clap::Error::raw(clap::error::ErrorKind::ArgumentConflict, "both on stdin");
524        let refused = usage_error(&conflict).context("Cannot apply");
525
526        assert_eq!(failure_status(&refused), EXIT_USAGE);
527        assert_eq!(failure_status(&pending().unwrap_err()), EXIT_FAILURE);
528    }
529
530    #[test]
531    fn a_coded_error_keeps_its_code_under_context() {
532        let err = pending().context("Read message 3").unwrap_err();
533        assert_eq!(code_of(&err), Some(TestCode::BodyPending));
534    }
535
536    #[test]
537    fn the_json_report_carries_the_code_beside_the_message() {
538        let err = pending().unwrap_err();
539        let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
540
541        assert_eq!(json["code"], "body-pending");
542        assert_eq!(json["error"], "not downloaded yet");
543    }
544
545    #[test]
546    fn an_uncoded_error_prints_a_null_code() {
547        let err = anyhow!("boom").context("Read message 3");
548        let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
549
550        assert_eq!(
551            json,
552            json!({ "code": null, "error": "Read message 3", "sources": ["boom"], "backtrace": null })
553        );
554    }
555
556    #[test]
557    fn the_schema_types_the_code_by_the_vocabulary() {
558        let schema = output_schema::<ErrorReport<TestCode>>();
559        let required = schema["required"].as_array().unwrap();
560
561        assert!(required.contains(&json!("code")), "{schema}");
562        assert!(required.contains(&json!("error")), "{schema}");
563        assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["body-pending"]));
564    }
565
566    #[derive(Debug, clap::Parser)]
567    struct App {
568        #[arg(long)]
569        count: u8,
570    }
571
572    fn refused(args: &[&str]) -> serde_json::Value {
573        let err = <App as clap::Parser>::try_parse_from(args).unwrap_err();
574        serde_json::to_value(ErrorReport::<TestCode>::from(usage_error(&err))).unwrap()
575    }
576
577    #[test]
578    fn a_refused_command_line_is_coded_usage_invalid_without_prefix() {
579        for args in [&["app", "--nope"][..], &["app"], &["app", "--count", "x"]] {
580            let json = refused(args);
581            let error = json["error"].as_str().unwrap();
582
583            assert_eq!(json["code"], "usage-invalid", "{json}");
584            assert!(!error.starts_with("error:"), "{error}");
585            assert!(!error.contains('\x1b'), "{error}");
586        }
587    }
588
589    #[test]
590    fn the_schema_admits_the_common_codes() {
591        let schema = output_schema::<ErrorReport<TestCode>>().to_string();
592        assert!(schema.contains("usage-invalid"), "{schema}");
593    }
594
595    #[test]
596    fn a_partial_outcome_keeps_its_status_under_context() {
597        let err = Error::from(PartialOutcome::new(3)).context("Check account");
598        assert_eq!(partial_outcome_of(&err).map(|o| o.status()), Some(3));
599        assert_eq!(partial_outcome_of(&anyhow!("boom")), None);
600    }
601
602    #[test]
603    #[should_panic]
604    fn a_partial_outcome_cannot_read_as_a_failure() {
605        PartialOutcome::new(1);
606    }
607
608    fn unreadable() -> Error {
609        pending()
610            .context("Cannot decode line")
611            .context("Cannot read message 34")
612            .unwrap_err()
613    }
614
615    #[test]
616    fn an_item_error_finds_the_code_under_context() {
617        let json = serde_json::to_value(ItemError::<TestCode>::new(Some("34"), &unreadable()));
618
619        assert_eq!(
620            json.unwrap(),
621            json!({
622                "id": "34",
623                "code": "body-pending",
624                "error": "Cannot read message 34",
625                "sources": ["Cannot decode line", "not downloaded yet"],
626            })
627        );
628    }
629
630    #[test]
631    fn an_item_error_schema_requires_every_key() {
632        let schema = output_schema::<ItemError<TestCode>>();
633        let required = schema["required"].as_array().unwrap();
634
635        for key in ["id", "code", "error", "sources"] {
636            assert!(required.contains(&json!(key)), "{schema}");
637        }
638
639        assert_eq!(
640            schema["properties"]["id"]["type"],
641            json!(["string", "null"])
642        );
643        assert_eq!(
644            schema["properties"]["code"]["anyOf"][1],
645            json!({ "type": "null" })
646        );
647        assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["body-pending"]));
648    }
649
650    #[test]
651    fn an_item_error_displays_as_one_line() {
652        let coded = ItemError::<TestCode>::new(Some("34"), &unreadable());
653        let uncoded = ItemError::<TestCode>::new(None, &anyhow!("Cannot read\nline"));
654
655        assert_eq!(
656            coded.to_string(),
657            "34  body-pending  Cannot read message 34"
658        );
659        assert_eq!(uncoded.to_string(), "-  -  Cannot read\u{fffd}line");
660    }
661}