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    hints: Hints<'a>,
144    width_override: Option<usize>,
145    stream: W,
146}
147
148/// A standard-error writer for formatted error chains.
149#[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    /// Use a custom level prefix, such as `warning`.
173    pub fn with_level(mut self, level: impl Into<Cow<'a, str>>) -> Self {
174        self.level = level.into();
175        self
176    }
177
178    /// Use a custom color for the level and cause prefixes.
179    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    /// Render additional user-facing hints after the error chain.
190    pub fn with_hints(mut self, hints: Hints<'a>) -> Self {
191        self.hints = hints;
192        self
193    }
194
195    /// Override the terminal width used for wrapping.
196    ///
197    /// This is primarily useful for testing.
198    pub fn with_width_override(mut self, width_override: usize) -> Self {
199        self.width_override = Some(width_override);
200        self
201    }
202
203    /// Write the rendered error chain to a custom stream.
204    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
215/// Format an error chain to standard error using the default level and color.
216pub fn write_error_chain(err: &dyn Error) -> fmt::Result {
217    write_error_chain_with_options(err, ErrorOptions::default())
218}
219
220/// Formats an error or warning chain with custom options.
221///
222/// Each hint is rendered on its own line, prefixed with the styled `hint:` label.
223pub 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}