Skip to main content

uv_errors/
lib.rs

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
12/// An error that may carry user-facing hints.
13///
14/// Implement this on error types that want to surface contextual suggestions
15/// (e.g., "try `--prerelease=allow`") to the diagnostics layer. Hints are
16/// rendered after the error output, each prefixed with `hint:`.
17pub trait Hint {
18    /// Return any hints associated with this error.
19    fn hints(&self) -> Hints<'_> {
20        Hints::none()
21    }
22}
23
24/// A collection of user-facing hint messages.
25///
26/// Each hint is rendered on its own line, prefixed with the styled `hint:` label.
27pub struct Hints<'a>(Vec<Cow<'a, str>>);
28
29impl Hints<'_> {
30    /// No hints.
31    pub fn none() -> Self {
32        Self(Vec::new())
33    }
34
35    /// Add a single owned hint.
36    pub fn push(&mut self, hint: String) {
37        self.0.push(Cow::Owned(hint));
38    }
39
40    /// Convert all borrowed hints to owned, extending the lifetime to `'static`.
41    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    /// Whether the collection is empty.
51    pub fn is_empty(&self) -> bool {
52        self.0.is_empty()
53    }
54
55    /// Extend with another set of hints, converting borrowed hints to owned.
56    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
66/// A display adapter for an error followed by its hints.
67///
68/// Error renderers line-terminate the error before rendering [`Hints`]. Use
69/// this adapter when an error and its hints need to be formatted together.
70pub struct ErrorWithHints<'a, E> {
71    error: E,
72    hints: Hints<'a>,
73}
74
75impl<'a, E> ErrorWithHints<'a, E> {
76    /// Format an error followed by any hints.
77    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
129/// A styled `hint:` prefix for use in user-facing messages.
130pub 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/// Options for formatting an error chain.
139#[must_use]
140pub struct ErrorOptions<'a, C = AnsiColors, W = Stderr> {
141    level: Cow<'a, str>,
142    color: C,
143    width_override: Option<usize>,
144    stream: W,
145}
146
147/// A standard-error writer for formatted error chains.
148#[derive(Debug, Clone, Copy, Default)]
149pub struct Stderr;
150
151impl fmt::Write for Stderr {
152    fn write_str(&mut self, output: &str) -> fmt::Result {
153        anstream::eprint!("{output}");
154        Ok(())
155    }
156}
157
158impl Default for ErrorOptions<'_, AnsiColors, Stderr> {
159    fn default() -> Self {
160        Self {
161            level: Cow::Borrowed("error"),
162            color: AnsiColors::Red,
163            width_override: None,
164            stream: Stderr,
165        }
166    }
167}
168
169impl<'a, C, W> ErrorOptions<'a, C, W> {
170    /// Use a custom level prefix, such as `warning`.
171    pub fn with_level(mut self, level: impl Into<Cow<'a, str>>) -> Self {
172        self.level = level.into();
173        self
174    }
175
176    /// Use a custom color for the level and cause prefixes.
177    pub fn with_color<D>(self, color: D) -> ErrorOptions<'a, D, W> {
178        ErrorOptions {
179            level: self.level,
180            color,
181            width_override: self.width_override,
182            stream: self.stream,
183        }
184    }
185
186    /// Override the terminal width used for wrapping.
187    ///
188    /// This is primarily useful for testing.
189    pub fn with_width_override(mut self, width_override: usize) -> Self {
190        self.width_override = Some(width_override);
191        self
192    }
193
194    /// Write the rendered error chain to a custom stream.
195    pub fn with_stream<D>(self, stream: D) -> ErrorOptions<'a, C, D> {
196        ErrorOptions {
197            level: self.level,
198            color: self.color,
199            width_override: self.width_override,
200            stream,
201        }
202    }
203}
204
205/// Format an error chain and explicitly supplied hints to standard error using the default level
206/// and color.
207pub fn write_error_chain(err: &dyn Error, hints: Hints<'_>) -> fmt::Result {
208    write_error_chain_with_options(err, hints, ErrorOptions::default())
209}
210
211/// Formats an error or warning chain with custom options.
212///
213/// Each hint is rendered on its own line, prefixed with the styled `hint:` label.
214pub fn write_error_chain_with_options<C: DynColor + Copy, W: fmt::Write>(
215    err: &dyn Error,
216    hints: Hints<'_>,
217    options: ErrorOptions<'_, C, W>,
218) -> fmt::Result {
219    let ErrorOptions {
220        level,
221        color,
222        width_override,
223        mut stream,
224    } = options;
225    let width = get_wrap_width(width_override);
226
227    let main_msg = err.to_string();
228    let main_padding = " ".repeat(level.len() + 2);
229    let wrapped_main = wrap_text(&main_msg, width, &main_padding, &main_padding);
230    writeln!(
231        &mut stream,
232        "{}{} {}",
233        level.as_ref().color(color).bold(),
234        ":".bold(),
235        wrapped_main.trim()
236    )?;
237
238    for source in iter::successors(err.source(), |&err| err.source()) {
239        let msg = source.to_string();
240        let padding = "  ";
241        let cause = "Caused by";
242        let child_padding = " ".repeat(padding.len() + cause.len() + 2);
243
244        let wrapped = wrap_text(&msg, width, "", &child_padding);
245
246        let mut lines = wrapped.lines();
247        if let Some(first) = lines.next() {
248            writeln!(
249                &mut stream,
250                "{}{}: {}",
251                padding,
252                cause.color(color).bold(),
253                first.trim()
254            )?;
255            for line in lines {
256                if line.trim().is_empty() {
257                    writeln!(&mut stream)?;
258                } else {
259                    writeln!(&mut stream, "{line}")?;
260                }
261            }
262        }
263    }
264
265    for hint in hints {
266        writeln!(&mut stream, "\n{HintPrefix} {hint}")?;
267    }
268
269    Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274    use anyhow::anyhow;
275    use indoc::indoc;
276    use insta::assert_snapshot;
277    use owo_colors::AnsiColors;
278
279    use super::{ErrorOptions, ErrorWithHints, HintPrefix, Hints, write_error_chain_with_options};
280
281    #[test]
282    fn extend_deduplicates_matching_hints() {
283        let mut hints = Hints::from("same");
284        hints.extend(Hints::from("same"));
285        hints.extend(Hints::from("other"));
286
287        let hints = hints
288            .into_iter()
289            .map(std::borrow::Cow::into_owned)
290            .collect::<Vec<_>>();
291        assert_eq!(hints, vec!["same".to_string(), "other".to_string()]);
292    }
293
294    #[test]
295    fn error_with_hints_separates_hints_from_error() {
296        assert_eq!(
297            ErrorWithHints::new("error", Hints::from("fix it")).to_string(),
298            format!("error\n\n{HintPrefix} fix it")
299        );
300        assert_eq!(
301            ErrorWithHints::new("error", Hints::none()).to_string(),
302            "error"
303        );
304    }
305
306    #[test]
307    fn test_error_wrapping_with_columns() {
308        #[derive(Debug, thiserror::Error)]
309        #[error(
310            "Because fiasobfhuasbf was not found in the package registry and you require fiasobfhuasbf, we can conclude that your requirements are unsatisfiable."
311        )]
312        struct Inner;
313
314        #[derive(Debug, thiserror::Error)]
315        #[error("No solution found when resolving dependencies")]
316        struct Outer {
317            #[source]
318            source: Inner,
319        }
320
321        let error = Outer { source: Inner };
322        let mut output = String::new();
323        write_error_chain_with_options(
324            &error,
325            Hints::none(),
326            ErrorOptions::default()
327                .with_width_override(80)
328                .with_stream(&mut output),
329        )
330        .unwrap();
331        let output = anstream::adapter::strip_str(&output);
332
333        assert_snapshot!(output, @r"
334        error: No solution found when resolving dependencies
335          Caused by: Because fiasobfhuasbf was not found in the package registry and you require
336                     fiasobfhuasbf, we can conclude that your requirements are
337                     unsatisfiable.
338        ");
339    }
340
341    #[test]
342    fn test_error_chain_with_cause() {
343        #[derive(Debug, thiserror::Error)]
344        #[error("Permission denied")]
345        struct Inner;
346
347        #[derive(Debug, thiserror::Error)]
348        #[error("Failed to write file")]
349        struct Outer {
350            #[source]
351            source: Inner,
352        }
353
354        let error = Outer { source: Inner };
355        let mut output = String::new();
356        write_error_chain_with_options(
357            &error,
358            Hints::none(),
359            ErrorOptions::default().with_stream(&mut output),
360        )
361        .unwrap();
362        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""#);
363        let output = anstream::adapter::strip_str(&output);
364
365        assert_snapshot!(output, @r"
366        error: Failed to write file
367          Caused by: Permission denied
368        ");
369    }
370
371    #[test]
372    fn format_with_custom_level() {
373        let error = anyhow!("Failed to create registry entry");
374        let mut output = String::new();
375        write_error_chain_with_options(
376            error.as_ref(),
377            Hints::none(),
378            ErrorOptions::default()
379                .with_level("warning")
380                .with_color(AnsiColors::Yellow)
381                .with_stream(&mut output),
382        )
383        .unwrap();
384        let output = anstream::adapter::strip_str(&output);
385
386        assert_snapshot!(output, @"warning: Failed to create registry entry
387");
388    }
389
390    #[test]
391    fn test_no_hyphenation() {
392        #[derive(Debug, thiserror::Error)]
393        #[error(
394            "Failed to download package from https://files.pythonhosted.org/packages/verylongpackagename"
395        )]
396        struct LongWord;
397
398        let error = LongWord;
399        let mut output = String::new();
400        write_error_chain_with_options(
401            &error,
402            Hints::none(),
403            ErrorOptions::default()
404                .with_width_override(50)
405                .with_stream(&mut output),
406        )
407        .unwrap();
408        let output = anstream::adapter::strip_str(&output);
409        assert_snapshot!(output, @r"
410        error: Failed to download package from
411               https://files.pythonhosted.org/packages/verylongpackagename
412        ");
413    }
414
415    #[test]
416    fn test_long_words_not_broken() {
417        #[derive(Debug, thiserror::Error)]
418        #[error(
419            "The package supercalifragilisticexpialidocious-extraordinarily-long-name was not found"
420        )]
421        struct VeryLongWord;
422
423        let error = VeryLongWord;
424        let mut output = String::new();
425        write_error_chain_with_options(
426            &error,
427            Hints::none(),
428            ErrorOptions::default()
429                .with_width_override(40)
430                .with_stream(&mut output),
431        )
432        .unwrap();
433        let output = anstream::adapter::strip_str(&output);
434        assert_snapshot!(output, @r"
435        error: The package
436               supercalifragilisticexpialidocious-extraordinarily-long-name
437               was not found
438        ");
439    }
440
441    #[test]
442    fn test_multiple_error_sources() {
443        #[derive(Debug, thiserror::Error)]
444        #[error("Network connection timeout after multiple retry attempts")]
445        struct DeepError;
446
447        #[derive(Debug, thiserror::Error)]
448        #[error("Failed to fetch package metadata from registry")]
449        struct MiddleError {
450            #[source]
451            source: DeepError,
452        }
453
454        #[derive(Debug, thiserror::Error)]
455        #[error("Unable to resolve package dependencies")]
456        struct TopError {
457            #[source]
458            source: MiddleError,
459        }
460
461        let error = TopError {
462            source: MiddleError { source: DeepError },
463        };
464        let mut output = String::new();
465        write_error_chain_with_options(
466            &error,
467            Hints::none(),
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            Hints::none(),
494            ErrorOptions::default()
495                .with_width_override(50)
496                .with_stream(&mut output),
497        )
498        .unwrap();
499        let output = anstream::adapter::strip_str(&output);
500
501        assert_snapshot!(output, @r"
502        error: There is no command `foobar` for `uv`. Did
503               you mean one of:
504            auth
505            run
506            init
507        ");
508    }
509
510    #[test]
511    fn test_wrap_only_on_ascii_space() {
512        #[derive(Debug, thiserror::Error)]
513        #[error("Path /usr/local/lib/python3.12/site-packages not found in filesystem hierarchy")]
514        struct SpecialChars;
515
516        let error = SpecialChars;
517        let mut output = String::new();
518        write_error_chain_with_options(
519            &error,
520            Hints::none(),
521            ErrorOptions::default()
522                .with_width_override(50)
523                .with_stream(&mut output),
524        )
525        .unwrap();
526        let output = anstream::adapter::strip_str(&output);
527        assert_snapshot!(output, @r"
528        error: Path /usr/local/lib/python3.12/site-packages
529               not found in filesystem hierarchy
530        ");
531    }
532
533    #[test]
534    fn format_with_hints() {
535        let err = anyhow!("Permission denied").context("Failed to fetch package");
536
537        let hints = [
538            "Try running with `--verbose` for more information.".to_string(),
539            "Try running without --offline.".to_string(),
540        ]
541        .into_iter()
542        .collect();
543
544        let mut rendered = String::new();
545        write_error_chain_with_options(
546            err.as_ref(),
547            hints,
548            ErrorOptions::default().with_stream(&mut rendered),
549        )
550        .unwrap();
551        let rendered = anstream::adapter::strip_str(&rendered);
552
553        assert_snapshot!(rendered, @r"
554        error: Failed to fetch package
555          Caused by: Permission denied
556
557        hint: Try running with `--verbose` for more information.
558
559        hint: Try running without --offline.
560        ");
561    }
562
563    #[test]
564    fn format_multiline_message() {
565        let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst
566        Server says: This endpoint only support POST requests.
567
568        For downloads, please refer to https://example.com/download/python3.13.tar.zst"};
569        let err = anyhow!("Caused By: HTTP Error 400")
570            .context(err_middle)
571            .context("Failed to download Python 3.12");
572
573        let mut rendered = String::new();
574        write_error_chain_with_options(
575            err.as_ref(),
576            Hints::none(),
577            ErrorOptions::default().with_stream(&mut rendered),
578        )
579        .unwrap();
580        let rendered = anstream::adapter::strip_str(&rendered);
581
582        assert_snapshot!(rendered, @r"
583        error: Failed to download Python 3.12
584          Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst
585        Server says: This endpoint only support POST requests.
586
587        For downloads, please refer to https://example.com/download/python3.13.tar.zst
588          Caused by: Caused By: HTTP Error 400
589        ");
590    }
591}