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    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/// @docgen The binary owns human formatting, so another front end can render failures differently.
70#[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            // @docgen A refusal to replace an existing file has its own answer, and generic disk advice would send the reader the wrong way.
198            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        // @docgen Every documented code is asserted here, so a variant nothing can produce cannot quietly join the contract.
226        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}