Skip to main content

nhs_number/
serde_string.rs

1//! Opt-in serde wrapper that puts the **canonical string form** on the
2//! wire instead of the default `{ "digits": [...] }` struct shape.
3//!
4//! Wrap an [`NHSNumber`] in [`NHSNumberString`] when your wire format
5//! wants the human-readable `"DDD DDD DDDD"` form:
6//!
7//! - **Serialize** renders via [`Display`](std::fmt::Display) — always
8//!   the twelve-character canonical form (`spec/05-string-forms.md` §5.1).
9//! - **Deserialize** parses via [`FromStr`] — it
10//!   accepts exactly the two documented shapes (`"DDDDDDDDDD"` and
11//!   `"DDD DDD DDDD"`, `spec/05-string-forms.md` §5.2) and therefore **guarantees every
12//!   digit is in `0..=9`**.
13//!
14//! The deserialisation error message never echoes the rejected input,
15//! so a near-miss NHS Number cannot leak into logs through the error
16//! path (see `AGENTS/safety.md` §3).
17//!
18//! See `spec/11-serialisation.md` §11.2 (rule R22) for the contract.
19//!
20//! Example — round-trip through JSON:
21//!
22//! ```rust
23//! use nhs_number::NHSNumber;
24//! use nhs_number::serde_string::NHSNumberString;
25//! use std::str::FromStr;
26//!
27//! let n = NHSNumber::from_str("999 100 0003").unwrap();
28//! let wrapped = NHSNumberString(n);
29//!
30//! let json = serde_json::to_string(&wrapped).unwrap();
31//! assert_eq!(json, r#""999 100 0003""#);
32//!
33//! let back: NHSNumberString = serde_json::from_str(&json).unwrap();
34//! assert_eq!(back.0, n);
35//! ```
36
37use crate::NHSNumber;
38use crate::parse_error::ParseError;
39use serde::{Deserialize, Deserializer, Serialize, Serializer};
40use std::fmt;
41use std::str::FromStr;
42
43/// Newtype that serialises the wrapped [`NHSNumber`] as its canonical
44/// `"DDD DDD DDDD"` string and deserialises via the crate's
45/// [`FromStr`] parser.
46///
47/// The inner value is a public tuple field, so wrapping and unwrapping
48/// are plain constructor / field syntax:
49///
50/// ```rust
51/// use nhs_number::NHSNumber;
52/// use nhs_number::serde_string::NHSNumberString;
53///
54/// let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
55/// let wrapped = NHSNumberString(n);
56/// let unwrapped: NHSNumber = wrapped.0;
57/// assert_eq!(unwrapped, n);
58/// ```
59///
60/// `From` conversions are provided in both directions:
61///
62/// ```rust
63/// use nhs_number::NHSNumber;
64/// use nhs_number::serde_string::NHSNumberString;
65///
66/// let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
67/// let wrapped = NHSNumberString::from(n);
68/// let back = NHSNumber::from(wrapped);
69/// assert_eq!(back, n);
70/// ```
71///
72/// Deserialisation accepts both documented input shapes and rejects
73/// everything else, exactly like [`FromStr`]:
74///
75/// ```rust
76/// use nhs_number::serde_string::NHSNumberString;
77///
78/// let tight: NHSNumberString = serde_json::from_str(r#""9991000003""#).unwrap();
79/// let canonical: NHSNumberString = serde_json::from_str(r#""999 100 0003""#).unwrap();
80/// assert_eq!(tight, canonical);
81///
82/// assert!(serde_json::from_str::<NHSNumberString>(r#""999-100-0003""#).is_err());
83/// ```
84///
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct NHSNumberString(pub NHSNumber);
87
88impl From<NHSNumber> for NHSNumberString {
89    fn from(n: NHSNumber) -> Self {
90        NHSNumberString(n)
91    }
92}
93
94impl From<NHSNumberString> for NHSNumber {
95    fn from(w: NHSNumberString) -> Self {
96        w.0
97    }
98}
99
100/// Delegates to the wrapped [`NHSNumber`]'s canonical form.
101impl fmt::Display for NHSNumberString {
102    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
103        self.0.fmt(f)
104    }
105}
106
107/// Delegates to the wrapped [`NHSNumber`]'s parser.
108impl FromStr for NHSNumberString {
109    type Err = ParseError;
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        NHSNumber::from_str(s).map(NHSNumberString)
112    }
113}
114
115/// Serialise as the canonical twelve-character string (`spec/05-string-forms.md` §5.1).
116impl Serialize for NHSNumberString {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        serializer.collect_str(&self.0)
122    }
123}
124
125/// Deserialise from a string via [`FromStr`].
126///
127/// Accepts exactly the two documented shapes; the error message for a
128/// rejected string never includes the string itself.
129impl<'de> Deserialize<'de> for NHSNumberString {
130    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
131    where
132        D: Deserializer<'de>,
133    {
134        struct Visitor;
135
136        impl serde::de::Visitor<'_> for Visitor {
137            type Value = NHSNumberString;
138
139            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
140                f.write_str("an NHS Number string in \"DDDDDDDDDD\" or \"DDD DDD DDDD\" form")
141            }
142
143            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
144            where
145                E: serde::de::Error,
146            {
147                // Deliberately no payload echo: the rejected candidate
148                // must not leak into error messages or logs
149                // (AGENTS/safety.md §3).
150                NHSNumberString::from_str(v).map_err(|_| E::custom("invalid NHS Number string"))
151            }
152        }
153
154        deserializer.deserialize_str(Visitor)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_serialize_is_canonical_quoted_string() {
164        let w = NHSNumberString(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
165        let actual = serde_json::to_string(&w).unwrap();
166        let expect = r#""999 100 0003""#;
167        assert_eq!(actual, expect);
168    }
169
170    #[test]
171    fn test_deserialize_canonical_form() {
172        let actual: NHSNumberString = serde_json::from_str(r#""999 100 0003""#).unwrap();
173        let expect = NHSNumberString(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
174        assert_eq!(actual, expect);
175    }
176
177    #[test]
178    fn test_deserialize_tight_form() {
179        let actual: NHSNumberString = serde_json::from_str(r#""9991000003""#).unwrap();
180        let expect = NHSNumberString(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
181        assert_eq!(actual, expect);
182    }
183
184    #[test]
185    fn test_round_trip() {
186        for digits in [[0; 10], [9; 10], [9, 4, 3, 4, 7, 6, 5, 9, 1, 9]] {
187            let w = NHSNumberString(NHSNumber::new(digits));
188            let json = serde_json::to_string(&w).unwrap();
189            let back: NHSNumberString = serde_json::from_str(&json).unwrap();
190            assert_eq!(back, w);
191        }
192    }
193
194    #[test]
195    fn test_deserialize_rejects_invalid_strings() {
196        for bad in [
197            r#""""#,
198            r#""999-100-0003""#,
199            r#"" 999 100 0003""#,
200            r#""999 100 00030""#,
201            r#""abc def ghij""#,
202        ] {
203            assert!(
204                serde_json::from_str::<NHSNumberString>(bad).is_err(),
205                "{bad} must be rejected"
206            );
207        }
208    }
209
210    #[test]
211    fn test_deserialize_rejects_non_strings() {
212        assert!(serde_json::from_str::<NHSNumberString>("9991000003").is_err());
213        assert!(serde_json::from_str::<NHSNumberString>("null").is_err());
214        assert!(
215            serde_json::from_str::<NHSNumberString>(r#"{"digits":[9,9,9,1,0,0,0,0,0,3]}"#).is_err()
216        );
217    }
218
219    #[test]
220    fn test_deserialize_error_does_not_echo_input() {
221        // Safety §3: a near-miss candidate must not leak through the
222        // error path. The distinctive digit run must be absent from the
223        // error's message.
224        let err = serde_json::from_str::<NHSNumberString>(r#""999-100-0003""#).unwrap_err();
225        let message = err.to_string();
226        assert!(
227            !message.contains("999-100-0003") && !message.contains("9991000003"),
228            "error message must not echo the input: {message}"
229        );
230    }
231
232    #[test]
233    fn test_display_and_from_str_delegate() {
234        let w = NHSNumberString(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
235        assert_eq!(w.to_string(), "999 100 0003");
236        let parsed = NHSNumberString::from_str("999 100 0003").unwrap();
237        assert_eq!(parsed, w);
238    }
239
240    #[test]
241    fn test_from_conversions_round_trip() {
242        let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
243        let w = NHSNumberString::from(n);
244        let back = NHSNumber::from(w);
245        assert_eq!(back, n);
246    }
247}