mod line_wrap;
use std::borrow::Cow;
use std::error::Error;
use std::fmt;
use std::iter;
use owo_colors::{AnsiColors, DynColor, OwoColorize};
use line_wrap::{get_wrap_width, wrap_text};
pub trait Hinted {
fn hints(&self) -> Hints<'_> {
Hints::none()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HintOrdering {
First,
#[default]
Any,
Last,
}
pub struct Hint<'a> {
message: Cow<'a, str>,
ordering: HintOrdering,
}
impl<'a> Hint<'a> {
pub fn new(message: impl Into<Cow<'a, str>>) -> Self {
Self {
message: message.into(),
ordering: HintOrdering::default(),
}
}
#[must_use]
pub fn with_ordering(mut self, ordering: HintOrdering) -> Self {
self.ordering = ordering;
self
}
fn into_owned(self) -> Hint<'static> {
Hint {
message: Cow::Owned(self.message.into_owned()),
ordering: self.ordering,
}
}
}
impl<'a> From<&'a str> for Hint<'a> {
fn from(message: &'a str) -> Self {
Self::new(message)
}
}
impl From<String> for Hint<'_> {
fn from(message: String) -> Self {
Self::new(message)
}
}
pub struct Hints<'a>(Vec<Hint<'a>>);
impl<'a> Hints<'a> {
pub fn none() -> Self {
Self(Vec::new())
}
pub fn push(&mut self, hint: impl Into<Hint<'a>>) {
self.0.push(hint.into());
}
#[must_use]
pub fn with_ordering(mut self, ordering: HintOrdering) -> Self {
for hint in &mut self.0 {
hint.ordering = ordering;
}
self
}
pub fn into_owned(self) -> Hints<'static> {
Hints(self.0.into_iter().map(Hint::into_owned).collect())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> HintsIter<'_, 'a> {
HintsIter {
hints: &self.0,
current: self.0.iter(),
ordering: HintOrdering::First,
remaining: [HintOrdering::Any, HintOrdering::Last].into_iter(),
}
}
pub fn extend(&mut self, other: Hints<'_>) {
for hint in other.0 {
if let Some(existing) = self
.0
.iter_mut()
.find(|existing| existing.message == hint.message)
{
existing.ordering = existing.ordering.min(hint.ordering);
} else {
self.0.push(hint.into_owned());
}
}
}
}
pub struct HintsIter<'h, 'a> {
hints: &'h [Hint<'a>],
current: std::slice::Iter<'h, Hint<'a>>,
ordering: HintOrdering,
remaining: std::array::IntoIter<HintOrdering, 2>,
}
impl<'h> Iterator for HintsIter<'h, '_> {
type Item = &'h str;
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(hint) = self.current.find(|hint| hint.ordering == self.ordering) {
return Some(hint.message.as_ref());
}
self.ordering = self.remaining.next()?;
self.current = self.hints.iter();
}
}
}
impl<'h, 'a> IntoIterator for &'h Hints<'a> {
type Item = &'h str;
type IntoIter = HintsIter<'h, 'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl<'a> IntoIterator for Hints<'a> {
type Item = Cow<'a, str>;
type IntoIter = std::iter::Map<std::vec::IntoIter<Hint<'a>>, fn(Hint<'a>) -> Cow<'a, str>>;
fn into_iter(mut self) -> Self::IntoIter {
self.0.sort_by_key(|hint| hint.ordering);
self.0.into_iter().map(|hint| hint.message)
}
}
pub struct ErrorWithHints<'a, E> {
error: E,
hints: Hints<'a>,
}
impl<'a, E> ErrorWithHints<'a, E> {
pub fn new(error: E, hints: Hints<'a>) -> Self {
Self { error, hints }
}
}
impl<E: fmt::Display> fmt::Display for ErrorWithHints<'_, E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if !self.hints.is_empty() {
writeln!(f)?;
write!(f, "{}", self.hints)?;
}
Ok(())
}
}
impl<'a> From<&'a str> for Hints<'a> {
fn from(hint: &'a str) -> Self {
Self::from(Hint::from(hint))
}
}
impl From<String> for Hints<'_> {
fn from(hint: String) -> Self {
Self::from(Hint::from(hint))
}
}
impl<'a> From<Hint<'a>> for Hints<'a> {
fn from(hint: Hint<'a>) -> Self {
Self(vec![hint])
}
}
impl<'a, T: Into<Hint<'a>>> FromIterator<T> for Hints<'a> {
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
Self(iter.into_iter().map(Into::into).collect())
}
}
impl fmt::Display for Hints<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for hint in self {
write!(f, "\n{HintPrefix} {hint}")?;
}
Ok(())
}
}
pub struct HintPrefix;
impl fmt::Display for HintPrefix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", "hint".bold().cyan(), ":".bold())
}
}
#[must_use]
pub struct ErrorOptions<'a, C = AnsiColors, W = Stderr> {
level: Cow<'a, str>,
color: C,
width_override: Option<usize>,
stream: W,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Stderr;
impl fmt::Write for Stderr {
fn write_str(&mut self, output: &str) -> fmt::Result {
anstream::eprint!("{output}");
Ok(())
}
}
impl Default for ErrorOptions<'_, AnsiColors, Stderr> {
fn default() -> Self {
Self {
level: Cow::Borrowed("error"),
color: AnsiColors::Red,
width_override: None,
stream: Stderr,
}
}
}
impl<'a, C, W> ErrorOptions<'a, C, W> {
pub fn with_level(mut self, level: impl Into<Cow<'a, str>>) -> Self {
self.level = level.into();
self
}
pub fn with_color<D>(self, color: D) -> ErrorOptions<'a, D, W> {
ErrorOptions {
level: self.level,
color,
width_override: self.width_override,
stream: self.stream,
}
}
#[cfg(test)]
fn with_width_override(mut self, width_override: usize) -> Self {
self.width_override = Some(width_override);
self
}
pub fn with_stream<D>(self, stream: D) -> ErrorOptions<'a, C, D> {
ErrorOptions {
level: self.level,
color: self.color,
width_override: self.width_override,
stream,
}
}
}
pub fn write_error_chain(err: &dyn Error, hints: &Hints<'_>) -> fmt::Result {
write_error_chain_with_options(err, hints, ErrorOptions::default())
}
pub fn debug_error_chain(err: &dyn Error) -> impl fmt::Display + '_ {
DebugErrorChain(err)
}
struct DebugErrorChain<'a>(&'a dyn Error);
impl fmt::Display for DebugErrorChain<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, error) in iter::successors(Some(self.0), |&error| error.source()).enumerate() {
if index > 0 {
formatter.write_str("\n")?;
}
write!(formatter, "{index}: {error:?}")?;
}
Ok(())
}
}
pub fn write_error_chain_with_options<C: DynColor + Copy, W: fmt::Write>(
err: &dyn Error,
hints: &Hints<'_>,
options: ErrorOptions<'_, C, W>,
) -> fmt::Result {
let ErrorOptions {
level,
color,
width_override,
mut stream,
} = options;
let width = get_wrap_width(width_override);
let main_msg = err.to_string();
let main_padding = " ".repeat(level.len() + 2);
let wrapped_main = wrap_text(&main_msg, width, &main_padding, &main_padding, "");
writeln!(
&mut stream,
"{}{} {}",
level.as_ref().color(color).bold(),
":".bold(),
wrapped_main.trim()
)?;
for source in iter::successors(err.source(), |&err| err.source()) {
let msg = source.to_string();
let wrapped = wrap_text(&msg, width.map(|width| width.saturating_sub(9)), "", "", "");
let mut lines = wrapped.lines();
if let Some(first) = lines.next() {
writeln!(
&mut stream,
" {}{} {}",
"cause".color(color).bold(),
":".bold(),
first.trim()
)?;
for line in lines {
if line.trim().is_empty() {
writeln!(&mut stream)?;
} else {
writeln!(&mut stream, " {line}")?;
}
}
}
}
for hint in hints {
writeln!(&mut stream, "\n{HintPrefix} {hint}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use indoc::indoc;
use insta::{assert_debug_snapshot, assert_snapshot};
use owo_colors::AnsiColors;
use super::{
ErrorOptions, ErrorWithHints, Hint, HintOrdering, Hints, debug_error_chain,
write_error_chain_with_options,
};
#[test]
fn extend_deduplicates_matching_hints() {
let mut hints = Hints::from("same");
hints.extend(Hints::from("same"));
hints.extend(Hints::from("other"));
let hints = hints.iter().collect::<Vec<_>>();
assert_debug_snapshot!(hints, @r#"
[
"same",
"other",
]
"#);
}
#[test]
fn hint_ordering_retains_insertion_order() {
let mut hints = Hints::from("any 1");
hints.push(Hint::new("last 1").with_ordering(HintOrdering::Last));
hints.push(Hint::new("first 1").with_ordering(HintOrdering::First));
hints.push("any 2".to_string());
hints.extend(Hints::from("last 2").with_ordering(HintOrdering::Last));
hints.extend(Hints::from("first 2").with_ordering(HintOrdering::First));
assert_snapshot!(anstream::adapter::strip_str(&hints.to_string()), @"
hint: first 1
hint: first 2
hint: any 1
hint: any 2
hint: last 1
hint: last 2
");
assert_debug_snapshot!((&hints).into_iter().collect::<Vec<_>>(), @r#"
[
"first 1",
"first 2",
"any 1",
"any 2",
"last 1",
"last 2",
]
"#);
assert_debug_snapshot!(hints.into_iter().collect::<Vec<_>>(), @r#"
[
"first 1",
"first 2",
"any 1",
"any 2",
"last 1",
"last 2",
]
"#);
}
#[test]
fn changing_ordering_retains_insertion_order() {
let hints = [
Hint::new("last").with_ordering(HintOrdering::Last),
Hint::new("first").with_ordering(HintOrdering::First),
Hint::new("any"),
]
.into_iter()
.collect::<Hints<'_>>()
.with_ordering(HintOrdering::Any);
assert_debug_snapshot!(hints.iter().collect::<Vec<_>>(), @r#"
[
"last",
"first",
"any",
]
"#);
}
#[test]
fn duplicate_hints_retain_the_earliest_ordering() {
let message = String::from("shared");
let mut hints = Hints::from(message.as_str())
.with_ordering(HintOrdering::Last)
.into_owned();
drop(message);
hints.extend(Hints::from("first").with_ordering(HintOrdering::First));
hints.extend(Hints::from("shared").with_ordering(HintOrdering::First));
hints.extend(Hints::from("shared").with_ordering(HintOrdering::Last));
hints.extend(Hints::from("any"));
assert_snapshot!(anstream::adapter::strip_str(&hints.to_string()), @"
hint: shared
hint: first
hint: any
");
}
#[test]
fn error_with_hints_separates_hints_from_error() {
let output = ErrorWithHints::new("error", Hints::from("fix it")).to_string();
assert_snapshot!(anstream::adapter::strip_str(&output), @"
error
hint: fix it
");
assert_snapshot!(ErrorWithHints::new("error", Hints::none()), @"error");
}
#[test]
fn test_error_wrapping_with_columns() {
#[derive(Debug, thiserror::Error)]
#[error(
"Because fiasobfhuasbf was not found in the package registry and you require fiasobfhuasbf, we can conclude that your requirements are unsatisfiable."
)]
struct Inner;
#[derive(Debug, thiserror::Error)]
#[error("No solution found when resolving dependencies")]
struct Outer {
#[source]
source: Inner,
}
let error = Outer { source: Inner };
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(80)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @"
error: No solution found when resolving dependencies
cause: Because fiasobfhuasbf was not found in the package registry and you
require fiasobfhuasbf, we can conclude that your requirements are
unsatisfiable.
");
}
#[test]
fn test_error_chain_with_cause() {
#[derive(Debug, thiserror::Error)]
#[error("Permission denied")]
struct Inner;
#[derive(Debug, thiserror::Error)]
#[error("Failed to write file")]
struct Outer {
#[source]
source: Inner,
}
let error = Outer { source: Inner };
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default().with_stream(&mut output),
)
.unwrap();
assert_snapshot!(format!("{output:?}"), @r#""\u{1b}[1m\u{1b}[31merror\u{1b}[39m\u{1b}[0m\u{1b}[1m:\u{1b}[0m Failed to write file\n \u{1b}[1m\u{1b}[31mcause\u{1b}[39m\u{1b}[0m\u{1b}[1m:\u{1b}[0m Permission denied\n""#);
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @"
error: Failed to write file
cause: Permission denied
");
}
#[test]
fn formats_debug_error_chain() {
#[derive(Debug, thiserror::Error)]
#[error("inner error")]
struct InnerError {
code: u8,
}
#[derive(Debug, thiserror::Error)]
#[error("outer error")]
struct OuterError {
#[source]
source: InnerError,
}
let error = OuterError {
source: InnerError { code: 42 },
};
assert_eq!(
debug_error_chain(&error).to_string(),
"0: OuterError { source: InnerError { code: 42 } }\n1: InnerError { code: 42 }"
);
}
#[test]
fn format_with_custom_level() {
let error = anyhow!("Failed to create registry entry");
let mut output = String::new();
write_error_chain_with_options(
error.as_ref(),
&Hints::none(),
ErrorOptions::default()
.with_level("warning")
.with_color(AnsiColors::Yellow)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @"warning: Failed to create registry entry
");
}
#[test]
fn test_no_hyphenation() {
#[derive(Debug, thiserror::Error)]
#[error(
"Failed to download package from https://files.pythonhosted.org/packages/verylongpackagename"
)]
struct LongWord;
let error = LongWord;
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(50)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @r"
error: Failed to download package from
https://files.pythonhosted.org/packages/verylongpackagename
");
}
#[test]
fn test_long_words_not_broken() {
#[derive(Debug, thiserror::Error)]
#[error(
"The package supercalifragilisticexpialidocious-extraordinarily-long-name was not found"
)]
struct VeryLongWord;
let error = VeryLongWord;
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(40)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @r"
error: The package
supercalifragilisticexpialidocious-extraordinarily-long-name
was not found
");
}
#[test]
fn test_multiple_error_sources() {
#[derive(Debug, thiserror::Error)]
#[error("Network connection timeout after multiple retry attempts")]
struct DeepError;
#[derive(Debug, thiserror::Error)]
#[error("Failed to fetch package metadata from registry")]
struct MiddleError {
#[source]
source: DeepError,
}
#[derive(Debug, thiserror::Error)]
#[error("Unable to resolve package dependencies")]
struct TopError {
#[source]
source: MiddleError,
}
let error = TopError {
source: MiddleError { source: DeepError },
};
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(40)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @"
error: Unable to resolve package
dependencies
cause: Failed to fetch package
metadata from registry
cause: Network connection timeout
after multiple retry attempts
");
}
#[test]
fn format_cause_with_narrow_width() {
let error = anyhow!("one two").context("root");
let mut output = String::new();
write_error_chain_with_options(
error.as_ref(),
&Hints::none(),
ErrorOptions::default()
.with_width_override(4)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @"
error: root
cause: one
two
");
}
#[test]
fn test_multiline_main_message_wraps_each_line() {
#[derive(Debug, thiserror::Error)]
#[error(
"There is no command `foobar` for `uv`. Did you mean one of:\n auth\n run\n init"
)]
struct Suggestions;
let error = Suggestions;
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(50)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @r"
error: There is no command `foobar` for `uv`. Did
you mean one of:
auth
run
init
");
}
#[test]
fn test_wrap_only_on_ascii_space() {
#[derive(Debug, thiserror::Error)]
#[error("Path /usr/local/lib/python3.12/site-packages not found in filesystem hierarchy")]
struct SpecialChars;
let error = SpecialChars;
let mut output = String::new();
write_error_chain_with_options(
&error,
&Hints::none(),
ErrorOptions::default()
.with_width_override(50)
.with_stream(&mut output),
)
.unwrap();
let output = anstream::adapter::strip_str(&output);
assert_snapshot!(output, @r"
error: Path /usr/local/lib/python3.12/site-packages
not found in filesystem hierarchy
");
}
#[test]
fn format_with_hints() {
let err = anyhow!("Permission denied").context("Failed to fetch package");
let hints = [
"Try running with `--verbose` for more information.".to_string(),
"Try running without --offline.".to_string(),
]
.into_iter()
.collect();
let mut rendered = String::new();
write_error_chain_with_options(
err.as_ref(),
&hints,
ErrorOptions::default().with_stream(&mut rendered),
)
.unwrap();
let rendered = anstream::adapter::strip_str(&rendered);
assert_snapshot!(rendered, @"
error: Failed to fetch package
cause: Permission denied
hint: Try running with `--verbose` for more information.
hint: Try running without --offline.
");
}
#[test]
fn format_multiline_message() {
let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst
Server says: This endpoint only support POST requests.
For downloads, please refer to https://example.com/download/python3.13.tar.zst"};
let err = anyhow!("Caused By: HTTP Error 400")
.context(err_middle)
.context("Failed to download Python 3.12");
let mut rendered = String::new();
write_error_chain_with_options(
err.as_ref(),
&Hints::none(),
ErrorOptions::default().with_stream(&mut rendered),
)
.unwrap();
let rendered = anstream::adapter::strip_str(&rendered);
assert_snapshot!(rendered, @"
error: Failed to download Python 3.12
cause: Failed to fetch https://example.com/upload/python3.13.tar.zst
Server says: This endpoint only support POST requests.
For downloads, please refer to https://example.com/download/python3.13.tar.zst
cause: Caused By: HTTP Error 400
");
}
}