Skip to content

Commit 1a0263e

Browse files
Merge pull request #33 from SierraSoftworks/feat/cli-rendering
feat: Add support for pretty CLI rendering of errors
2 parents 61825c2 + 69912cc commit 1a0263e

9 files changed

Lines changed: 487 additions & 91 deletions

File tree

.github/workflows/rust-test.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@ jobs:
1919
- name: Check out code
2020
uses: actions/checkout@v6
2121

22+
- name: Run fmt check
23+
uses: actions-rs/cargo@v1.0.3
24+
with:
25+
command: fmt
26+
args: --check --all
27+
2228
- name: Run clippy
2329
uses: actions-rs/cargo@v1.0.3
2430
with:
2531
command: clippy
2632
args: --all-targets --all-features
2733

28-
- name: Run fmt check
34+
- name: Run tests
2935
uses: actions-rs/cargo@v1.0.3
3036
with:
31-
command: fmt
32-
args: --check --all
37+
command: test
38+
args: --all-features

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,12 @@ edition = "2024"
1414
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1515

1616
[dependencies]
17+
cli-boxes = { version = "0.1", optional = true }
18+
colored = { version = "2.0", optional = true }
19+
serde = { version = "1.0", optional = true }
20+
textwrap = { version = "0.16.2", optional = true }
21+
22+
[features]
23+
default = []
24+
pretty = ["dep:cli-boxes", "dep:colored", "dep:textwrap"]
25+
serde = ["dep:serde"]

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,22 @@ fn read_file() -> Result<String, Error> {
126126
))
127127
}
128128
```
129+
130+
## Pretty Printing
131+
132+
Errors produced by this library implement the `Display` trait to provide a
133+
human-friendly rendering of the error message and its advice. However, if you
134+
want to customize the rendering further, you can use the `pretty` feature
135+
which enables you to use the `pretty` function to format errors in a nice,
136+
human-readable, way.
137+
138+
```rust
139+
use human_errors;
140+
141+
let err = human_errors::user(
142+
"We could not connect to the database.",
143+
&["Check that the database server is running.", "Verify your network connection."]
144+
);
145+
146+
eprintln!("{}", human_errors::pretty(err));
147+
```

src/error.rs

Lines changed: 145 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,8 @@
1+
use super::Kind;
12
use std::{error, fmt};
23

3-
/// The kind of error which occurred.
4-
///
5-
/// Distinguishes between errors which were the result of user actions
6-
/// and those which were the result of system failures. Conceptually
7-
/// similar to HTTP status codes in that 4xx errors are user-caused
8-
/// and 5xx errors are system-caused.
9-
#[derive(Debug, PartialEq, Eq)]
10-
pub enum Kind {
11-
/// An error which was the result of actions that the user took.
12-
///
13-
/// These errors are usually things which a user can easily resolve by
14-
/// changing how they interact with the system. Advice should be used
15-
/// to guide the user to the correct interaction paths and help them
16-
/// self-mitigate without needing to open support tickets.
17-
///
18-
/// These errors are usually generated with [`crate::user`], [`crate::user_with_cause`]
19-
/// and [`crate::user_with_internal`].
20-
User,
21-
22-
/// An error which was the result of the system failing rather than the user's actions.
23-
///
24-
/// These kinds of issues are usually the result of the system entering
25-
/// an unexpected state and/or violating an assumption on behalf of the
26-
/// developer. Often these issues cannot be resolved by the user directly,
27-
/// so the advice should guide them to the best way to raise a bug with you
28-
/// and provide you with information to help them fix the issue.
29-
///
30-
/// These errors are usually generated with [`crate::system`], [`crate::system_with_cause`]
31-
/// and [`crate::system_with_internal`].
32-
System,
33-
}
34-
35-
impl Kind {
36-
fn format_description(&self, description: &str) -> String {
37-
match self {
38-
Kind::User => format!("Oh no! {description}"),
39-
Kind::System => format!("Whoops! {description} (This isn't your fault)"),
40-
}
41-
}
42-
}
4+
#[cfg(feature = "serde")]
5+
use serde::ser::SerializeStruct;
436

447
/// The fundamental error type used by this library.
458
///
@@ -63,9 +26,9 @@ impl Kind {
6326
/// ```
6427
#[derive(Debug)]
6528
pub struct Error {
66-
kind: Kind,
67-
error: Box<dyn error::Error + Send + Sync>,
68-
advice: &'static [&'static str],
29+
pub(crate) kind: Kind,
30+
pub(crate) error: Box<dyn error::Error + Send + Sync>,
31+
pub(crate) advice: &'static [&'static str],
6932
}
7033

