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}