use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
VersionMismatch,
Protocol,
UnknownSubject,
UnresolvedAtPeer,
PeerUnreachable,
HopLimitExceeded,
InvalidInput,
Unauthorized,
Conflict,
PayloadTooLarge,
Busy,
Cancelled,
Internal,
}
impl ErrorCode {
pub fn status(self) -> http::StatusCode {
let status = match self {
ErrorCode::InvalidInput | ErrorCode::Protocol => 400,
ErrorCode::Unauthorized => 401,
ErrorCode::UnknownSubject => 404,
ErrorCode::Conflict => 409,
ErrorCode::PayloadTooLarge => 413,
ErrorCode::UnresolvedAtPeer => 421,
ErrorCode::Cancelled => 499,
ErrorCode::Internal => 500,
ErrorCode::PeerUnreachable => 502,
ErrorCode::Busy => 503,
ErrorCode::VersionMismatch => 505,
ErrorCode::HopLimitExceeded => 508,
};
http::StatusCode::from_u16(status).expect("mapped statuses are valid")
}
pub fn token(self) -> &'static str {
match self {
ErrorCode::VersionMismatch => "VERSION_MISMATCH",
ErrorCode::Protocol => "PROTOCOL",
ErrorCode::UnknownSubject => "UNKNOWN_SUBJECT",
ErrorCode::UnresolvedAtPeer => "UNRESOLVED_AT_PEER",
ErrorCode::PeerUnreachable => "PEER_UNREACHABLE",
ErrorCode::HopLimitExceeded => "HOP_LIMIT_EXCEEDED",
ErrorCode::InvalidInput => "INVALID_INPUT",
ErrorCode::Unauthorized => "UNAUTHORIZED",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::PayloadTooLarge => "PAYLOAD_TOO_LARGE",
ErrorCode::Busy => "BUSY",
ErrorCode::Cancelled => "CANCELLED",
ErrorCode::Internal => "INTERNAL",
}
}
pub fn from_status(status: http::StatusCode) -> ErrorCode {
match status.as_u16() {
400 => ErrorCode::InvalidInput,
401 => ErrorCode::Unauthorized,
404 => ErrorCode::UnknownSubject,
409 => ErrorCode::Conflict,
413 => ErrorCode::PayloadTooLarge,
421 => ErrorCode::UnresolvedAtPeer,
499 => ErrorCode::Cancelled,
502 => ErrorCode::PeerUnreachable,
503 => ErrorCode::Busy,
505 => ErrorCode::VersionMismatch,
508 => ErrorCode::HopLimitExceeded,
_ => ErrorCode::Internal,
}
}
}
const SUGGEST_DISTANCE: usize = 3;
pub fn suggest<'a>(input: &str, known: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
known
.into_iter()
.map(|candidate| (levenshtein(input, candidate), candidate))
.filter(|(distance, _)| *distance <= SUGGEST_DISTANCE)
.min_by_key(|(distance, _)| *distance)
.map(|(_, candidate)| candidate)
}
pub fn teach_unknown(what: &str, input: &str, known: &[&str]) -> String {
match suggest(input, known.iter().copied()) {
Some(candidate) => format!("Unknown {what} \"{input}\". Did you mean \"{candidate}\"?"),
None if known.is_empty() => format!("Unknown {what} \"{input}\"."),
None => format!(
"Unknown {what} \"{input}\". Available: {}",
known.join(", ")
),
}
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut current = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let substitution = previous[j] + usize::from(ca != cb);
current.push(substitution.min(previous[j + 1] + 1).min(current[j] + 1));
}
previous = current;
}
previous[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_error_code_maps_to_its_documented_status() {
let expected = [
(ErrorCode::InvalidInput, 400),
(ErrorCode::Protocol, 400),
(ErrorCode::Unauthorized, 401),
(ErrorCode::UnknownSubject, 404),
(ErrorCode::Conflict, 409),
(ErrorCode::PayloadTooLarge, 413),
(ErrorCode::UnresolvedAtPeer, 421),
(ErrorCode::Cancelled, 499),
(ErrorCode::Internal, 500),
(ErrorCode::PeerUnreachable, 502),
(ErrorCode::Busy, 503),
(ErrorCode::VersionMismatch, 505),
(ErrorCode::HopLimitExceeded, 508),
];
for (code, status) in expected {
assert_eq!(code.status().as_u16(), status, "{code:?}");
}
}
#[test]
fn statuses_map_back_with_the_collision_default() {
for code in [
ErrorCode::Unauthorized,
ErrorCode::UnknownSubject,
ErrorCode::Conflict,
ErrorCode::PayloadTooLarge,
ErrorCode::UnresolvedAtPeer,
ErrorCode::Cancelled,
ErrorCode::Internal,
ErrorCode::PeerUnreachable,
ErrorCode::Busy,
ErrorCode::VersionMismatch,
ErrorCode::HopLimitExceeded,
] {
assert_eq!(ErrorCode::from_status(code.status()), code, "{code:?}");
}
assert_eq!(
ErrorCode::from_status(http::StatusCode::BAD_REQUEST),
ErrorCode::InvalidInput
);
assert_eq!(
ErrorCode::from_status(http::StatusCode::IM_A_TEAPOT),
ErrorCode::Internal
);
}
#[test]
fn close_misspelling_suggests_the_nearest_name() {
assert_eq!(suggest("ches", ["chess", "todo"]), Some("chess"));
assert_eq!(
teach_unknown("subject", "ches", &["chess", "todo"]),
"Unknown subject \"ches\". Did you mean \"chess\"?"
);
}
#[test]
fn distant_input_lists_what_is_available() {
assert_eq!(suggest("zzzzzzzzzz", ["chess", "todo"]), None);
assert_eq!(
teach_unknown("subject", "zzzzzzzzzz", &["chess", "todo"]),
"Unknown subject \"zzzzzzzzzz\". Available: chess, todo"
);
}
#[test]
fn empty_catalog_teaches_without_a_list() {
assert_eq!(
teach_unknown("subject", "chess", &[]),
"Unknown subject \"chess\"."
);
}
}