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    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/// @docgen The binary owns human formatting, so another front end can render failures differently.
72#[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            // @docgen A refusal to replace something already there carries its own answer, which generic disk advice would contradict.
209            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        // @docgen Every documented code is asserted here, so a variant nothing can produce cannot quietly join the contract.
236        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}