winbrew_cli\commands/
error.rs

1use std::process::ExitCode;
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum CommandError {
6    #[error("{message}")]
7    Reported {
8        message: String,
9        exit_code: i32,
10        hint: Option<String>,
11        #[source]
12        source: Option<anyhow::Error>,
13    },
14
15    #[error("operation cancelled")]
16    Cancelled,
17
18    #[error("{0}")]
19    Fatal(String),
20}
21
22pub fn reported(message: impl Into<String>) -> anyhow::Error {
23    CommandError::reported(message).into()
24}
25
26pub fn reported_with_hint(message: impl Into<String>, hint: impl Into<String>) -> anyhow::Error {
27    CommandError::reported(message).with_hint(hint).into()
28}
29
30pub fn reported_with_source(
31    message: impl Into<String>,
32    source: Option<anyhow::Error>,
33) -> anyhow::Error {
34    CommandError::reported(message).with_source(source).into()
35}
36
37pub fn cancelled() -> anyhow::Error {
38    CommandError::cancelled().into()
39}
40
41pub fn fatal(message: impl Into<String>) -> anyhow::Error {
42    CommandError::fatal(message).into()
43}
44
45pub fn is_handled(err: &anyhow::Error) -> bool {
46    err.downcast_ref::<CommandError>().is_some()
47}
48
49impl CommandError {
50    pub fn reported(message: impl Into<String>) -> Self {
51        Self::Reported {
52            message: message.into(),
53            exit_code: 1,
54            hint: None,
55            source: None,
56        }
57    }
58
59    pub fn cancelled() -> Self {
60        Self::Cancelled
61    }
62
63    pub fn fatal(message: impl Into<String>) -> Self {
64        Self::Fatal(message.into())
65    }
66
67    #[must_use = "builder method returns a modified error and should be used"]
68    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
69        if let Self::Reported {
70            hint: current_hint, ..
71        } = &mut self
72        {
73            *current_hint = Some(hint.into());
74        }
75
76        self
77    }
78
79    #[must_use = "builder method returns a modified error and should be used"]
80    pub fn with_exit_code(mut self, exit_code: i32) -> Self {
81        if let Self::Reported {
82            exit_code: current_exit_code,
83            ..
84        } = &mut self
85        {
86            *current_exit_code = exit_code;
87        }
88
89        self
90    }
91
92    #[must_use = "builder method returns a modified error and should be used"]
93    pub fn with_source(mut self, source: Option<anyhow::Error>) -> Self {
94        if let Self::Reported {
95            source: current_source,
96            ..
97        } = &mut self
98        {
99            *current_source = source;
100        }
101
102        self
103    }
104
105    pub fn as_reported(&self) -> Option<(&str, i32, Option<&str>)> {
106        match self {
107            Self::Reported {
108                message,
109                exit_code,
110                hint,
111                ..
112            } => Some((message.as_str(), *exit_code, hint.as_deref())),
113            _ => None,
114        }
115    }
116
117    pub fn exit_code(&self) -> i32 {
118        match self {
119            Self::Reported { exit_code, .. } => *exit_code,
120            Self::Cancelled => 130,
121            Self::Fatal(_) => 1,
122        }
123    }
124
125    pub fn is_fatal(&self) -> bool {
126        matches!(self, Self::Fatal(_))
127    }
128
129    pub fn hint(&self) -> Option<&str> {
130        self.as_reported().and_then(|(_, _, hint)| hint)
131    }
132}
133
134impl PartialEq for CommandError {
135    fn eq(&self, other: &Self) -> bool {
136        match (self, other) {
137            (
138                Self::Reported {
139                    message: left_message,
140                    exit_code: left_exit_code,
141                    hint: left_hint,
142                    ..
143                },
144                Self::Reported {
145                    message: right_message,
146                    exit_code: right_exit_code,
147                    hint: right_hint,
148                    ..
149                },
150            ) => {
151                left_message == right_message
152                    && left_exit_code == right_exit_code
153                    && left_hint == right_hint
154            }
155            (Self::Cancelled, Self::Cancelled) => true,
156            (Self::Fatal(left), Self::Fatal(right)) => left == right,
157            _ => false,
158        }
159    }
160}
161
162impl Eq for CommandError {}
163
164impl From<&CommandError> for ExitCode {
165    fn from(err: &CommandError) -> Self {
166        ExitCode::from(err.exit_code().clamp(0, 255) as u8)
167    }
168}
169
170impl From<CommandError> for ExitCode {
171    fn from(err: CommandError) -> Self {
172        ExitCode::from(&err)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{
179        CommandError, cancelled, fatal, is_handled, reported, reported_with_hint,
180        reported_with_source,
181    };
182    use std::process::ExitCode;
183
184    #[test]
185    fn reported_errors_are_handled_and_default_to_exit_one() {
186        let err = reported("already shown");
187
188        let cmd_err = err.downcast_ref::<CommandError>().expect("command error");
189        assert_eq!(cmd_err, &CommandError::reported("already shown"));
190        assert_eq!(cmd_err.exit_code(), 1);
191        assert!(is_handled(&err));
192    }
193
194    #[test]
195    fn reported_errors_can_carry_hints() {
196        let err = reported_with_hint("already shown", "try again later");
197
198        let cmd_err = err.downcast_ref::<CommandError>().expect("command error");
199        assert_eq!(
200            cmd_err,
201            &CommandError::reported("already shown").with_hint("try again later")
202        );
203        assert_eq!(cmd_err.hint(), Some("try again later"));
204    }
205
206    #[test]
207    fn reported_errors_can_carry_sources() {
208        let source = std::io::Error::other("disk full");
209        let err = reported_with_source("already shown", Some(source.into()));
210
211        let cmd_err = err.downcast_ref::<CommandError>().expect("command error");
212        assert!(std::error::Error::source(cmd_err).is_some());
213    }
214
215    #[test]
216    fn reported_builder_can_customize_hint_and_exit_code() {
217        let err = CommandError::reported("already shown")
218            .with_hint("try again later")
219            .with_exit_code(2);
220
221        assert_eq!(
222            err.as_reported(),
223            Some(("already shown", 2, Some("try again later")))
224        );
225    }
226
227    #[test]
228    fn cancelled_errors_exit_with_130() {
229        let err = cancelled();
230
231        let cmd_err = err.downcast_ref::<CommandError>().expect("command error");
232        assert_eq!(cmd_err, &CommandError::Cancelled);
233        assert_eq!(cmd_err.exit_code(), 130);
234    }
235
236    #[test]
237    fn fatal_errors_exit_with_one() {
238        let err = fatal("boom");
239
240        let cmd_err = err.downcast_ref::<CommandError>().expect("command error");
241        assert_eq!(cmd_err, &CommandError::Fatal("boom".to_string()));
242        assert!(cmd_err.is_fatal());
243        assert_eq!(cmd_err.exit_code(), 1);
244    }
245
246    #[test]
247    fn command_error_converts_to_exit_code() {
248        let reported = CommandError::reported("already shown").with_exit_code(2);
249        let cancelled = CommandError::Cancelled;
250        let fatal = CommandError::Fatal("boom".to_string());
251
252        assert_eq!(ExitCode::from(&reported), ExitCode::from(2));
253        assert_eq!(ExitCode::from(&cancelled), ExitCode::from(130));
254        assert_eq!(ExitCode::from(&fatal), ExitCode::from(1));
255    }
256}