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 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#[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}