1mod line_wrap;
2
3use std::borrow::Cow;
4use std::error::Error;
5use std::fmt;
6use std::iter;
7
8use owo_colors::{AnsiColors, DynColor, OwoColorize};
9
10use line_wrap::{get_wrap_width, wrap_text};
11
12pub trait Hint {
18 fn hints(&self) -> Hints<'_> {
20 Hints::none()
21 }
22}
23
24pub struct Hints<'a>(Vec<Cow<'a, str>>);
28
29impl Hints<'_> {
30 pub fn none() -> Self {
32 Self(Vec::new())
33 }
34
35 pub fn push(&mut self, hint: String) {
37 self.0.push(Cow::Owned(hint));
38 }
39
40 pub fn into_owned(self) -> Hints<'static> {
42 Hints(
43 self.0
44 .into_iter()
45 .map(|cow| Cow::Owned(cow.into_owned()))
46 .collect(),
47 )
48 }
49
50 pub fn is_empty(&self) -> bool {
52 self.0.is_empty()
53 }
54
55 pub fn extend(&mut self, other: Hints<'_>) {
57 for hint in other.0 {
58 let hint = Cow::Owned(hint.into_owned());
59 if !self.0.iter().any(|existing| existing == &hint) {
60 self.0.push(hint);
61 }
62 }
63 }
64}
65
66pub struct ErrorWithHints<'a, E> {
71 error: E,
72 hints: Hints<'a>,
73}
74
75impl<'a, E> ErrorWithHints<'a, E> {
76 pub fn new(error: E, hints: Hints<'a>) -> Self {
78 Self { error, hints }
79 }
80}
81
82impl<E: fmt::Display> fmt::Display for ErrorWithHints<'_, E> {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(f, "{}", self.error)?;
85 if !self.hints.is_empty() {
86 writeln!(f)?;
87 write!(f, "{}", self.hints)?;
88 }
89 Ok(())
90 }
91}
92
93impl<'a> From<&'a str> for Hints<'a> {
94 fn from(hint: &'a str) -> Self {
95 Self(vec![Cow::Borrowed(hint)])
96 }
97}
98
99impl From<String> for Hints<'_> {
100 fn from(hint: String) -> Self {
101 Self(vec![Cow::Owned(hint)])
102 }
103}
104
105impl FromIterator<String> for Hints<'_> {
106 fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
107 Self(iter.into_iter().map(Cow::Owned).collect())
108 }
109}
110
111impl fmt::Display for Hints<'_> {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 for hint in &self.0 {
114 write!(f, "\n{HintPrefix} {hint}")?;
115 }
116 Ok(())
117 }
118}
119
120impl<'a> IntoIterator for Hints<'a> {
121 type Item = Cow<'a, str>;
122 type IntoIter = std::vec::IntoIter<Cow<'a, str>>;
123
124 fn into_iter(self) -> Self::IntoIter {
125 self.0.into_iter()
126 }
127}
128
129pub struct HintPrefix;
131
132impl fmt::Display for HintPrefix {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(f, "{}{}", "hint".bold().cyan(), ":".bold())
135 }
136}
137
138#[must_use]
140pub struct ErrorOptions<'a, C = AnsiColors, W = Stderr> {
141 level: Cow<'a, str>,
142 color: C,
143 hints: Hints<'a>,
144 width_override: Option<usize>,
145 stream: W,
146}
147
148#[derive(Debug, Clone, Copy, Default)]
150pub struct Stderr;
151
152impl fmt::Write for Stderr {
153 fn write_str(&mut self, output: &str) -> fmt::Result {
154 anstream::eprint!("{output}");
155 Ok(())
156 }
157}
158
159impl Default for ErrorOptions<'_, AnsiColors, Stderr> {
160 fn default() -> Self {
161 Self {
162 level: Cow::Borrowed("error"),
163 color: AnsiColors::Red,
164 hints: Hints::none(),
165 width_override: None,
166 stream: Stderr,
167 }
168 }
169}
170
171impl<'a, C, W> ErrorOptions<'a, C, W> {
172 pub fn with_level(mut self, level: impl Into<Cow<'a, str>>) -> Self {
174 self.level = level.into();
175 self
176 }
177
178 pub fn with_color<D>(self, color: D) -> ErrorOptions<'a, D, W> {
180 ErrorOptions {
181 level: self.level,
182 color,
183 hints: self.hints,
184 width_override: self.width_override,
185 stream: self.stream,
186 }
187 }
188
189 pub fn with_hints(mut self, hints: Hints<'a>) -> Self {
191 self.hints = hints;
192 self
193 }
194
195 pub fn with_width_override(mut self, width_override: usize) -> Self {
199 self.width_override = Some(width_override);
200 self
201 }
202
203 pub fn with_stream<D>(self, stream: D) -> ErrorOptions<'a, C, D> {
205 ErrorOptions {
206 level: self.level,
207 color: self.color,
208 hints: self.hints,
209 width_override: self.width_override,
210 stream,
211 }
212 }
213}
214
215pub fn write_error_chain(err: &dyn Error) -> fmt::Result {
217 write_error_chain_with_options(err, ErrorOptions::default())
218}
219
220pub fn write_error_chain_with_options<C: DynColor + Copy, W: fmt::Write>(
224 err: &dyn Error,
225 options: ErrorOptions<'_, C, W>,
226) -> fmt::Result {
227 let ErrorOptions {
228 level,
229 color,
230 hints,
231 width_override,
232 mut stream,
233 } = options;
234 let width = get_wrap_width(width_override);
235
236 let main_msg = err.to_string();
237 let main_padding = " ".repeat(level.len() + 2);
238 let wrapped_main = wrap_text(&main_msg, width, &main_padding, &main_padding);
239 writeln!(
240 &mut stream,
241 "{}{} {}",
242 level.as_ref().color(color).bold(),
243 ":".bold(),
244 wrapped_main.trim()
245 )?;
246
247 for source in iter::successors(err.source(), |&err| err.source()) {
248 let msg = source.to_string();
249 let padding = " ";
250 let cause = "Caused by";
251 let child_padding = " ".repeat(padding.len() + cause.len() + 2);
252
253 let wrapped = wrap_text(&msg, width, "", &child_padding);
254
255 let mut lines = wrapped.lines();
256 if let Some(first) = lines.next() {
257 writeln!(
258 &mut stream,
259 "{}{}: {}",
260 padding,
261 cause.color(color).bold(),
262 first.trim()
263 )?;
264 for line in lines {
265 if line.trim().is_empty() {
266 writeln!(&mut stream)?;
267 } else {
268 writeln!(&mut stream, "{line}")?;
269 }
270 }
271 }
272 }
273
274 for hint in hints {
275 writeln!(&mut stream, "\n{HintPrefix} {hint}")?;
276 }
277
278 Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283 use anyhow::anyhow;
284 use indoc::indoc;
285 use insta::assert_snapshot;
286 use owo_colors::AnsiColors;
287
288 use super::{ErrorOptions, ErrorWithHints, HintPrefix, Hints, write_error_chain_with_options};
289
290 #[test]
291 fn extend_deduplicates_matching_hints() {
292 let mut hints = Hints::from("same");
293 hints.extend(Hints::from("same"));
294 hints.extend(Hints::from("other"));
295
296 let hints = hints
297 .into_iter()
298 .map(std::borrow::Cow::into_owned)
299 .collect::<Vec<_>>();
300 assert_eq!(hints, vec!["same".to_string(), "other".to_string()]);
301 }
302
303 #[test]
304 fn error_with_hints_separates_hints_from_error() {
305 assert_eq!(
306 ErrorWithHints::new("error", Hints::from("fix it")).to_string(),
307 format!("error\n\n{HintPrefix} fix it")
308 );
309 assert_eq!(
310 ErrorWithHints::new("error", Hints::none()).to_string(),
311 "error"
312 );
313 }
314
315 #[test]
316 fn test_error_wrapping_with_columns() {
317 #[derive(Debug, thiserror::Error)]
318 #[error(
319 "Because fiasobfhuasbf was not found in the package registry and you require fiasobfhuasbf, we can conclude that your requirements are unsatisfiable."
320 )]
321 struct Inner;
322
323 #[derive(Debug, thiserror::Error)]
324 #[error("No solution found when resolving dependencies")]
325 struct Outer {
326 #[source]
327 source: Inner,
328 }
329
330 let error = Outer { source: Inner };
331 let mut output = String::new();
332 write_error_chain_with_options(
333 &error,
334 ErrorOptions::default()
335 .with_width_override(80)
336 .with_stream(&mut output),
337 )
338 .unwrap();
339 let output = anstream::adapter::strip_str(&output);
340
341 assert_snapshot!(output, @r"
342 error: No solution found when resolving dependencies
343 Caused by: Because fiasobfhuasbf was not found in the package registry and you require
344 fiasobfhuasbf, we can conclude that your requirements are
345 unsatisfiable.
346 ");
347 }
348
349 #[test]
350 fn test_error_chain_with_cause() {
351 #[derive(Debug, thiserror::Error)]
352 #[error("Permission denied")]
353 struct Inner;
354
355 #[derive(Debug, thiserror::Error)]
356 #[error("Failed to write file")]
357 struct Outer {
358 #[source]
359 source: Inner,
360 }
361
362 let error = Outer { source: Inner };
363 let mut output = String::new();
364 write_error_chain_with_options(&error, ErrorOptions::default().with_stream(&mut output))
365 .unwrap();
366 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}[31mCaused by\u{1b}[39m\u{1b}[0m: Permission denied\n""#);
367 let output = anstream::adapter::strip_str(&output);
368
369 assert_snapshot!(output, @r"
370 error: Failed to write file
371 Caused by: Permission denied
372 ");
373 }
374
375 #[test]
376 fn format_with_custom_level() {
377 let error = anyhow!("Failed to create registry entry");
378 let mut output = String::new();
379 write_error_chain_with_options(
380 error.as_ref(),
381 ErrorOptions::default()
382 .with_level("warning")
383 .with_color(AnsiColors::Yellow)
384 .with_stream(&mut output),
385 )
386 .unwrap();
387 let output = anstream::adapter::strip_str(&output);
388
389 assert_snapshot!(output, @"warning: Failed to create registry entry
390");
391 }
392
393 #[test]
394 fn test_no_hyphenation() {
395 #[derive(Debug, thiserror::Error)]
396 #[error(
397 "Failed to download package from https://files.pythonhosted.org/packages/verylongpackagename"
398 )]
399 struct LongWord;
400
401 let error = LongWord;
402 let mut output = String::new();
403 write_error_chain_with_options(
404 &error,
405 ErrorOptions::default()
406 .with_width_override(50)
407 .with_stream(&mut output),
408 )
409 .unwrap();
410 let output = anstream::adapter::strip_str(&output);
411 assert_snapshot!(output, @r"
412 error: Failed to download package from
413 https://files.pythonhosted.org/packages/verylongpackagename
414 ");
415 }
416
417 #[test]
418 fn test_long_words_not_broken() {
419 #[derive(Debug, thiserror::Error)]
420 #[error(
421 "The package supercalifragilisticexpialidocious-extraordinarily-long-name was not found"
422 )]
423 struct VeryLongWord;
424
425 let error = VeryLongWord;
426 let mut output = String::new();
427 write_error_chain_with_options(
428 &error,
429 ErrorOptions::default()
430 .with_width_override(40)
431 .with_stream(&mut output),
432 )
433 .unwrap();
434 let output = anstream::adapter::strip_str(&output);
435 assert_snapshot!(output, @r"
436 error: The package
437 supercalifragilisticexpialidocious-extraordinarily-long-name
438 was not found
439 ");
440 }
441
442 #[test]
443 fn test_multiple_error_sources() {
444 #[derive(Debug, thiserror::Error)]
445 #[error("Network connection timeout after multiple retry attempts")]
446 struct DeepError;
447
448 #[derive(Debug, thiserror::Error)]
449 #[error("Failed to fetch package metadata from registry")]
450 struct MiddleError {
451 #[source]
452 source: DeepError,
453 }
454
455 #[derive(Debug, thiserror::Error)]
456 #[error("Unable to resolve package dependencies")]
457 struct TopError {
458 #[source]
459 source: MiddleError,
460 }
461
462 let error = TopError {
463 source: MiddleError { source: DeepError },
464 };
465 let mut output = String::new();
466 write_error_chain_with_options(
467 &error,
468 ErrorOptions::default()
469 .with_width_override(60)
470 .with_stream(&mut output),
471 )
472 .unwrap();
473 let output = anstream::adapter::strip_str(&output);
474 assert_snapshot!(output, @r"
475 error: Unable to resolve package dependencies
476 Caused by: Failed to fetch package metadata from registry
477 Caused by: Network connection timeout after multiple retry attempts
478 ");
479 }
480
481 #[test]
482 fn test_multiline_main_message_wraps_each_line() {
483 #[derive(Debug, thiserror::Error)]
484 #[error(
485 "There is no command `foobar` for `uv`. Did you mean one of:\n auth\n run\n init"
486 )]
487 struct Suggestions;
488
489 let error = Suggestions;
490 let mut output = String::new();
491 write_error_chain_with_options(
492 &error,
493 ErrorOptions::default()
494 .with_width_override(50)
495 .with_stream(&mut output),
496 )
497 .unwrap();
498 let output = anstream::adapter::strip_str(&output);
499
500 assert_snapshot!(output, @r"
501 error: There is no command `foobar` for `uv`. Did
502 you mean one of:
503 auth
504 run
505 init
506 ");
507 }
508
509 #[test]
510 fn test_wrap_only_on_ascii_space() {
511 #[derive(Debug, thiserror::Error)]
512 #[error("Path /usr/local/lib/python3.12/site-packages not found in filesystem hierarchy")]
513 struct SpecialChars;
514
515 let error = SpecialChars;
516 let mut output = String::new();
517 write_error_chain_with_options(
518 &error,
519 ErrorOptions::default()
520 .with_width_override(50)
521 .with_stream(&mut output),
522 )
523 .unwrap();
524 let output = anstream::adapter::strip_str(&output);
525 assert_snapshot!(output, @r"
526 error: Path /usr/local/lib/python3.12/site-packages
527 not found in filesystem hierarchy
528 ");
529 }
530
531 #[test]
532 fn format_with_hints() {
533 let err = anyhow!("Permission denied").context("Failed to fetch package");
534
535 let hints = [
536 "Try running with `--verbose` for more information.".to_string(),
537 "Try running without --offline.".to_string(),
538 ]
539 .into_iter()
540 .collect();
541
542 let mut rendered = String::new();
543 write_error_chain_with_options(
544 err.as_ref(),
545 ErrorOptions::default()
546 .with_hints(hints)
547 .with_stream(&mut rendered),
548 )
549 .unwrap();
550 let rendered = anstream::adapter::strip_str(&rendered);
551
552 assert_snapshot!(rendered, @r"
553 error: Failed to fetch package
554 Caused by: Permission denied
555
556 hint: Try running with `--verbose` for more information.
557
558 hint: Try running without --offline.
559 ");
560 }
561
562 #[test]
563 fn format_multiline_message() {
564 let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst
565 Server says: This endpoint only support POST requests.
566
567 For downloads, please refer to https://example.com/download/python3.13.tar.zst"};
568 let err = anyhow!("Caused By: HTTP Error 400")
569 .context(err_middle)
570 .context("Failed to download Python 3.12");
571
572 let mut rendered = String::new();
573 write_error_chain_with_options(
574 err.as_ref(),
575 ErrorOptions::default().with_stream(&mut rendered),
576 )
577 .unwrap();
578 let rendered = anstream::adapter::strip_str(&rendered);
579
580 assert_snapshot!(rendered, @r"
581 error: Failed to download Python 3.12
582 Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst
583 Server says: This endpoint only support POST requests.
584
585 For downloads, please refer to https://example.com/download/python3.13.tar.zst
586 Caused by: Caused By: HTTP Error 400
587 ");
588 }
589}