7134
impl Error {
@@ -92,6 +55,27 @@ impl Error {
9255
}
9356
}
9457

58+
/// Checks if this error is of a specific kind.
59+
///
60+
/// Returns `true` if this error matches the provided [Kind],
61+
/// otherwise `false`.
62+
///
63+
/// # Examples
64+
/// ```
65+
/// use human_errors;
66+
///
67+
/// let err = human_errors::user(
68+
/// "We could not open the config file you provided.",
69+
/// &["Make sure that the file exists and is readable by the application."],
70+
/// );
71+
///
72+
/// // Prints "is_user?: true"
73+
/// println!("is_user?: {}", err.is(human_errors::Kind::User));
74+
/// ```
75+
pub fn is(&self, kind: Kind) -> bool {
76+
self.kind == kind
77+
}
78+
9579
/// Gets the description message from this error.
9680
///
9781
/// Gets the description which was provided as the first argument when constructing
@@ -116,6 +100,52 @@ impl Error {
116100
}
117101
}
118102

103+
/// Gets the advice associated with this error and its causes.
104+
///
105+
/// Gathers all advice from this error and any causal errors it wraps,
106+
/// returning a deduplicated list of suggestions for how a user should
107+
/// deal with this error.
108+
///
109+
/// # Examples
110+
/// ```
111+
/// use human_errors;
112+
///
113+
/// let err = human_errors::wrap_user(
114+
/// human_errors::user(
115+
/// "We could not find a file at /home/user/.config/demo.yml",
116+
/// &["Make sure that the file exists and is readable by the application."]
117+
/// ),
118+
/// "We could not open the config file you provided.",
119+
/// &["Make sure that you've specified a valid config file with the --config option."],
120+
/// );
121+
///
122+
/// // Prints:
123+
/// // - Make sure that the file exists and is readable by the application.
124+
/// // - Make sure that you've specified a valid config file with the --config option.
125+
/// for tip in err.advice() {
126+
/// println!("- {}", tip);
127+
/// }
128+
/// ``````
129+
pub fn advice(&self) -> Vec<&'static str> {
130+
let mut advice = self.advice.to_vec();
131+
132+
let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(self.error.as_ref());
133+
while let Some(err) = cause {
134+
if let Some(err) = err.downcast_ref::<Error>() {
135+
advice.extend_from_slice(err.advice);
136+
}
137+
138+
cause = err.source();
139+
}
140+
141+
advice.reverse();
142+
143+
let mut seen = std::collections::HashSet::new();
144+
advice.retain(|item| seen.insert(*item));
145+
146+
advice
147+
}
148+
119149
/// Gets the formatted error and its advice.
120150
///
121151
/// Generates a string containing the description of the error and any causes,
@@ -139,7 +169,7 @@ impl Error {
139169
/// );
140170
///
141171
/// // Prints a message like the following:
142-
/// // Oh no! We could not open the config file you provided.
172+
/// // We could not open the config file you provided. (User error)
143173
/// //
144174
/// // This was caused by:
145175
/// // We could not find a file at /home/user/.config/demo.yml
@@ -195,44 +225,6 @@ impl Error {
195225

196226
causes
197227
}
198-
199-
fn advice(&self) -> Vec<&'static str> {
200-
let mut advice = self.advice.to_vec();
201-
202-
let mut cause = self.error.as_ref();
203-
while let Some(err) = cause.downcast_ref::<Error>() {
204-
advice.extend_from_slice(err.advice);
205-
cause = err.error.as_ref();
206-
}
207-
208-
advice.reverse();
209-
210-
let mut seen = std::collections::HashSet::new();
211-
advice.retain(|item| seen.insert(*item));
212-
213-
advice
214-
}
215-
216-
/// Checks if this error is of a specific kind.
217-
///
218-
/// Returns `true` if this error matches the provided [Kind],
219-
/// otherwise `false`.
220-
///
221-
/// # Examples
222-
/// ```
223-
/// use human_errors;
224-
///
225-
/// let err = human_errors::user(
226-
/// "We could not open the config file you provided.",
227-
/// &["Make sure that the file exists and is readable by the application."],
228-
/// );
229-
///
230-
/// // Prints "is_user?: true"
231-
/// println!("is_user?: {}", err.is(human_errors::Kind::User));
232-
/// ```
233-
pub fn is(&self, kind: Kind) -> bool {
234-
self.kind == kind
235-
}
236228
}
237229

