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