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