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    #[cfg(test)]
190    fn with_width_override(mut self, width_override: usize) -> Self {
191        self.width_override = Some(width_override);
192        self
193    }
194
195    /// Write the rendered error chain to a custom stream.
196    pub fn with_stream<D>(self, stream: D) -> ErrorOptions<'a, C, D> {
197        ErrorOptions {
198            level: self.level,
199            color: self.color,
200            width_override: self.width_override,
201            stream,
202        }
203    }
204}
205
206/// Format an error chain and explicitly supplied hints to standard error using the default level
207/// and color.
208pub fn write_error_chain(err: &dyn Error, hints: Hints<'_>) -> fmt::Result {
209    write_error_chain_with_options(err, hints, ErrorOptions::default())
210}
211
212/// Format the [`Debug`] representation of every error in an error chain.
213pub fn debug_error_chain(err: &dyn Error) -> impl fmt::Display + '_ {
214    DebugErrorChain(err)
215}
216
217struct DebugErrorChain<'a>(&'a dyn Error);
218
219impl fmt::Display for DebugErrorChain<'_> {
220    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
221        for (index, error) in iter::successors(Some(self.0), |&error| error.source()).enumerate() {
222            if index > 0 {
223                formatter.write_str("\n")?;
224            }
225            write!(formatter, "{index}: {error:?}")?;
226        }
227        Ok(())
228    }
229}
230
231/// Formats an error or warning chain with custom options.
232///
233/// Each hint is rendered on its own line, prefixed with the styled `hint:` label.
234pub fn write_error_chain_with_options<C: DynColor + Copy, W: fmt::Write>(
235    err: &dyn Error,
236    hints: Hints<'_>,
237    options: ErrorOptions<'_, C, W>,
238) -> fmt::Result {
239    let ErrorOptions {
240        level,
241        color,
242        width_override,
243        mut stream,
244    } = options;
245    let width = get_wrap_width(width_override);
246
247    let main_msg = err.to_string();
248    let main_padding = " ".repeat(level.len() + 2);
249    let wrapped_main = wrap_text(&main_msg, width, &main_padding, &main_padding, "");
250    writeln!(
251        &mut stream,
252        "{}{} {}",
253        level.as_ref().color(color).bold(),
254        ":".bold(),
255        wrapped_main.trim()
256    )?;
257
258    for source in iter::successors(err.source(), |&err| err.source()) {
259        let msg = source.to_string();
260        let padding = "  ";
261        let cause = "Caused by";
262        let child_padding = " ".repeat(padding.len() + cause.len() + 2);
263        let authored_line_padding = "    ";
264
265        let wrapped = wrap_text(&msg, width, "", &child_padding, authored_line_padding);
266
267        let mut lines = wrapped.lines();
268        if let Some(first) = lines.next() {
269            writeln!(
270                &mut stream,
271                "{}{}: {}",
272                padding,
273                cause.color(color).bold(),
274                first.trim()
275            )?;
276            for line in lines {
277                if line.trim().is_empty() {
278                    writeln!(&mut stream)?;
279                } else {
280                    writeln!(&mut stream, "{line}")?;
281                }
282            }
283        }
284    }
285
286    for hint in hints {
287        writeln!(&mut stream, "\n{HintPrefix} {hint}")?;
288    }
289
290    Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295    use anyhow::anyhow;
296    use indoc::indoc;
297    use insta::assert_snapshot;
298    use owo_colors::AnsiColors;
299
300    use super::{
301        ErrorOptions, ErrorWithHints, HintPrefix, Hints, debug_error_chain,
302        write_error_chain_with_options,
303    };
304
305    #[test]
306    fn extend_deduplicates_matching_hints() {
307        let mut hints = Hints::from("same");
308        hints.extend(Hints::from("same"));
309        hints.extend(Hints::from("other"));
310
311        let hints = hints
312            .into_iter()
313            .map(std::borrow::Cow::into_owned)
314            .collect::<Vec<_>>();
315        assert_eq!(hints, vec!["same".to_string(), "other".to_string()]);
316    }
317
318    #[test]
319    fn error_with_hints_separates_hints_from_error() {
320        assert_eq!(
321            ErrorWithHints::new("error", Hints::from("fix it")).to_string(),
322            format!("error\n\n{HintPrefix} fix it")
323        );
324        assert_eq!(
325            ErrorWithHints::new("error", Hints::none()).to_string(),
326            "error"
327        );
328    }
329
330    #[test]
331    fn test_error_wrapping_with_columns() {
332        #[derive(Debug, thiserror::Error)]
333        #[error(
334            "Because fiasobfhuasbf was not found in the package registry and you require fiasobfhuasbf, we can conclude that your requirements are unsatisfiable."
335        )]
336        struct Inner;
337
338        #[derive(Debug, thiserror::Error)]
339        #[error("No solution found when resolving dependencies")]
340        struct Outer {
341            #[source]
342            source: Inner,
343        }
344
345        let error = Outer { source: Inner };
346        let mut output = String::new();
347        write_error_chain_with_options(
348            &error,
349            Hints::none(),
350            ErrorOptions::default()
351                .with_width_override(80)
352                .with_stream(&mut output),
353        )
354        .unwrap();
355        let output = anstream::adapter::strip_str(&output);
356
357        assert_snapshot!(output, @r"
358        error: No solution found when resolving dependencies
359          Caused by: Because fiasobfhuasbf was not found in the package registry and you require
360                     fiasobfhuasbf, we can conclude that your requirements are
361                     unsatisfiable.
362        ");
363    }
364
365    #[test]
366    fn test_error_chain_with_cause() {
367        #[derive(Debug, thiserror::Error)]
368        #[error("Permission denied")]
369        struct Inner;
370
371        #[derive(Debug, thiserror::Error)]
372        #[error("Failed to write file")]
373        struct Outer {
374            #[source]
375            source: Inner,
376        }
377
378        let error = Outer { source: Inner };
379        let mut output = String::new();
380        write_error_chain_with_options(
381            &error,
382            Hints::none(),
383            ErrorOptions::default().with_stream(&mut output),
384        )
385        .unwrap();
386        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""#);
387        let output = anstream::adapter::strip_str(&output);
388
389        assert_snapshot!(output, @r"
390        error: Failed to write file
391          Caused by: Permission denied
392        ");
393    }
394
395    #[test]
396    fn formats_debug_error_chain() {
397        #[derive(Debug, thiserror::Error)]
398        #[error("inner error")]
399        struct InnerError {
400            code: u8,
401        }
402
403        #[derive(Debug, thiserror::Error)]
404        #[error("outer error")]
405        struct OuterError {
406            #[source]
407            source: InnerError,
408        }
409
410        let error = OuterError {
411            source: InnerError { code: 42 },
412        };
413
414        assert_eq!(
415            debug_error_chain(&error).to_string(),
416            "0: OuterError { source: InnerError { code: 42 } }\n1: InnerError { code: 42 }"
417        );
418    }
419
420    #[test]
421    fn format_with_custom_level() {
422        let error = anyhow!("Failed to create registry entry");
423        let mut output = String::new();
424        write_error_chain_with_options(
425            error.as_ref(),
426            Hints::none(),
427            ErrorOptions::default()
428                .with_level("warning")
429                .with_color(AnsiColors::Yellow)
430                .with_stream(&mut output),
431        )
432        .unwrap();
433        let output = anstream::adapter::strip_str(&output);
434
435        assert_snapshot!(output, @"warning: Failed to create registry entry
436");
437    }
438
439    #[test]
440    fn test_no_hyphenation() {
441        #[derive(Debug, thiserror::Error)]
442        #[error(
443            "Failed to download package from https://files.pythonhosted.org/packages/verylongpackagename"
444        )]
445        struct LongWord;
446
447        let error = LongWord;
448        let mut output = String::new();
449        write_error_chain_with_options(
450            &error,
451            Hints::none(),
452            ErrorOptions::default()
453                .with_width_override(50)
454                .with_stream(&mut output),
455        )
456        .unwrap();
457        let output = anstream::adapter::strip_str(&output);
458        assert_snapshot!(output, @r"
459        error: Failed to download package from
460               https://files.pythonhosted.org/packages/verylongpackagename
461        ");
462    }
463
464    #[test]
465    fn test_long_words_not_broken() {
466        #[derive(Debug, thiserror::Error)]
467        #[error(
468            "The package supercalifragilisticexpialidocious-extraordinarily-long-name was not found"
469        )]
470        struct VeryLongWord;
471
472        let error = VeryLongWord;
473        let mut output = String::new();
474        write_error_chain_with_options(
475            &error,
476            Hints::none(),
477            ErrorOptions::default()
478                .with_width_override(40)
479                .with_stream(&mut output),
480        )
481        .unwrap();
482        let output = anstream::adapter::strip_str(&output);
483        assert_snapshot!(output, @r"
484        error: The package
485               supercalifragilisticexpialidocious-extraordinarily-long-name
486               was not found
487        ");
488    }
489
490    #[test]
491    fn test_multiple_error_sources() {
492        #[derive(Debug, thiserror::Error)]
493        #[error("Network connection timeout after multiple retry attempts")]
494        struct DeepError;
495
496        #[derive(Debug, thiserror::Error)]
497        #[error("Failed to fetch package metadata from registry")]
498        struct MiddleError {
499            #[source]
500            source: DeepError,
501        }
502
503        #[derive(Debug, thiserror::Error)]
504        #[error("Unable to resolve package dependencies")]
505        struct TopError {
506            #[source]
507            source: MiddleError,
508        }
509
510        let error = TopError {
511            source: MiddleError { source: DeepError },
512        };
513        let mut output = String::new();
514        write_error_chain_with_options(
515            &error,
516            Hints::none(),
517            ErrorOptions::default()
518                .with_width_override(60)
519                .with_stream(&mut output),
520        )
521        .unwrap();
522        let output = anstream::adapter::strip_str(&output);
523        assert_snapshot!(output, @r"
524        error: Unable to resolve package dependencies
525          Caused by: Failed to fetch package metadata from registry
526          Caused by: Network connection timeout after multiple retry attempts
527        ");
528    }
529
530    #[test]
531    fn test_multiline_main_message_wraps_each_line() {
532        #[derive(Debug, thiserror::Error)]
533        #[error(
534            "There is no command `foobar` for `uv`. Did you mean one of:\n    auth\n    run\n    init"
535        )]
536        struct Suggestions;
537
538        let error = Suggestions;
539        let mut output = String::new();
540        write_error_chain_with_options(
541            &error,
542            Hints::none(),
543            ErrorOptions::default()
544                .with_width_override(50)
545                .with_stream(&mut output),
546        )
547        .unwrap();
548        let output = anstream::adapter::strip_str(&output);
549
550        assert_snapshot!(output, @r"
551        error: There is no command `foobar` for `uv`. Did
552               you mean one of:
553            auth
554            run
555            init
556        ");
557    }
558
559    #[test]
560    fn test_wrap_only_on_ascii_space() {
561        #[derive(Debug, thiserror::Error)]
562        #[error("Path /usr/local/lib/python3.12/site-packages not found in filesystem hierarchy")]
563        struct SpecialChars;
564
565        let error = SpecialChars;
566        let mut output = String::new();
567        write_error_chain_with_options(
568            &error,
569            Hints::none(),
570            ErrorOptions::default()
571                .with_width_override(50)
572                .with_stream(&mut output),
573        )
574        .unwrap();
575        let output = anstream::adapter::strip_str(&output);
576        assert_snapshot!(output, @r"
577        error: Path /usr/local/lib/python3.12/site-packages
578               not found in filesystem hierarchy
579        ");
580    }
581
582    #[test]
583    fn format_with_hints() {
584        let err = anyhow!("Permission denied").context("Failed to fetch package");
585
586        let hints = [
587            "Try running with `--verbose` for more information.".to_string(),
588            "Try running without --offline.".to_string(),
589        ]
590        .into_iter()
591        .collect();
592
593        let mut rendered = String::new();
594        write_error_chain_with_options(
595            err.as_ref(),
596            hints,
597            ErrorOptions::default().with_stream(&mut rendered),
598        )
599        .unwrap();
600        let rendered = anstream::adapter::strip_str(&rendered);
601
602        assert_snapshot!(rendered, @r"
603        error: Failed to fetch package
604          Caused by: Permission denied
605
606        hint: Try running with `--verbose` for more information.
607
608        hint: Try running without --offline.
609        ");
610    }
611
612    #[test]
613    fn format_multiline_message() {
614        let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst
615        Server says: This endpoint only support POST requests.
616
617        For downloads, please refer to https://example.com/download/python3.13.tar.zst"};
618        let err = anyhow!("Caused By: HTTP Error 400")
619            .context(err_middle)
620            .context("Failed to download Python 3.12");
621
622        let mut rendered = String::new();
623        write_error_chain_with_options(
624            err.as_ref(),
625            Hints::none(),
626            ErrorOptions::default().with_stream(&mut rendered),
627        )
628        .unwrap();
629        let rendered = anstream::adapter::strip_str(&rendered);
630
631        assert_snapshot!(rendered, @r"
632        error: Failed to download Python 3.12
633          Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst
634            Server says: This endpoint only support POST requests.
635
636            For downloads, please refer to https://example.com/download/python3.13.tar.zst
637          Caused by: Caused By: HTTP Error 400
638        ");
639    }
640}