238230
impl error::Error for Error {
@@ -246,3 +238,74 @@ impl fmt::Display for Error {
246238
write!(f, "{}", self.message())
247239
}
248240
}
241+
242+
#[cfg(feature = "serde")]
243+
impl serde::Serialize for Error {
244+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
245+
where
246+
S: serde::Serializer,
247+
{
248+
let mut state = serializer.serialize_struct("Error", 3)?;
249+
state.serialize_field("kind", &self.kind)?;
250+
state.serialize_field("description", &self.description())?;
251+
state.serialize_field("advice", &self.advice())?;
252+
state.end()
253+
}
254+
}
255+
256+
#[cfg(test)]
257+
mod tests {
258+
use super::*;
259+
260+
#[test]
261+
fn test_basic_user_error() {
262+
let err = Error::new(
263+
"Something bad happened.",
264+
Kind::User,
265+
&["Avoid bad things happening in future"],
266+
);
267+
268+
assert!(err.is(Kind::User));
269+
assert_eq!(err.description(), "Something bad happened.");
270+
assert_eq!(
271+
err.message(),
272+
"Something bad happened. (User error)\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
273+
);
274+
}
275+
276+
#[test]
277+
fn test_basic_system_error() {
278+
let err = Error::new(
279+
"Something bad happened.",
280+
Kind::System,
281+
&["Avoid bad things happening in future"],
282+
);
283+
284+
assert!(err.is(Kind::System));
285+
assert_eq!(err.description(), "Something bad happened.");
286+
assert_eq!(
287+
err.message(),
288+
"Something bad happened. (System failure)\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
289+
);
290+
}
291+
292+
#[test]
293+
fn test_advice_aggregation() {
294+
let low_level_err = Error::new(
295+
"Low-level failure.",
296+
Kind::System,
297+
&["Check low-level systems"],
298+
);
299+
300+
let high_level_err = Error::new(
301+
low_level_err,
302+
Kind::User,
303+
&["Check high-level configuration"],
304+
);
305+
306+
assert_eq!(
307+
high_level_err.advice(),
308+
vec!["Check low-level systems", "Check high-level configuration"]
309+
);
310+
}
311+
}

src/helpers.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ mod tests {
143143
&["Avoid bad things happening in future"]
144144
)
145145
.message(),
146-
"Oh no! Something bad happened.\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
146+
"Something bad happened. (User error)\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
147147
);
148148

149149
assert_eq!(
@@ -152,7 +152,7 @@ mod tests {
152152
&["Avoid bad things happening in future"]
153153
)
154154
.message(),
155-
"Whoops! Something bad happened. (This isn't your fault)\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
155+
"Something bad happened. (System failure)\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
156156
);
157157
}
158158

@@ -165,7 +165,7 @@ mod tests {
165165
&["Avoid bad things happening in future"]
166166
)
167167
.message(),
168-
"Oh no! Something bad happened.\n\nThis was caused by:\n - You got rate limited\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
168+
"Something bad happened. (User error)\n\nThis was caused by:\n - You got rate limited\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
169169
);
170170

171171
assert_eq!(
@@ -175,7 +175,7 @@ mod tests {
175175
&["Avoid bad things happening in future"]
176176
)
177177
.message(),
178-
"Whoops! Something bad happened. (This isn't your fault)\n\nThis was caused by:\n - You got rate limited\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
178+
"Something bad happened. (System failure)\n\nThis was caused by:\n - You got rate limited\n\nTo try and fix this, you can:\n - Avoid bad things happening in future"
179179
);
180180
}
181181
}

0 commit comments

Comments
 (0)