Skip to main content

reserve_core/
error.rs

1use std::fmt;
2use std::path::PathBuf;
3
4/// @docgen Scripts branch on these codes, so a value never changes meaning within a major version.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
6#[repr(u8)]
7pub enum ExitClass {
8    Success = 0,
9    NothingAvailable = 1,
10    Usage = 2,
11    Config = 3,
12    Network = 4,
13    Io = 5,
14    Interrupted = 130,
15}
16
17impl ExitClass {
18    #[must_use]
19    pub const fn code(self) -> u8 {
20        self as u8
21    }
22}
23
24impl fmt::Display for ExitClass {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        write!(f, "{}", self.code())
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ErrorId {
32    FileUnreadable,
33    CatalogMalformed,
34    CatalogEmptySelection,
35    GroupUnknown,
36    ExtensionInvalid,
37    FilterInvalid,
38    NameInvalid,
39    NameListEmpty,
40    BootstrapUnavailable,
41    NetworkUnreachable,
42    OutputUnwritable,
43}
44
45impl ErrorId {
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::FileUnreadable => "file.unreadable",
50            Self::CatalogMalformed => "catalog.malformed",
51            Self::CatalogEmptySelection => "catalog.empty_selection",
52            Self::GroupUnknown => "group.unknown",
53            Self::ExtensionInvalid => "extension.invalid",
54            Self::FilterInvalid => "filter.invalid",
55            Self::NameInvalid => "name.invalid",
56            Self::NameListEmpty => "name.list_empty",
57            Self::BootstrapUnavailable => "bootstrap.unavailable",
58            Self::NetworkUnreachable => "network.unreachable",
59            Self::OutputUnwritable => "output.unwritable",
60        }
61    }
62}
63
64impl fmt::Display for ErrorId {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70/// @docgen The binary owns human formatting, so another front end can render failures differently.
71#[derive(Debug, thiserror::Error)]
72pub enum Error {
73    #[error("{path} could not be read")]
74    FileUnreadable {
75        path: PathBuf,
76        #[source]
77        source: std::io::Error,
78    },
79
80    #[error("the catalog data could not be parsed")]
81    CatalogMalformed {
82        #[source]
83        source: Box<serde_json::Error>,
84    },
85
86    #[error("the filters you gave leave no extension to check")]
87    CatalogEmptySelection,
88
89    #[error("`{name}` is not a known group")]
90    GroupUnknown {
91        name: String,
92        closest_groups: Vec<String>,
93    },
94
95    #[error("`{extension}` is not a usable domain extension")]
96    ExtensionInvalid { extension: String },
97
98    #[error("`{value}` is not a usable {setting}")]
99    FilterInvalid { setting: String, value: String },
100
101    #[error("`{name}` is not a usable domain name")]
102    NameInvalid { name: String, reason: String },
103
104    #[error("no name was given to check")]
105    NameListEmpty,
106
107    #[error("the registry server list could not be fetched and no cached copy is usable")]
108    BootstrapUnavailable {
109        #[source]
110        source: Box<dyn std::error::Error + Send + Sync>,
111    },
112
113    #[error("the network could not be reached")]
114    NetworkUnreachable {
115        #[source]
116        source: Box<dyn std::error::Error + Send + Sync>,
117    },
118
119    #[error("output could not be written to {target}")]
120    OutputUnwritable {
121        target: String,
122        #[source]
123        source: std::io::Error,
124    },
125}
126
127impl Error {
128    #[must_use]
129    pub const fn id(&self) -> ErrorId {
130        match self {
131            Self::FileUnreadable { .. } => ErrorId::FileUnreadable,
132            Self::CatalogMalformed { .. } => ErrorId::CatalogMalformed,
133            Self::CatalogEmptySelection => ErrorId::CatalogEmptySelection,
134            Self::GroupUnknown { .. } => ErrorId::GroupUnknown,
135            Self::ExtensionInvalid { .. } => ErrorId::ExtensionInvalid,
136            Self::FilterInvalid { .. } => ErrorId::FilterInvalid,
137            Self::NameInvalid { .. } => ErrorId::NameInvalid,
138            Self::NameListEmpty => ErrorId::NameListEmpty,
139            Self::BootstrapUnavailable { .. } => ErrorId::BootstrapUnavailable,
140            Self::NetworkUnreachable { .. } => ErrorId::NetworkUnreachable,
141            Self::OutputUnwritable { .. } => ErrorId::OutputUnwritable,
142        }
143    }
144
145    #[must_use]
146    pub const fn exit_class(&self) -> ExitClass {
147        match self {
148            Self::FileUnreadable { .. }
149            | Self::CatalogMalformed { .. }
150            | Self::CatalogEmptySelection
151            | Self::GroupUnknown { .. }
152            | Self::ExtensionInvalid { .. }
153            | Self::FilterInvalid { .. }
154            | Self::NameInvalid { .. }
155            | Self::NameListEmpty => ExitClass::Usage,
156            Self::BootstrapUnavailable { .. } | Self::NetworkUnreachable { .. } => {
157                ExitClass::Network
158            }
159            Self::OutputUnwritable { .. } => ExitClass::Io,
160        }
161    }
162
163    #[must_use]
164    pub fn remedy(&self) -> String {
165        match self {
166            Self::FileUnreadable { .. } => {
167                "Check that the file exists and that you can read it.".to_owned()
168            }
169            Self::CatalogMalformed { .. } => {
170                "The supplied catalog file is not valid. Remove it to use the bundled one."
171                    .to_owned()
172            }
173            Self::CatalogEmptySelection => {
174                "Loosen a filter, or run `reserve groups` to see what you can pick.".to_owned()
175            }
176            Self::GroupUnknown { closest_groups, .. } => {
177                if closest_groups.is_empty() {
178                    "Run `reserve groups` to see every group.".to_owned()
179                } else {
180                    format!("Did you mean {}?", closest_groups.join(", "))
181                }
182            }
183            Self::ExtensionInvalid { .. } => {
184                "Give the extension without a leading dot, such as `com` or `co.uk`.".to_owned()
185            }
186            Self::FilterInvalid { .. } => {
187                "Run `reserve --help` to see the accepted values.".to_owned()
188            }
189            Self::NameInvalid { .. } => {
190                "Give the name without the extension, such as `example`.".to_owned()
191            }
192            Self::NameListEmpty => "Pass at least one name to check.".to_owned(),
193            Self::BootstrapUnavailable { .. } => {
194                "Check your connection, or pass `--source text` to skip the registry list."
195                    .to_owned()
196            }
197            Self::NetworkUnreachable { .. } => "Check your connection and try again.".to_owned(),
198            Self::OutputUnwritable { .. } => {
199                "Check that the target is writable and has free space.".to_owned()
200            }
201        }
202    }
203}
204
205pub type Result<T, E = Error> = std::result::Result<T, E>;
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn exit_codes_are_the_documented_contract() {
213        assert_eq!(ExitClass::Success.code(), 0);
214        assert_eq!(ExitClass::NothingAvailable.code(), 1);
215        assert_eq!(ExitClass::Usage.code(), 2);
216        assert_eq!(ExitClass::Network.code(), 4);
217        assert_eq!(ExitClass::Io.code(), 5);
218        assert_eq!(ExitClass::Interrupted.code(), 130);
219    }
220
221    #[test]
222    fn every_error_has_an_id_and_a_remedy() {
223        let cases = [
224            Error::CatalogEmptySelection,
225            Error::NameListEmpty,
226            Error::ExtensionInvalid {
227                extension: "..".to_owned(),
228            },
229        ];
230        for case in cases {
231            assert!(!case.id().as_str().is_empty());
232            assert!(!case.remedy().is_empty());
233        }
234    }
235
236    #[test]
237    fn usage_errors_map_to_the_usage_exit_class() {
238        assert_eq!(Error::NameListEmpty.exit_class(), ExitClass::Usage);
239        assert_eq!(Error::CatalogEmptySelection.exit_class(), ExitClass::Usage);
240    }
241
242    #[test]
243    fn unknown_group_suggests_the_closest_names() {
244        let err = Error::GroupUnknown {
245            name: "tec".to_owned(),
246            closest_groups: vec!["tech".to_owned()],
247        };
248        assert!(err.remedy().contains("tech"));
249    }
250}