unb-core 2.0.3

Core unb protocol types: envelope, session, routing, taxonomy
Documentation
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\"."
        );
    }
}