Skip to main content

kamu_snap_response/
response_code.rs

1//! Wire-level `responseCode` newtype + the `ServiceCode` constrained `u8`.
2//!
3//! SNAP BI's `responseCode` is a 7-digit string: `HHHSSCC` where `HHH` is the
4//! HTTP status (3 digits), `SS` is the service code (2 digits, 00–99), and
5//! `CC` is the case code (2 digits). [`ResponseCode::parse`] is total — it
6//! never errors. Malformed codes preserve `raw()` while `http()` / `service()`
7//! / `case()` return `None`, keeping the `responseMessage` available for
8//! operator inspection (review F-03 fix).
9
10use crate::error::Error;
11
12/// Wire-format `responseCode`.
13///
14/// Construct via [`ResponseCode::parse`] (defensive — never fails) or via
15/// [`ResponseCode::from_parts`] (validated). The raw string is preserved
16/// verbatim even when malformed.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18#[serde(transparent)]
19pub struct ResponseCode(String);
20
21impl ResponseCode {
22    /// Wrap an arbitrary string. Never fails — the value is preserved as-is.
23    pub fn parse(s: impl Into<String>) -> Self {
24        Self(s.into())
25    }
26
27    /// Construct from validated `(http, service, case)` parts. Panics if `http`
28    /// is outside `100..=999` or `case` is outside `0..=99` — either would widen
29    /// the canonical 7-digit `HHHSSCC` wire form (e.g. `case = 100` formats as
30    /// three digits, yielding an 8-char code that every reader then rejects).
31    /// Use [`ResponseCode::parse`] when you cannot guarantee the inputs.
32    pub fn from_parts(http: u16, service: ServiceCode, case: u8) -> Self {
33        assert!((100..=999).contains(&http), "http status out of range");
34        assert!(case <= 99, "case code out of range (must be 0..=99)");
35        Self(format!("{http:03}{:02}{case:02}", service.get()))
36    }
37
38    /// Construct a `2-prefix` success code (`2HH SSCC` with `HH=00`).
39    pub fn success(service: ServiceCode, sub: u8) -> Self {
40        Self::from_parts(200, service, sub)
41    }
42
43    /// The verbatim wire string.
44    pub fn raw(&self) -> &str {
45        &self.0
46    }
47
48    /// Extract the leading HTTP status, defensively. `None` if the code is not
49    /// in the canonical 7-digit form or the leading 3 digits don't form a valid
50    /// HTTP status.
51    pub fn http(&self) -> Option<http::StatusCode> {
52        if self.0.len() != 7 {
53            return None;
54        }
55        let n = self.0.get(..3)?.parse::<u16>().ok()?;
56        http::StatusCode::from_u16(n).ok()
57    }
58
59    /// Extract the service code (positions 4..5). `None` for malformed codes.
60    pub fn service(&self) -> Option<ServiceCode> {
61        if self.0.len() != 7 {
62            return None;
63        }
64        let n = self.0.get(3..5)?.parse::<u8>().ok()?;
65        ServiceCode::new(n)
66    }
67
68    /// Extract the case code (positions 6..7). `None` for malformed codes.
69    pub fn case(&self) -> Option<u8> {
70        if self.0.len() != 7 {
71            return None;
72        }
73        self.0.get(5..7)?.parse::<u8>().ok()
74    }
75
76    /// Inverse classifier: given a received wire code, return the matching
77    /// [`Error`] variant (with empty-string payload where the variant carries
78    /// one). `None` for unknown HTTP+case pairs and for malformed wire forms.
79    ///
80    /// Designed for client-side use — receiving a response and reasoning about
81    /// it typed.
82    pub fn classify(&self) -> Option<Error> {
83        let http = self.http()?.as_u16();
84        let case = self.case()?;
85        Error::from_http_and_case(http, case)
86    }
87}
88
89impl core::fmt::Display for ResponseCode {
90    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        f.write_str(&self.0)
92    }
93}
94
95/// SNAP BI service code (2-digit slot inside `responseCode`).
96///
97/// Construction is total via [`ServiceCode::new`]: values `>99` return `None`,
98/// closing the truncation hazard of the old `service_code % 100` formula.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub struct ServiceCode(u8);
101
102impl ServiceCode {
103    /// Construct a `ServiceCode`. Returns `None` for values `>99`.
104    pub const fn new(code: u8) -> Option<Self> {
105        if code > 99 { None } else { Some(Self(code)) }
106    }
107
108    /// The underlying value.
109    pub const fn get(self) -> u8 {
110        self.0
111    }
112}
113
114impl core::fmt::Display for ServiceCode {
115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116        write!(f, "{:02}", self.0)
117    }
118}
119
120impl serde::Serialize for ServiceCode {
121    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
122        self.0.serialize(serializer)
123    }
124}
125
126impl<'de> serde::Deserialize<'de> for ServiceCode {
127    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
128        let n = u8::deserialize(deserializer)?;
129        ServiceCode::new(n).ok_or_else(|| serde::de::Error::custom("service code must be 0..=99"))
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn from_parts_builds_canonical_seven_digit_code() {
139        let sc = ServiceCode::new(5).unwrap();
140        let rc = ResponseCode::from_parts(404, sc, 11);
141        assert_eq!(rc.raw(), "4040511");
142        assert_eq!(rc.raw().len(), 7);
143        assert_eq!(rc.http().unwrap().as_u16(), 404);
144        assert_eq!(rc.service().unwrap().get(), 5);
145        assert_eq!(rc.case(), Some(11));
146    }
147
148    #[test]
149    #[should_panic(expected = "case code out of range")]
150    fn from_parts_panics_on_case_over_99() {
151        let sc = ServiceCode::new(5).unwrap();
152        // Would format as "100" and widen the wire code to 8 chars — rejected.
153        let _ = ResponseCode::from_parts(404, sc, 100);
154    }
155
156    #[test]
157    #[should_panic(expected = "case code out of range")]
158    fn success_panics_on_sub_over_99() {
159        let sc = ServiceCode::new(0).unwrap();
160        let _ = ResponseCode::success(sc, 200);
161    }
162}