Skip to main content

unb_core/
taxonomy.rs

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}