const DETAIL_PREFIX: &str = "- ";
const DETAIL_SEPARATOR: &str = "\n- ";
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct StructuredError {
pub summary: String,
pub details: Vec<String>,
}
impl StructuredError {
pub fn from_summary(summary: impl Into<String>) -> Self {
Self {
summary: summary.into(),
details: Vec::new(),
}
}
pub fn parse(message: impl AsRef<str>) -> Self {
let message = message.as_ref().trim();
if let Some(details) = message.strip_prefix(DETAIL_PREFIX) {
return Self::from_summary("").with_detail(details);
}
match message.split_once(DETAIL_SEPARATOR) {
Some((summary, details)) => Self::from_summary(summary.trim_end()).with_detail(details),
None => Self::from_summary(message),
}
}
#[inline]
pub fn with_detail(mut self, detail: impl AsRef<str>) -> Self {
self.add_detail(detail);
self
}
#[inline]
pub fn with_details(mut self, details: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
self.add_details(details);
self
}
pub fn add_detail(&mut self, detail: impl AsRef<str>) {
let detail = detail.as_ref().trim();
let detail = detail.strip_prefix(DETAIL_PREFIX).unwrap_or(detail);
for part in detail.split(DETAIL_SEPARATOR) {
let part = part.trim();
if !part.is_empty() && !self.details.iter().any(|seen| seen == part) {
self.details.push(part.to_owned());
}
}
}
pub fn add_details(&mut self, details: impl IntoIterator<Item = impl AsRef<str>>) {
for detail in details {
self.add_detail(detail);
}
}
pub fn concat(mut self, inner: impl Into<Self>) -> Self {
let Self { summary, details } = inner.into();
if self.summary.is_empty() {
self.summary = summary;
} else if !summary.is_empty() {
self.summary.push_str(": ");
self.summary.push_str(&summary);
}
self.add_details(details);
self
}
pub fn details_joined(&self) -> Option<String> {
(!self.details.is_empty()).then(|| {
self.details
.iter()
.map(|detail| format!("{DETAIL_PREFIX}{detail}"))
.collect::<Vec<_>>()
.join("\n")
})
}
}
impl<Rhs: Into<Self>> std::ops::Add<Rhs> for StructuredError {
type Output = Self;
fn add(self, inner: Rhs) -> Self {
self.concat(inner)
}
}
impl std::fmt::Display for StructuredError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self { summary, details } = self;
f.write_str(summary)?;
for (i, detail) in details.iter().enumerate() {
if !summary.is_empty() || 0 < i {
f.write_str("\n")?;
}
write!(f, "{DETAIL_PREFIX}{detail}")?;
}
Ok(())
}
}
impl std::str::FromStr for StructuredError {
type Err = std::convert::Infallible;
fn from_str(message: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(message))
}
}
impl From<&str> for StructuredError {
fn from(message: &str) -> Self {
Self::parse(message)
}
}
impl From<String> for StructuredError {
fn from(message: String) -> Self {
Self::parse(message)
}
}
impl From<StructuredError> for String {
fn from(error: StructuredError) -> Self {
error.to_string()
}
}
pub fn format_with_details(error: impl AsRef<str>, details: impl AsRef<str>) -> String {
StructuredError::parse(error)
.with_detail(details)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_with_details() {
assert_eq!(
format_with_details("Error", "The fine print"),
"Error\n\
- The fine print"
);
assert_eq!(format_with_details("Error", ""), "Error");
assert_eq!(
format_with_details("Error\n- from the source", "The fine print"),
"Error\n\
- from the source\n\
- The fine print"
);
assert_eq!(
format_with_details("Error", "trace-id: 42\n- metadata: {}"),
"Error\n\
- trace-id: 42\n\
- metadata: {}"
);
}
#[test]
fn test_format_with_details_deduplicates() {
assert_eq!(
format_with_details(
"outer\n- Server: rerun://example.com:443\n- outer detail",
"Server: rerun://example.com:443",
),
"outer\n\
- Server: rerun://example.com:443\n\
- outer detail"
);
}
#[test]
fn test_display() {
assert_eq!(
StructuredError::parse("Error")
.with_details(["trace-id: 42", "metadata: {}"])
.to_string(),
"Error\n\
- trace-id: 42\n\
- metadata: {}"
);
assert_eq!(StructuredError::parse("Error").to_string(), "Error");
}
#[test]
fn test_parse() {
for (in_summary, in_details) in [
("just a message", vec![]),
("message", vec!["the fine print"]),
("message", vec!["first", "second"]),
] {
let combined = StructuredError::parse(in_summary)
.with_details(&in_details)
.to_string();
let error = StructuredError::parse(&combined);
assert_eq!(error.summary, in_summary);
assert_eq!(error.details, in_details);
}
let error = StructuredError::parse("just a message\nspanning two lines");
assert_eq!(error.summary, "just a message\nspanning two lines");
assert!(error.details.is_empty());
}
#[test]
fn test_round_trip() {
for message in [
"just a message",
"message\n- the fine print",
"message\n- first\n- second",
"- a detail without a summary",
] {
let error = StructuredError::parse(message);
assert_eq!(error.to_string(), message);
assert_eq!(StructuredError::parse(error.to_string()), error);
}
let error = StructuredError::from_summary("").with_detail("the fine print");
assert_eq!(StructuredError::parse(error.to_string()), error);
}
#[test]
fn test_parse_corner_cases() {
let error = StructuredError::parse("message \n- first \n\n \n- second\n");
assert_eq!(error.summary, "message");
assert_eq!(error.details, ["first", "second"]);
let error = StructuredError::parse("message\n- first\nstill first");
assert_eq!(error.summary, "message");
assert_eq!(error.details, ["first\nstill first"]);
let error = StructuredError::parse("message\n- first\n- second");
assert_eq!(error.details, ["first", "second"]);
let error = StructuredError::parse("a - b");
assert_eq!(error.summary, "a - b");
assert!(error.details.is_empty());
let error = StructuredError::parse("message\n-tick");
assert_eq!(error.summary, "message\n-tick");
assert!(error.details.is_empty());
let error = StructuredError::parse("- a detail without a summary");
assert_eq!(error.summary, "");
assert_eq!(error.details, ["a detail without a summary"]);
let error = StructuredError::parse("message").with_detail("- already marked");
assert_eq!(error.to_string(), "message\n- already marked");
}
#[test]
fn test_details_are_deduplicated() {
let error = StructuredError::parse("message")
.with_detail("same")
.with_details(["same", "other", "same"]);
assert_eq!(error.details, ["same", "other"]);
}
fn empty_error() -> StructuredError {
StructuredError::parse("")
}
#[test]
fn test_concat() {
let outer =
StructuredError::parse("outer").with_details(["server: example.com", "outer only"]);
let inner = StructuredError::parse("inner\n- server: example.com\n- inner only");
let combined = outer.clone() + inner;
assert_eq!(combined.summary, "outer: inner");
assert_eq!(
combined.details,
["server: example.com", "outer only", "inner only"]
);
assert_eq!(
combined.to_string(),
"outer: inner\n\
- server: example.com\n\
- outer only\n\
- inner only"
);
assert_eq!((empty_error() + outer.clone()).summary, "outer");
assert_eq!((outer.concat(empty_error())).summary, "outer");
assert_eq!(
(StructuredError::parse("outer") + "inner\n- the fine print").to_string(),
"outer: inner\n\
- the fine print"
);
}
#[test]
fn test_details_joined() {
assert_eq!(StructuredError::parse("message").details_joined(), None);
assert_eq!(
StructuredError::parse("message")
.with_details(["a", "b"])
.details_joined(),
Some("- a\n- b".to_owned())
);
}
}