1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
4#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
5pub enum ErrorCode {
6 VersionMismatch,
7 Protocol,
8 UnknownSubject,
9 UnresolvedAtPeer,
10 PeerUnreachable,
11 HopLimitExceeded,
12 InvalidInput,
13 Unauthorized,
14 Conflict,
15 PayloadTooLarge,
16 Busy,
17 Cancelled,
18 Internal,
19}
20
21impl ErrorCode {
22 pub fn status(self) -> http::StatusCode {
23 let status = match self {
24 ErrorCode::InvalidInput | ErrorCode::Protocol => 400,
25 ErrorCode::Unauthorized => 401,
26 ErrorCode::UnknownSubject => 404,
27 ErrorCode::Conflict => 409,
28 ErrorCode::PayloadTooLarge => 413,
29 ErrorCode::UnresolvedAtPeer => 421,
30 ErrorCode::Cancelled => 499,
31 ErrorCode::Internal => 500,
32 ErrorCode::PeerUnreachable => 502,
33 ErrorCode::Busy => 503,
34 ErrorCode::VersionMismatch => 505,
35 ErrorCode::HopLimitExceeded => 508,
36 };
37 http::StatusCode::from_u16(status).expect("mapped statuses are valid")
38 }
39
40 pub fn token(self) -> &'static str {
41 match self {
42 ErrorCode::VersionMismatch => "VERSION_MISMATCH",
43 ErrorCode::Protocol => "PROTOCOL",
44 ErrorCode::UnknownSubject => "UNKNOWN_SUBJECT",
45 ErrorCode::UnresolvedAtPeer => "UNRESOLVED_AT_PEER",
46 ErrorCode::PeerUnreachable => "PEER_UNREACHABLE",
47 ErrorCode::HopLimitExceeded => "HOP_LIMIT_EXCEEDED",
48 ErrorCode::InvalidInput => "INVALID_INPUT",
49 ErrorCode::Unauthorized => "UNAUTHORIZED",
50 ErrorCode::Conflict => "CONFLICT",
51 ErrorCode::PayloadTooLarge => "PAYLOAD_TOO_LARGE",
52 ErrorCode::Busy => "BUSY",
53 ErrorCode::Cancelled => "CANCELLED",
54 ErrorCode::Internal => "INTERNAL",
55 }
56 }
57
58 pub fn from_status(status: http::StatusCode) -> ErrorCode {
59 match status.as_u16() {
60 400 => ErrorCode::InvalidInput,
61 401 => ErrorCode::Unauthorized,
62 404 => ErrorCode::UnknownSubject,
63 409 => ErrorCode::Conflict,
64 413 => ErrorCode::PayloadTooLarge,
65 421 => ErrorCode::UnresolvedAtPeer,
66 499 => ErrorCode::Cancelled,
67 502 => ErrorCode::PeerUnreachable,
68 503 => ErrorCode::Busy,
69 505 => ErrorCode::VersionMismatch,
70 508 => ErrorCode::HopLimitExceeded,
71 _ => ErrorCode::Internal,
72 }
73 }
74}
75
76const SUGGEST_DISTANCE: usize = 3;
77
78pub fn suggest<'a>(input: &str, known: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
79 known
80 .into_iter()
81 .map(|candidate| (levenshtein(input, candidate), candidate))
82 .filter(|(distance, _)| *distance <= SUGGEST_DISTANCE)
83 .min_by_key(|(distance, _)| *distance)
84 .map(|(_, candidate)| candidate)
85}
86
87pub fn teach_unknown(what: &str, input: &str, known: &[&str]) -> String {
88 match suggest(input, known.iter().copied()) {
89 Some(candidate) => format!("Unknown {what} \"{input}\". Did you mean \"{candidate}\"?"),
90 None if known.is_empty() => format!("Unknown {what} \"{input}\"."),
91 None => format!(
92 "Unknown {what} \"{input}\". Available: {}",
93 known.join(", ")
94 ),
95 }
96}
97
98fn levenshtein(a: &str, b: &str) -> usize {
99 let a: Vec<char> = a.chars().collect();
100 let b: Vec<char> = b.chars().collect();
101 let mut previous: Vec<usize> = (0..=b.len()).collect();
102 for (i, ca) in a.iter().enumerate() {
103 let mut current = vec![i + 1];
104 for (j, cb) in b.iter().enumerate() {
105 let substitution = previous[j] + usize::from(ca != cb);
106 current.push(substitution.min(previous[j + 1] + 1).min(current[j] + 1));
107 }
108 previous = current;
109 }
110 previous[b.len()]
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn every_error_code_maps_to_its_documented_status() {
119 let expected = [
120 (ErrorCode::InvalidInput, 400),
121 (ErrorCode::Protocol, 400),
122 (ErrorCode::Unauthorized, 401),
123 (ErrorCode::UnknownSubject, 404),
124 (ErrorCode::Conflict, 409),
125 (ErrorCode::PayloadTooLarge, 413),
126 (ErrorCode::UnresolvedAtPeer, 421),
127 (ErrorCode::Cancelled, 499),
128 (ErrorCode::Internal, 500),
129 (ErrorCode::PeerUnreachable, 502),
130 (ErrorCode::Busy, 503),
131 (ErrorCode::VersionMismatch, 505),
132 (ErrorCode::HopLimitExceeded, 508),
133 ];
134 for (code, status) in expected {
135 assert_eq!(code.status().as_u16(), status, "{code:?}");
136 }
137 }
138
139 #[test]
140 fn statuses_map_back_with_the_collision_default() {
141 for code in [
142 ErrorCode::Unauthorized,
143 ErrorCode::UnknownSubject,
144 ErrorCode::Conflict,
145 ErrorCode::PayloadTooLarge,
146 ErrorCode::UnresolvedAtPeer,
147 ErrorCode::Cancelled,
148 ErrorCode::Internal,
149 ErrorCode::PeerUnreachable,
150 ErrorCode::Busy,
151 ErrorCode::VersionMismatch,
152 ErrorCode::HopLimitExceeded,
153 ] {
154 assert_eq!(ErrorCode::from_status(code.status()), code, "{code:?}");
155 }
156 assert_eq!(
157 ErrorCode::from_status(http::StatusCode::BAD_REQUEST),
158 ErrorCode::InvalidInput
159 );
160 assert_eq!(
161 ErrorCode::from_status(http::StatusCode::IM_A_TEAPOT),
162 ErrorCode::Internal
163 );
164 }
165
166 #[test]
167 fn close_misspelling_suggests_the_nearest_name() {
168 assert_eq!(suggest("ches", ["chess", "todo"]), Some("chess"));
169 assert_eq!(
170 teach_unknown("subject", "ches", &["chess", "todo"]),
171 "Unknown subject \"ches\". Did you mean \"chess\"?"
172 );
173 }
174
175 #[test]
176 fn distant_input_lists_what_is_available() {
177 assert_eq!(suggest("zzzzzzzzzz", ["chess", "todo"]), None);
178 assert_eq!(
179 teach_unknown("subject", "zzzzzzzzzz", &["chess", "todo"]),
180 "Unknown subject \"zzzzzzzzzz\". Available: chess, todo"
181 );
182 }
183
184 #[test]
185 fn empty_catalog_teaches_without_a_list() {
186 assert_eq!(
187 teach_unknown("subject", "chess", &[]),
188 "Unknown subject \"chess\"."
189 );
190 }
191}