lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

reason.rs (12450B)


      1 //! Stable, bounded reason codes for status and health surfaces.
      2 
      3 use core::{fmt, str::FromStr};
      4 
      5 use serde::{Deserialize, Deserializer, Serialize, Serializer};
      6 
      7 use super::StatusContractError;
      8 
      9 pub const REASON_CODE_MAX_BYTES: usize = 64;
     10 pub const REASON_CODES_MAX_ITEMS: usize = 32;
     11 
     12 /// Shared reason codes whose meanings are service-neutral.
     13 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     14 pub enum CommonReasonCode {
     15     IdentityUnavailable,
     16     DatabaseSchemaMismatch,
     17     DatabaseReadOnly,
     18     DatabaseLowDisk,
     19     RequiredRelayUnavailable,
     20     SubscriberNotActive,
     21     SignerProviderUnavailable,
     22     OutboxInvariantFailed,
     23     PublicationBacklogExceeded,
     24     AdminListenerFailed,
     25     OperationsListenerFailed,
     26     ShutdownInProgress,
     27 }
     28 
     29 impl CommonReasonCode {
     30     #[must_use]
     31     pub const fn as_str(self) -> &'static str {
     32         match self {
     33             Self::IdentityUnavailable => "identity_unavailable",
     34             Self::DatabaseSchemaMismatch => "database_schema_mismatch",
     35             Self::DatabaseReadOnly => "database_read_only",
     36             Self::DatabaseLowDisk => "database_low_disk",
     37             Self::RequiredRelayUnavailable => "required_relay_unavailable",
     38             Self::SubscriberNotActive => "subscriber_not_active",
     39             Self::SignerProviderUnavailable => "signer_provider_unavailable",
     40             Self::OutboxInvariantFailed => "outbox_invariant_failed",
     41             Self::PublicationBacklogExceeded => "publication_backlog_exceeded",
     42             Self::AdminListenerFailed => "admin_listener_failed",
     43             Self::OperationsListenerFailed => "operations_listener_failed",
     44             Self::ShutdownInProgress => "shutdown_in_progress",
     45         }
     46     }
     47 }
     48 
     49 /// A validated stable status reason code.
     50 #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
     51 pub struct ReasonCode(String);
     52 
     53 impl ReasonCode {
     54     pub fn new(value: impl AsRef<str>) -> Result<Self, StatusContractError> {
     55         let value = value.as_ref();
     56         if !valid_reason_code(value) {
     57             return Err(StatusContractError::InvalidReasonCode);
     58         }
     59         Ok(Self(value.to_owned()))
     60     }
     61 
     62     fn from_string(value: String) -> Result<Self, StatusContractError> {
     63         if !valid_reason_code(&value) {
     64             return Err(StatusContractError::InvalidReasonCode);
     65         }
     66         Ok(Self(value))
     67     }
     68 
     69     #[must_use]
     70     pub fn as_str(&self) -> &str {
     71         &self.0
     72     }
     73 }
     74 
     75 impl From<CommonReasonCode> for ReasonCode {
     76     fn from(value: CommonReasonCode) -> Self {
     77         Self(value.as_str().to_owned())
     78     }
     79 }
     80 
     81 impl fmt::Display for ReasonCode {
     82     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     83         formatter.write_str(self.as_str())
     84     }
     85 }
     86 
     87 impl FromStr for ReasonCode {
     88     type Err = StatusContractError;
     89 
     90     fn from_str(value: &str) -> Result<Self, Self::Err> {
     91         Self::new(value)
     92     }
     93 }
     94 
     95 impl Serialize for ReasonCode {
     96     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     97     where
     98         S: Serializer,
     99     {
    100         serializer.serialize_str(self.as_str())
    101     }
    102 }
    103 
    104 impl<'de> Deserialize<'de> for ReasonCode {
    105     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    106     where
    107         D: Deserializer<'de>,
    108     {
    109         struct ReasonCodeVisitor;
    110 
    111         impl<'de> serde::de::Visitor<'de> for ReasonCodeVisitor {
    112             type Value = ReasonCode;
    113 
    114             fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    115                 formatter.write_str("a bounded canonical reason code")
    116             }
    117 
    118             fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
    119             where
    120                 E: serde::de::Error,
    121             {
    122                 ReasonCode::new(value).map_err(E::custom)
    123             }
    124 
    125             fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
    126             where
    127                 E: serde::de::Error,
    128             {
    129                 ReasonCode::from_string(value).map_err(E::custom)
    130             }
    131         }
    132 
    133         deserializer.deserialize_str(ReasonCodeVisitor)
    134     }
    135 }
    136 
    137 /// A unique, canonically sorted, bounded reason-code collection.
    138 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
    139 #[serde(transparent)]
    140 pub struct ReasonCodes(Vec<ReasonCode>);
    141 
    142 impl ReasonCodes {
    143     #[must_use]
    144     pub const fn empty() -> Self {
    145         Self(Vec::new())
    146     }
    147 
    148     pub fn new(values: impl IntoIterator<Item = ReasonCode>) -> Result<Self, StatusContractError> {
    149         let mut bounded = Vec::with_capacity(REASON_CODES_MAX_ITEMS);
    150         for value in values.into_iter().take(REASON_CODES_MAX_ITEMS + 1) {
    151             if bounded.len() == REASON_CODES_MAX_ITEMS {
    152                 return Err(StatusContractError::TooManyReasonCodes {
    153                     maximum: REASON_CODES_MAX_ITEMS,
    154                 });
    155             }
    156             bounded.push(value);
    157         }
    158         bounded.sort_unstable();
    159         bounded.dedup();
    160         Ok(Self(bounded))
    161     }
    162 
    163     #[must_use]
    164     pub fn as_slice(&self) -> &[ReasonCode] {
    165         &self.0
    166     }
    167 
    168     #[must_use]
    169     pub fn is_empty(&self) -> bool {
    170         self.0.is_empty()
    171     }
    172 }
    173 
    174 impl<'de> Deserialize<'de> for ReasonCodes {
    175     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    176     where
    177         D: Deserializer<'de>,
    178     {
    179         struct ReasonCodesVisitor;
    180 
    181         impl<'de> serde::de::Visitor<'de> for ReasonCodesVisitor {
    182             type Value = ReasonCodes;
    183 
    184             fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    185                 formatter.write_str("a bounded canonically sorted reason-code array")
    186             }
    187 
    188             fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
    189             where
    190                 A: serde::de::SeqAccess<'de>,
    191             {
    192                 let mut values = Vec::with_capacity(
    193                     sequence
    194                         .size_hint()
    195                         .unwrap_or(0)
    196                         .min(REASON_CODES_MAX_ITEMS),
    197                 );
    198                 while let Some(value) = sequence.next_element::<ReasonCode>()? {
    199                     if values.len() == REASON_CODES_MAX_ITEMS {
    200                         return Err(serde::de::Error::custom(
    201                             "reason-code collection exceeds its item limit",
    202                         ));
    203                     }
    204                     if values.last().is_some_and(|previous| previous >= &value) {
    205                         return Err(serde::de::Error::custom(
    206                             "reason codes must be unique and canonically sorted",
    207                         ));
    208                     }
    209                     values.push(value);
    210                 }
    211                 Ok(ReasonCodes(values))
    212             }
    213         }
    214 
    215         deserializer.deserialize_seq(ReasonCodesVisitor)
    216     }
    217 }
    218 
    219 fn valid_reason_code(value: &str) -> bool {
    220     let mut bytes = value.bytes();
    221     let Some(first) = bytes.next() else {
    222         return false;
    223     };
    224     value.len() <= REASON_CODE_MAX_BYTES
    225         && first.is_ascii_lowercase()
    226         && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
    227 }
    228 
    229 #[cfg(test)]
    230 mod tests {
    231     use super::*;
    232 
    233     #[test]
    234     fn common_codes_are_stable_valid_and_unique() {
    235         let codes = [
    236             CommonReasonCode::IdentityUnavailable,
    237             CommonReasonCode::DatabaseSchemaMismatch,
    238             CommonReasonCode::DatabaseReadOnly,
    239             CommonReasonCode::DatabaseLowDisk,
    240             CommonReasonCode::RequiredRelayUnavailable,
    241             CommonReasonCode::SubscriberNotActive,
    242             CommonReasonCode::SignerProviderUnavailable,
    243             CommonReasonCode::OutboxInvariantFailed,
    244             CommonReasonCode::PublicationBacklogExceeded,
    245             CommonReasonCode::AdminListenerFailed,
    246             CommonReasonCode::OperationsListenerFailed,
    247             CommonReasonCode::ShutdownInProgress,
    248         ];
    249         let reasons = ReasonCodes::new(codes.map(ReasonCode::from)).expect("common reasons");
    250         assert_eq!(reasons.as_slice().len(), codes.len());
    251         assert!(reasons.as_slice().windows(2).all(|pair| pair[0] < pair[1]));
    252     }
    253 
    254     #[test]
    255     fn reason_code_validation_matches_frozen_contract() {
    256         for valid in ["a", "database_low_disk", "myc_reason_01"] {
    257             assert_eq!(ReasonCode::new(valid).unwrap().as_str(), valid);
    258         }
    259         assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES)).is_ok());
    260 
    261         for invalid in ["", "Upper", "1reason", "a-b", "a.b", "a b", "café"] {
    262             assert_eq!(
    263                 ReasonCode::new(invalid),
    264                 Err(StatusContractError::InvalidReasonCode)
    265             );
    266         }
    267         assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES + 1)).is_err());
    268 
    269         let very_large = "a".repeat(4 * 1024 * 1024);
    270         assert_eq!(
    271             ReasonCode::new(&very_large),
    272             Err(StatusContractError::InvalidReasonCode)
    273         );
    274         let encoded = serde_json::to_string(&very_large).expect("large reason JSON");
    275         assert!(serde_json::from_str::<ReasonCode>(&encoded).is_err());
    276     }
    277 
    278     #[test]
    279     fn owned_text_traits_empty_state_and_deserializer_expectations_are_bound() {
    280         let owned = ReasonCode::from_string("owned_reason".to_owned()).expect("owned reason");
    281         assert_eq!(owned.to_string(), "owned_reason");
    282         assert_eq!(
    283             "parsed_reason".parse::<ReasonCode>().unwrap().as_str(),
    284             "parsed_reason"
    285         );
    286         assert_eq!(
    287             ReasonCode::from_string("Invalid".to_owned()),
    288             Err(StatusContractError::InvalidReasonCode)
    289         );
    290 
    291         assert!(ReasonCodes::empty().is_empty());
    292         assert!(!ReasonCodes::new([owned]).unwrap().is_empty());
    293         assert!(serde_json::from_str::<ReasonCode>("42").is_err());
    294         assert!(serde_json::from_str::<ReasonCodes>(r#"{"reason":"owned_reason"}"#).is_err());
    295     }
    296 
    297     #[test]
    298     fn collections_sort_deduplicate_bound_and_serialize_canonically() {
    299         let reasons = ReasonCodes::new([
    300             ReasonCode::new("z_reason").unwrap(),
    301             ReasonCode::new("a_reason").unwrap(),
    302             ReasonCode::new("z_reason").unwrap(),
    303         ])
    304         .expect("bounded reasons");
    305         assert_eq!(
    306             serde_json::to_string(&reasons).unwrap(),
    307             r#"["a_reason","z_reason"]"#
    308         );
    309 
    310         let over = (0..=REASON_CODES_MAX_ITEMS)
    311             .map(|index| ReasonCode::new(format!("reason_{index:02}")).unwrap());
    312         assert_eq!(
    313             ReasonCodes::new(over),
    314             Err(StatusContractError::TooManyReasonCodes {
    315                 maximum: REASON_CODES_MAX_ITEMS
    316             })
    317         );
    318         assert!(serde_json::from_str::<ReasonCodes>(r#"["z_reason","a_reason"]"#).is_err());
    319         assert!(serde_json::from_str::<ReasonCodes>(r#"["a_reason","a_reason"]"#).is_err());
    320     }
    321 
    322     #[test]
    323     fn collection_ingestion_stops_at_maximum_plus_one() {
    324         use core::cell::Cell;
    325 
    326         struct CountedInfinite<'a> {
    327             calls: &'a Cell<usize>,
    328             value: ReasonCode,
    329         }
    330 
    331         impl Iterator for CountedInfinite<'_> {
    332             type Item = ReasonCode;
    333 
    334             fn next(&mut self) -> Option<Self::Item> {
    335                 self.calls.set(self.calls.get() + 1);
    336                 Some(self.value.clone())
    337             }
    338         }
    339 
    340         let calls = Cell::new(0);
    341         assert_eq!(
    342             ReasonCodes::new(CountedInfinite {
    343                 calls: &calls,
    344                 value: ReasonCode::new("same_reason").unwrap(),
    345             }),
    346             Err(StatusContractError::TooManyReasonCodes {
    347                 maximum: REASON_CODES_MAX_ITEMS,
    348             })
    349         );
    350         assert_eq!(calls.get(), REASON_CODES_MAX_ITEMS + 1);
    351 
    352         let maximum = (0..REASON_CODES_MAX_ITEMS)
    353             .map(|index| format!("reason_{index:02}"))
    354             .collect::<Vec<_>>();
    355         let maximum_json = serde_json::to_string(&maximum).unwrap();
    356         assert_eq!(
    357             serde_json::from_str::<ReasonCodes>(&maximum_json)
    358                 .unwrap()
    359                 .as_slice()
    360                 .len(),
    361             REASON_CODES_MAX_ITEMS
    362         );
    363         let over_maximum = (0..=REASON_CODES_MAX_ITEMS)
    364             .map(|index| format!("reason_{index:02}"))
    365             .collect::<Vec<_>>();
    366         assert!(
    367             serde_json::from_str::<ReasonCodes>(&serde_json::to_string(&over_maximum).unwrap())
    368                 .is_err()
    369         );
    370     }
    371 }