use std::{
backtrace::{Backtrace, BacktraceStatus},
fmt, process,
};
use anyhow::{Error, Result};
use log::{Level, log_enabled};
use schemars::JsonSchema;
use serde::{Serialize, Serializer};
use serde_json::Value;
use crate::{columns::Columns, printer::Printer, sanitize};
pub trait ErrorCode: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static {}
impl<T: Clone + fmt::Debug + Serialize + JsonSchema + Send + Sync + 'static> ErrorCode for T {}
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
pub enum NoErrorCode {}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CommonCode {
UsageInvalid,
}
pub fn usage_error(err: &clap::Error) -> Error {
let rendered = err.render().to_string();
let message = rendered.strip_prefix("error: ").unwrap_or(&rendered);
usage_invalid(message.trim_end())
}
pub(crate) fn usage_invalid(message: impl Into<String>) -> Error {
CodedError::new(CommonCode::UsageInvalid, message).into()
}
#[derive(Debug)]
pub struct CodedError<C> {
code: C,
message: String,
}
impl<C> CodedError<C> {
pub fn new(code: C, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl<C> fmt::Display for CodedError<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl<C: fmt::Debug> std::error::Error for CodedError<C> {}
pub const EXIT_FAILURE: i32 = 1;
pub const EXIT_USAGE: i32 = 2;
pub const EXIT_PARTIAL: i32 = 3;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PartialOutcome {
status: i32,
}
impl PartialOutcome {
pub fn new(status: i32) -> Self {
assert!(
status >= EXIT_PARTIAL,
"partial outcome status {status} < {EXIT_PARTIAL}"
);
Self { status }
}
pub fn status(&self) -> i32 {
self.status
}
}
impl Default for PartialOutcome {
fn default() -> Self {
Self::new(EXIT_PARTIAL)
}
}
impl fmt::Display for PartialOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Command ended with exit status {}", self.status)
}
}
impl std::error::Error for PartialOutcome {}
pub fn partial_outcome_of(err: &Error) -> Option<PartialOutcome> {
err.chain()
.find_map(|cause| cause.downcast_ref::<PartialOutcome>())
.copied()
}
pub fn failure_status(err: &Error) -> i32 {
match code_of::<CommonCode>(err) {
Some(CommonCode::UsageInvalid) => EXIT_USAGE,
None => EXIT_FAILURE,
}
}
pub fn with_code<C: ErrorCode>(err: Error, code: impl Fn(&Error) -> Option<C>) -> Error {
let kept = code_of::<C>(&err).is_some()
|| code_of::<CommonCode>(&err).is_some()
|| partial_outcome_of(&err).is_some();
if kept {
return err;
}
let Some(code) = code(&err) else {
return err;
};
let mut messages: Vec<String> = err.chain().map(ToString::to_string).collect();
let innermost = messages.pop().unwrap_or_default();
let coded = Error::new(CodedError::new(code, innermost));
messages
.into_iter()
.rev()
.fold(coded, |err, message| err.context(message))
}
pub fn code_of<C: ErrorCode>(err: &Error) -> Option<C> {
err.chain()
.find_map(|cause| cause.downcast_ref::<CodedError<C>>())
.map(|coded| coded.code.clone())
}
pub struct ErrorReport<C = NoErrorCode> {
error: Error,
code: Option<ReportCode<C>>,
pub(crate) log_level_hint: &'static str,
}
impl ErrorReport {
pub fn eval<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
Self::eval_coded(printer, result)
}
}
impl<C: ErrorCode> ErrorReport<C> {
pub fn eval_coded<T>(printer: &mut impl Printer, result: Result<T, Error>) -> T {
match result {
Ok(res) => res,
Err(err) => {
if let Some(outcome) = partial_outcome_of(&err) {
process::exit(outcome.status());
}
let status = failure_status(&err);
let _ = printer.out(Self::from(err));
process::exit(status);
}
}
}
fn sources(&self) -> impl Iterator<Item = String> + '_ {
self.error.chain().skip(1).map(ToString::to_string)
}
fn suggestions(&self) -> Option<&str> {
if !log_enabled!(Level::Debug) {
Some(self.log_level_hint)
} else {
None
}
}
fn backtrace(&self) -> Option<&Backtrace> {
let backtrace = self.error.backtrace();
if let BacktraceStatus::Captured = backtrace.status() {
Some(backtrace)
} else {
None
}
}
}
impl<C: ErrorCode> From<Error> for ErrorReport<C> {
fn from(error: Error) -> Self {
let code = code_of(&error)
.map(ReportCode::Binary)
.or_else(|| code_of::<CommonCode>(&error).map(ReportCode::Common));
Self {
error,
code,
log_level_hint: "Run with --log-level to enable more verbose logs",
}
}
}
impl<C: ErrorCode> fmt::Display for ErrorReport<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Error: {}", self.error)?;
let mut header_printed = false;
for err in self.sources() {
if !header_printed {
writeln!(f)?;
writeln!(f)?;
write!(f, "Caused by:")?;
header_printed = true;
}
writeln!(f)?;
write!(f, " - {}", err.trim())?;
}
if let Some(backtrace) = self.backtrace() {
writeln!(f)?;
writeln!(f)?;
writeln!(f, "Backtrace:")?;
write!(f, "{backtrace}")?;
}
if let Some(suggestion) = self.suggestions() {
writeln!(f)?;
writeln!(f)?;
writeln!(f, "Suggestions:")?;
write!(f, " - {suggestion}")?;
}
writeln!(f)
}
}
impl<C: ErrorCode> Serialize for ErrorReport<C> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let output = ErrorReportOutput {
code: self.code.clone(),
error: self.error.to_string(),
sources: self.sources().collect(),
backtrace: self.backtrace().map(ToString::to_string),
};
output.serialize(serializer)
}
}
impl<C: ErrorCode> JsonSchema for ErrorReport<C> {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ErrorReport".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
ErrorReportOutput::<C>::json_schema(generator)
}
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct ItemError<C = NoErrorCode> {
pub id: Option<String>,
pub code: Option<C>,
pub error: String,
pub sources: Vec<String>,
}
impl<C: ErrorCode> ItemError<C> {
pub fn new(id: Option<&str>, err: &Error) -> Self {
Self {
id: id.map(String::from),
code: code_of(err),
error: err.to_string(),
sources: err.chain().skip(1).map(ToString::to_string).collect(),
}
}
}
impl<C: ErrorCode> ItemError<C> {
fn cells(&self) -> [String; 3] {
let id = sanitize::text(self.id.as_deref().unwrap_or("-")).into_owned();
let code = match &self.code {
Some(code) => serde_json::to_value(code).unwrap_or(Value::Null),
None => Value::Null,
};
let code = code.as_str().unwrap_or("-").to_owned();
[id, code, sanitize::text(&self.error).into_owned()]
}
}
impl<C: ErrorCode> fmt::Display for ItemError<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let [id, code, error] = self.cells();
write!(f, "{id} {code} {error}")
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ItemErrors {
pub rows: Vec<[String; 3]>,
}
impl ItemErrors {
pub fn push<C: ErrorCode>(&mut self, error: &ItemError<C>) {
self.rows.push(error.cells());
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn write_after(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_empty() {
return Ok(());
}
writeln!(f)?;
write!(f, "{self}")
}
}
impl fmt::Display for ItemErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut columns = Columns::default();
for [id, code, error] in &self.rows {
columns.push(["Error:", id, code, error]);
}
write!(f, "{columns}")
}
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(untagged)]
enum ReportCode<C> {
Binary(C),
Common(CommonCode),
}
#[derive(Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct ErrorReportOutput<C> {
code: Option<ReportCode<C>>,
error: String,
sources: Vec<String>,
backtrace: Option<String>,
}
#[cfg(test)]
mod tests {
use anyhow::{Context, anyhow};
use serde::Serialize;
use serde_json::json;
use crate::{clap::commands::json_schema::output_schema, error::*};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
enum TestCode {
BodyPending,
}
fn pending() -> Result<(), Error> {
Err(CodedError::new(TestCode::BodyPending, "not downloaded yet").into())
}
#[test]
fn a_code_read_deep_in_the_chain_reaches_the_report() {
#[derive(Debug)]
struct Refused;
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("403 Forbidden")
}
}
impl std::error::Error for Refused {}
let code = |err: &Error| {
err.chain()
.any(|cause| cause.is::<Refused>())
.then_some(TestCode::BodyPending)
};
let err = Error::new(Refused)
.context("Cannot fetch")
.context("Cannot sync");
let coded = with_code(err, code);
assert_eq!(code_of(&coded), Some(TestCode::BodyPending));
let messages: Vec<String> = coded.chain().map(ToString::to_string).collect();
assert_eq!(messages, ["Cannot sync", "Cannot fetch", "403 Forbidden"]);
let refused = usage_invalid("both on stdin");
let kept = with_code(refused, |_| Some(TestCode::BodyPending));
assert_eq!(code_of::<TestCode>(&kept), None);
assert_eq!(failure_status(&kept), EXIT_USAGE);
}
#[test]
fn a_refused_command_line_exits_two() {
let conflict = clap::Error::raw(clap::error::ErrorKind::ArgumentConflict, "both on stdin");
let refused = usage_error(&conflict).context("Cannot apply");
assert_eq!(failure_status(&refused), EXIT_USAGE);
assert_eq!(failure_status(&pending().unwrap_err()), EXIT_FAILURE);
}
#[test]
fn a_coded_error_keeps_its_code_under_context() {
let err = pending().context("Read message 3").unwrap_err();
assert_eq!(code_of(&err), Some(TestCode::BodyPending));
}
#[test]
fn the_json_report_carries_the_code_beside_the_message() {
let err = pending().unwrap_err();
let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
assert_eq!(json["code"], "body-pending");
assert_eq!(json["error"], "not downloaded yet");
}
#[test]
fn an_uncoded_error_prints_a_null_code() {
let err = anyhow!("boom").context("Read message 3");
let json = serde_json::to_value(ErrorReport::<TestCode>::from(err)).unwrap();
assert_eq!(
json,
json!({ "code": null, "error": "Read message 3", "sources": ["boom"], "backtrace": null })
);
}
#[test]
fn the_schema_types_the_code_by_the_vocabulary() {
let schema = output_schema::<ErrorReport<TestCode>>();
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("code")), "{schema}");
assert!(required.contains(&json!("error")), "{schema}");
assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["body-pending"]));
}
#[derive(Debug, clap::Parser)]
struct App {
#[arg(long)]
count: u8,
}
fn refused(args: &[&str]) -> serde_json::Value {
let err = <App as clap::Parser>::try_parse_from(args).unwrap_err();
serde_json::to_value(ErrorReport::<TestCode>::from(usage_error(&err))).unwrap()
}
#[test]
fn a_refused_command_line_is_coded_usage_invalid_without_prefix() {
for args in [&["app", "--nope"][..], &["app"], &["app", "--count", "x"]] {
let json = refused(args);
let error = json["error"].as_str().unwrap();
assert_eq!(json["code"], "usage-invalid", "{json}");
assert!(!error.starts_with("error:"), "{error}");
assert!(!error.contains('\x1b'), "{error}");
}
}
#[test]
fn the_schema_admits_the_common_codes() {
let schema = output_schema::<ErrorReport<TestCode>>().to_string();
assert!(schema.contains("usage-invalid"), "{schema}");
}
#[test]
fn a_partial_outcome_keeps_its_status_under_context() {
let err = Error::from(PartialOutcome::new(3)).context("Check account");
assert_eq!(partial_outcome_of(&err).map(|o| o.status()), Some(3));
assert_eq!(partial_outcome_of(&anyhow!("boom")), None);
}
#[test]
#[should_panic]
fn a_partial_outcome_cannot_read_as_a_failure() {
PartialOutcome::new(1);
}
fn unreadable() -> Error {
pending()
.context("Cannot decode line")
.context("Cannot read message 34")
.unwrap_err()
}
#[test]
fn an_item_error_finds_the_code_under_context() {
let json = serde_json::to_value(ItemError::<TestCode>::new(Some("34"), &unreadable()));
assert_eq!(
json.unwrap(),
json!({
"id": "34",
"code": "body-pending",
"error": "Cannot read message 34",
"sources": ["Cannot decode line", "not downloaded yet"],
})
);
}
#[test]
fn an_item_error_schema_requires_every_key() {
let schema = output_schema::<ItemError<TestCode>>();
let required = schema["required"].as_array().unwrap();
for key in ["id", "code", "error", "sources"] {
assert!(required.contains(&json!(key)), "{schema}");
}
assert_eq!(
schema["properties"]["id"]["type"],
json!(["string", "null"])
);
assert_eq!(
schema["properties"]["code"]["anyOf"][1],
json!({ "type": "null" })
);
assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["body-pending"]));
}
#[test]
fn an_item_error_displays_as_one_line() {
let coded = ItemError::<TestCode>::new(Some("34"), &unreadable());
let uncoded = ItemError::<TestCode>::new(None, &anyhow!("Cannot read\nline"));
assert_eq!(
coded.to_string(),
"34 body-pending Cannot read message 34"
);
assert_eq!(uncoded.to_string(), "- - Cannot read\u{fffd}line");
}
}