use std::error::Error as StdError;
use std::fmt;
use serde::Serialize;
use super::warn::{json_fallback, json_line_or, Diagnostic};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
#[non_exhaustive]
pub enum ExitKind {
Gate = 1,
Usage = 2,
Report = 3,
Marker = 4,
Config = 5,
Git = 6,
PathMismatch = 7,
Other = 8,
}
impl ExitKind {
pub const fn code(self) -> u8 {
self as u8
}
pub const fn name(self) -> &'static str {
match self {
Self::Gate => "gate",
Self::Usage => "usage",
Self::Report => "report",
Self::Marker => "marker",
Self::Config => "config",
Self::Git => "git",
Self::PathMismatch => "path-mismatch",
Self::Other => "other",
}
}
pub fn error(
self,
message: impl fmt::Display + fmt::Debug + Send + Sync + 'static,
) -> anyhow::Error {
anyhow::Error::new(ExitError {
kind: self,
source: anyhow::Error::msg(message),
gates: Vec::new(),
})
}
}
#[derive(Debug)]
pub struct ExitError {
kind: ExitKind,
source: anyhow::Error,
gates: Vec<GateFailure>,
}
impl ExitError {
pub const fn kind(&self) -> ExitKind {
self.kind
}
pub fn gates(&self) -> &[GateFailure] {
&self.gates
}
}
impl fmt::Display for ExitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.source, f)
}
}
impl StdError for ExitError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
let inner: &(dyn StdError + Send + Sync + 'static) = self.source.as_ref();
inner.source()
}
}
fn tag_of(err: &anyhow::Error) -> Option<&ExitError> {
err.chain()
.find_map(|cause| cause.downcast_ref::<ExitError>())
}
fn kind_of(err: &anyhow::Error) -> Option<ExitKind> {
tag_of(err).map(ExitError::kind)
}
pub fn kind(err: &anyhow::Error) -> ExitKind {
kind_of(err).unwrap_or(ExitKind::Other)
}
pub fn code(err: &anyhow::Error) -> u8 {
kind(err).code()
}
const LEVEL: &str = "error";
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "gate", rename_all = "kebab-case")]
pub enum GateFailure {
FailUnderPatch {
threshold: f64,
measured: f64,
},
FailUnderLines {
threshold: f64,
measured: Option<f64>,
},
FailOnUnmeasured {
files: Vec<String>,
},
}
impl fmt::Display for GateFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FailUnderPatch {
threshold,
measured,
} => write!(
f,
"patch coverage {measured:.2}% is below the --fail-under-patch threshold of {threshold:.2}%"
),
Self::FailUnderLines {
threshold,
measured: Some(pct),
} => write!(
f,
"line coverage {pct:.2}% is below the --fail-under-lines threshold of {threshold:.2}%"
),
Self::FailUnderLines {
threshold,
measured: None,
} => write!(
f,
"the report has no executable lines, so the --fail-under-lines threshold of {threshold:.2}% cannot be met"
),
Self::FailOnUnmeasured { files } => write!(
f,
"touched files absent from every coverage report (--fail-on-unmeasured / diff.require-measured): {}",
files.join(", ")
),
}
}
}
pub fn gate_error(failures: Vec<GateFailure>) -> anyhow::Error {
debug_assert!(!failures.is_empty(), "a gate error names a failed gate");
let message = failures
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
anyhow::Error::new(ExitError {
kind: ExitKind::Gate,
source: anyhow::Error::msg(message),
gates: failures,
})
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ErrorReport {
pub level: &'static str,
pub code: u8,
pub kind: &'static str,
pub message: String,
pub chain: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gates: Option<Vec<GateFailure>>,
}
impl Diagnostic for ErrorReport {
fn level(&self) -> &str {
self.level
}
fn kind(&self) -> &str {
self.kind
}
}
impl ErrorReport {
pub fn new(err: &anyhow::Error) -> Self {
let kind = kind(err);
let mut messages = err.chain().map(ToString::to_string);
Self {
level: LEVEL,
code: kind.code(),
kind: kind.name(),
message: messages.next().unwrap_or_default(),
chain: messages.collect(),
gates: tag_of(err)
.map(ExitError::gates)
.filter(|gates| !gates.is_empty())
.map(<[GateFailure]>::to_vec),
}
}
pub fn usage(message: impl Into<String>) -> Self {
Self {
level: LEVEL,
code: ExitKind::Usage.code(),
kind: ExitKind::Usage.name(),
message: message.into(),
chain: Vec::new(),
gates: None,
}
}
pub fn to_json(&self) -> String {
self.json_with(self)
}
fn json_with(&self, record: &impl Serialize) -> String {
json_line_or(record, || self.fallback())
}
fn fallback(&self) -> serde_json::Value {
let mut fallback = json_fallback(self, &self.message);
fallback["code"] = self.code.into();
if let Ok(chain) = serde_json::to_value(&self.chain) {
fallback["chain"] = chain;
}
if let Some(gates) = &self.gates {
if let Ok(gates) = serde_json::to_value(gates) {
fallback["gates"] = gates;
}
}
fallback
}
}
pub trait Classify<T> {
fn classify(self, kind: ExitKind) -> anyhow::Result<T>;
}
impl<T, E> Classify<T> for Result<T, E>
where
E: Into<anyhow::Error>,
{
fn classify(self, kind: ExitKind) -> anyhow::Result<T> {
self.map_err(|err| {
let err = err.into();
if kind_of(&err).is_some() {
err
} else {
anyhow::Error::new(ExitError {
kind,
source: err,
gates: Vec::new(),
})
}
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use anyhow::{anyhow, Context};
use super::*;
use crate::cli::doc_fields::{
assert_every_variant, check_fields, check_type, example_keys, first_table_fields,
first_table_rows, gate_rows, json_keys, kind_codes, reference_section, Field,
};
#[test]
fn error_builds_a_tagged_error() {
let err = ExitKind::Gate.error("it failed");
assert_eq!(code(&err), 1);
assert_eq!(format!("{err:#}"), "it failed");
}
fn fail(kind: ExitKind) -> anyhow::Error {
Err::<(), _>(anyhow!("boom")).classify(kind).unwrap_err()
}
#[test]
fn codes_are_stable() {
let expected = [
(ExitKind::Gate, 1),
(ExitKind::Usage, 2),
(ExitKind::Report, 3),
(ExitKind::Marker, 4),
(ExitKind::Config, 5),
(ExitKind::Git, 6),
(ExitKind::PathMismatch, 7),
(ExitKind::Other, 8),
];
for (kind, number) in expected {
assert_eq!(kind.code(), number, "{kind:?}");
assert_eq!(code(&fail(kind)), number, "{kind:?}");
}
}
#[test]
fn names_are_stable() {
let expected = [
(ExitKind::Gate, "gate"),
(ExitKind::Usage, "usage"),
(ExitKind::Report, "report"),
(ExitKind::Marker, "marker"),
(ExitKind::Config, "config"),
(ExitKind::Git, "git"),
(ExitKind::PathMismatch, "path-mismatch"),
(ExitKind::Other, "other"),
];
for (kind, name) in expected {
assert_eq!(kind.name(), name, "{kind:?}");
}
}
#[test]
fn a_report_names_the_class_message_and_causes() {
let err = Err::<(), _>(std::io::Error::other("disk on fire"))
.context("could not read it")
.classify(ExitKind::Report)
.unwrap_err();
let err = Err::<(), _>(err).context("outer").unwrap_err();
let report = ErrorReport::new(&err);
assert_eq!(report.code, 3);
assert_eq!(report.kind, "report");
assert_eq!(report.message, "outer");
assert_eq!(report.chain, ["could not read it", "disk on fire"]);
let mut all = vec![report.message.clone()];
all.extend(report.chain);
assert_eq!(all.join(": "), format!("{err:#}"));
}
#[test]
fn a_report_of_an_untagged_error_is_other_with_no_chain() {
let report = ErrorReport::new(&anyhow!("boom"));
assert_eq!(report.code, 8);
assert_eq!(report.kind, "other");
assert_eq!(report.message, "boom");
assert!(report.chain.is_empty());
}
#[test]
fn a_report_names_its_own_level_and_kind() {
for report in [
ErrorReport::new(&anyhow!("boom")),
ErrorReport::usage("bad flag"),
] {
let object = serde_json::to_value(&report).unwrap();
assert_eq!(object["level"], report.level());
assert_eq!(object["kind"], report.kind());
}
}
#[test]
fn a_usage_report_has_no_chain() {
let report = ErrorReport::usage("bad flag");
assert_eq!((report.code, report.kind), (2, "usage"));
assert!(report.chain.is_empty());
}
#[test]
fn json_is_one_line_in_field_order() {
let err = ExitKind::Gate.error("it said \"no\"\nreally");
let json = ErrorReport::new(&err).to_json();
assert!(!json.contains('\n'), "{json}");
assert_eq!(
json,
r#"{"level":"error","code":1,"kind":"gate","message":"it said \"no\"\nreally","chain":[]}"#
);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["message"], "it said \"no\"\nreally");
}
fn failures() -> Vec<GateFailure> {
vec![
GateFailure::FailUnderPatch {
threshold: 80.0,
measured: 66.5,
},
GateFailure::FailUnderLines {
threshold: 90.0,
measured: Some(40.0),
},
GateFailure::FailUnderLines {
threshold: 50.0,
measured: None,
},
GateFailure::FailOnUnmeasured {
files: vec!["a.rs".into(), "b.rs".into()],
},
]
}
#[test]
fn a_gate_failure_displays_as_its_message() {
let messages: Vec<String> = failures().iter().map(ToString::to_string).collect();
assert_eq!(
messages,
[
"patch coverage 66.50% is below the --fail-under-patch threshold of 80.00%",
"line coverage 40.00% is below the --fail-under-lines threshold of 90.00%",
"the report has no executable lines, so the --fail-under-lines threshold of 50.00% cannot be met",
"touched files absent from every coverage report (--fail-on-unmeasured / diff.require-measured): a.rs, b.rs",
]
);
}
#[test]
fn a_gate_error_joins_the_messages_and_keeps_the_data() {
let err = gate_error(failures());
assert_eq!(code(&err), 1);
let text = format!("{err:#}");
assert_eq!(text.matches("; ").count(), 3, "{text}");
assert!(text.starts_with("patch coverage 66.50%"), "{text}");
assert_eq!(tag_of(&err).unwrap().gates(), failures());
}
#[test]
fn a_gate_report_lists_the_gates() {
let report = ErrorReport::new(&gate_error(failures()));
let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
assert_eq!(
json["gates"],
serde_json::json!([
{"gate": "fail-under-patch", "threshold": 80.0, "measured": 66.5},
{"gate": "fail-under-lines", "threshold": 90.0, "measured": 40.0},
{"gate": "fail-under-lines", "threshold": 50.0, "measured": null},
{"gate": "fail-on-unmeasured", "files": ["a.rs", "b.rs"]},
])
);
let line = report.to_json();
assert!(
line.contains(
r#""gates":[{"gate":"fail-under-patch","threshold":80.0,"measured":66.5}"#
),
"{line}"
);
assert!(!line.contains('\n'), "{line}");
}
#[test]
fn measured_values_are_not_rounded() {
let err = gate_error(vec![GateFailure::FailUnderPatch {
threshold: 80.0,
measured: 79.996,
}]);
let json: serde_json::Value =
serde_json::from_str(&ErrorReport::new(&err).to_json()).unwrap();
assert_eq!(json["gates"][0]["measured"], 79.996);
assert!(err.to_string().contains("80.00% is below"), "{err}");
}
#[test]
fn only_a_gate_with_data_has_gates() {
for err in [
ExitKind::Gate.error("no data"),
fail(ExitKind::Report),
anyhow!("boom"),
] {
let report = ErrorReport::new(&err);
assert_eq!(report.gates, None);
assert!(!report.to_json().contains("gates"));
}
assert_eq!(ErrorReport::usage("bad flag").gates, None);
}
#[test]
fn the_gates_survive_added_context() {
let err = Err::<(), _>(gate_error(failures()))
.context("outer")
.classify(ExitKind::Other)
.unwrap_err();
let report = ErrorReport::new(&err);
assert_eq!(report.kind, "gate");
assert_eq!(report.gates, Some(failures()));
}
#[test]
fn an_untagged_error_is_other() {
assert_eq!(code(&anyhow!("boom")), ExitKind::Other.code());
}
#[test]
fn the_tag_survives_added_context() {
let err = Err::<(), _>(fail(ExitKind::Git))
.context("while resolving")
.unwrap_err();
assert_eq!(code(&err), ExitKind::Git.code());
}
#[test]
fn the_innermost_class_wins() {
let err = Err::<(), _>(fail(ExitKind::Report))
.classify(ExitKind::Other)
.unwrap_err();
assert_eq!(code(&err), ExitKind::Report.code());
let err = Err::<(), _>(fail(ExitKind::Report))
.context("outer")
.classify(ExitKind::Config)
.unwrap_err();
assert_eq!(code(&err), ExitKind::Report.code());
}
#[test]
fn the_message_and_chain_are_unchanged() {
let build = || {
Err::<(), _>(std::io::Error::other("disk on fire"))
.context("could not read it")
.context("outer")
.unwrap_err()
};
let plain = build();
let tagged = Err::<(), _>(build())
.classify(ExitKind::Report)
.unwrap_err();
assert_eq!(format!("{plain:#}"), format!("{tagged:#}"));
assert_eq!(format!("{plain}"), format!("{tagged}"));
assert_eq!(plain.chain().count(), tagged.chain().count());
}
#[test]
fn a_std_error_can_be_classified() {
let err = Err::<(), _>(std::io::Error::other("x"))
.classify(ExitKind::Other)
.unwrap_err();
assert_eq!(code(&err), ExitKind::Other.code());
assert_eq!(err.to_string(), "x");
}
const KINDS: usize = 8;
const fn kind_ordinal(kind: ExitKind) -> usize {
match kind {
ExitKind::Gate => 0,
ExitKind::Usage => 1,
ExitKind::Report => 2,
ExitKind::Marker => 3,
ExitKind::Config => 4,
ExitKind::Git => 5,
ExitKind::PathMismatch => 6,
ExitKind::Other => 7,
}
}
fn every_kind() -> [ExitKind; KINDS] {
[
ExitKind::Gate,
ExitKind::Usage,
ExitKind::Report,
ExitKind::Marker,
ExitKind::Config,
ExitKind::Git,
ExitKind::PathMismatch,
ExitKind::Other,
]
}
#[test]
fn every_kind_has_a_sample() {
assert_every_variant(every_kind().map(kind_ordinal), KINDS, "ExitKind");
}
#[test]
fn the_kind_table_matches_exit_kind() {
let mut documented = kind_codes(reference_section("Error output"));
documented.sort();
let mut actual: Vec<_> = every_kind()
.iter()
.map(|kind| (kind.name().to_owned(), kind.code()))
.collect();
actual.sort();
assert_eq!(documented, actual);
}
#[test]
fn the_exit_code_table_lists_every_code() {
let mut want = vec![0];
want.extend(every_kind().iter().map(|kind| kind.code()));
let mut listed: Vec<u8> = first_table_fields(reference_section("Exit codes"))
.iter()
.map(|code| code.parse().unwrap())
.collect();
listed.sort_unstable();
want.sort_unstable();
assert_eq!(listed, want);
}
fn with_chain(kind: ExitKind) -> anyhow::Error {
Err::<(), _>(anyhow!("root"))
.context("middle")
.classify(kind)
.unwrap_err()
}
#[test]
fn the_error_object_matches_the_docs_table() {
let section = reference_section("Error output");
let rows = first_table_rows(section);
let names: Vec<_> = rows.iter().map(|f| f.name.as_str()).collect();
let without_gates: Vec<_> = names.iter().copied().filter(|n| *n != "gates").collect();
let ordinary_rows: Vec<Field> = rows
.iter()
.filter(|field| field.name != "gates")
.cloned()
.collect();
let mut reports: Vec<ErrorReport> = every_kind()
.iter()
.filter(|kind| **kind != ExitKind::Gate)
.map(|kind| ErrorReport::new(&with_chain(*kind)))
.collect();
reports.push(ErrorReport::usage("bad flag"));
reports.push(ErrorReport::new(&anyhow!("boom")));
let codes = kind_codes(section);
let gate_report = ErrorReport::new(&gate_error(failures()));
for report in reports.iter().chain([&gate_report]) {
let pair = (report.kind.to_owned(), report.code);
assert!(codes.contains(&pair), "{pair:?} is not in {codes:?}");
}
for report in &reports {
let line = report.to_json();
assert_eq!(json_keys(&line), without_gates, "{line}");
let value = serde_json::from_str(&line).unwrap();
check_fields(&ordinary_rows, &value, &line);
}
assert!(reports.iter().any(|r| !r.chain.is_empty()));
assert!(reports.iter().any(|r| r.chain.is_empty()));
let gate = gate_report.to_json();
assert_eq!(json_keys(&gate), names, "{gate}");
check_fields(&rows, &serde_json::from_str(&gate).unwrap(), &gate);
}
#[test]
fn the_error_examples_match_the_docs_table() {
let section = reference_section("Error output");
let names = first_table_fields(section);
let ordinary: Vec<_> = names.iter().filter(|n| *n != "gates").cloned().collect();
assert_eq!(example_keys(section, "report"), Some(ordinary));
assert_eq!(example_keys(section, "gate"), Some(names));
}
const GATES: usize = 3;
const fn gate_ordinal(gate: &GateFailure) -> usize {
match gate {
GateFailure::FailUnderPatch { .. } => 0,
GateFailure::FailUnderLines { .. } => 1,
GateFailure::FailOnUnmeasured { .. } => 2,
}
}
#[test]
fn every_gate_has_a_sample() {
assert_every_variant(failures().iter().map(gate_ordinal), GATES, "GateFailure");
}
#[test]
fn the_gate_table_matches_gate_failure() {
let documented = gate_rows(reference_section("Error output"));
let mut documented_names: Vec<_> = documented.iter().map(|(g, _)| g.as_str()).collect();
documented_names.sort_unstable();
let samples = failures();
let mut seen = Vec::new();
for failure in &samples {
let value = serde_json::to_value(failure).unwrap();
let gate = value["gate"].as_str().unwrap().to_owned();
let (_, fields) = documented
.iter()
.find(|(g, _)| *g == gate)
.unwrap_or_else(|| panic!("the docs list no gate `{gate}`"));
let keys = json_keys(&serde_json::to_string(failure).unwrap());
let want: Vec<_> = std::iter::once("gate")
.chain(fields.iter().map(|(name, _)| name.as_str()))
.collect();
assert_eq!(keys, want, "{gate}");
for (name, ty) in fields {
check_type(ty, &value[name], &format!("{gate}: `{name}`"));
}
seen.push(gate);
}
seen.sort_unstable();
seen.dedup();
assert_eq!(seen, documented_names);
}
struct Fails;
impl Serialize for Fails {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("not serializable"))
}
}
#[test]
fn json_with_prefers_the_supplied_record() {
let report = ErrorReport::usage("bad flag");
assert_eq!(report.json_with(&report), report.to_json());
assert_eq!(
report.json_with(&serde_json::json!({ "other": 1 })),
r#"{"other":1}"#
);
}
#[test]
fn fallback_keeps_level_code_kind_and_message() {
let err = ExitKind::Config.error("bad \"config\"\nline");
let config = ErrorReport::new(&err);
assert_eq!(
config.json_with(&Fails),
r#"{"chain":[],"code":5,"kind":"config","level":"error","message":"bad \"config\"\nline"}"#
);
let usage = ErrorReport::usage("bad flag");
assert_eq!(
usage.json_with(&Fails),
r#"{"chain":[],"code":2,"kind":"usage","level":"error","message":"bad flag"}"#
);
}
#[test]
fn fallback_keeps_chain_and_gates() {
let gate = gate_error(failures()).context("gate check failed");
let ordinary = ExitKind::Report
.error("could not read \"report\"\nnext line")
.context("outer error");
for report in [ErrorReport::new(&gate), ErrorReport::new(&ordinary)] {
let line = report.json_with(&Fails);
assert!(!line.contains('\n'), "{line}");
let fallback: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(fallback, serde_json::to_value(&report).unwrap());
assert!(!fallback["chain"].as_array().unwrap().is_empty());
}
}
}