1use 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
34pub 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#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
46pub enum NoErrorCode {}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
51#[serde(rename_all = "kebab-case")]
52pub enum CommonCode {
53 UsageInvalid,
56}
57
58pub 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
67pub(crate) fn usage_invalid(message: impl Into<String>) -> Error {
70 CodedError::new(CommonCode::UsageInvalid, message).into()
71}
72
73#[derive(Debug)]
75pub struct CodedError<C> {
76 code: C,
77 message: String,
78}
79
80impl<C> CodedError<C> {
81 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
98pub const EXIT_FAILURE: i32 = 1;
101
102pub const EXIT_USAGE: i32 = 2;
104
105pub const EXIT_PARTIAL: i32 = 3;
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub struct PartialOutcome {
119 status: i32,
120}
121
122impl PartialOutcome {
123 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 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
157pub fn partial_outcome_of(err: &Error) -> Option<PartialOutcome> {
159 err.chain()
160 .find_map(|cause| cause.downcast_ref::<PartialOutcome>())
161 .copied()
162}
163
164pub 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
174pub 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
204pub 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
211pub struct ErrorReport<C = NoErrorCode> {
214 error: Error,
215 code: Option<ReportCode<C>>,
216 pub(crate) log_level_hint: &'static str,
218}
219
220impl ErrorReport {
221 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 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#[derive(Clone, Debug, Serialize, JsonSchema)]
347pub struct ItemError<C = NoErrorCode> {
348 pub id: Option<String>,
350 pub code: Option<C>,
352 pub error: String,
354 pub sources: Vec<String>,
356}
357
358impl<C: ErrorCode> ItemError<C> {
359 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 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
398pub struct ItemErrors {
399 pub rows: Vec<[String; 3]>,
401}
402
403impl ItemErrors {
404 pub fn push<C: ErrorCode>(&mut self, error: &ItemError<C>) {
406 self.rows.push(error.cells());
407 }
408
409 pub fn is_empty(&self) -> bool {
411 self.rows.is_empty()
412 }
413
414 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#[derive(Clone, Debug, Serialize, JsonSchema)]
440#[serde(untagged)]
441enum ReportCode<C> {
442 Binary(C),
443 Common(CommonCode),
444}
445
446#[derive(Serialize, JsonSchema)]
451#[serde(rename_all = "camelCase")]
452struct ErrorReportOutput<C> {
453 code: Option<ReportCode<C>>,
456 error: String,
458 sources: Vec<String>,
460 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 #[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 #[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}