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