fhir_core/decimal.rs
1//! The FHIR `decimal` primitive.
2//!
3//! URL: <http://hl7.org/fhir/StructureDefinition/decimal>
4//!
5//! `decimal` is defined identically in R3, R4 and R5 — "a rational number
6//! with implicit precision" — so unlike the other primitives it is written
7//! once here and re-exported by each release's `types` module, alongside
8//! [`Coded`](crate::coded) and [`temporal`](crate::temporal).
9//!
10//! See spec 02 (R2.2) for why the representation is lexical.
11
12use ::serde::{Deserialize, Serialize};
13
14/// A rational number with implicit precision, used to represent measurement
15/// values and other quantities where the number of significant digits carries
16/// meaning.
17///
18/// # Precision is data
19///
20/// FHIR states that the precision of a decimal has significance: a laboratory
21/// result of `0.50` mmol/L claims two significant figures where `0.5` claims
22/// one, and a dose of `1.000` mg is a different assertion from `1.0` mg. This
23/// type therefore stores the **lexical form** it was given and emits it back
24/// unchanged (spec R2.2).
25///
26/// Backed by `f64` — which is what `serde_json::Number` is by default —
27/// `0.50` becomes `0.5`, `1.000` becomes `1.0`, and
28/// `12345678901234567890.5` becomes `1.2345678901234567e+19`. This crate
29/// therefore enables `serde_json/arbitrary_precision` **unconditionally**, so
30/// a `Number` carries the lexeme it was parsed from. Cargo features are
31/// additive and cannot be switched off by a dependent, which makes precision
32/// a guarantee rather than a default someone can lose.
33///
34/// `float_roundtrip` is enabled alongside it, for the values that do become
35/// an `f64` rather than staying a lexeme — [`as_f64`](Decimal::as_f64), and
36/// any dependent deserializing straight into `f64`. It makes
37/// `f64` → JSON → `f64` return the value it started as instead of the
38/// shortest form that displays the same. The two features cover opposite
39/// directions of the same guarantee; see
40/// `spec/serde-json-float-roundtrip-arbitrary-precision/`.
41///
42/// The cost is real and worth stating: `arbitrary_precision` is global to the
43/// compiled binary, so every other crate's `serde_json::Number` in the same
44/// build also becomes lexeme-preserving, and `Number` arithmetic goes through
45/// `as_f64()`. For a library whose numbers are drug doses and lab results,
46/// that is the correct side to err on.
47///
48/// # Equality is lexical, ordering is numeric
49///
50/// `Decimal("1.0") != Decimal("1.00")`, because the two say different things
51/// about precision and must survive a round trip distinctly. They *compare*
52/// equal, because they denote the same quantity:
53///
54/// ```
55/// use fhir::decimal::Decimal;
56/// use std::cmp::Ordering;
57///
58/// let one_dp = Decimal::new("1.0").unwrap();
59/// let two_dp = Decimal::new("1.00").unwrap();
60/// assert_ne!(one_dp, two_dp);
61/// assert_eq!(one_dp.partial_cmp(&two_dp), Some(Ordering::Equal));
62/// ```
63///
64/// # JSON
65///
66/// The lexeme survives every serde path this crate uses — `from_str`,
67/// `from_slice`, `from_reader`, `from_value`, and through the
68/// `#[serde(flatten)]` that choice elements rely on. A `serde_json::Value`
69/// built in the same binary is likewise lexeme-preserving, so
70/// `json!(0.50) != json!(0.5)`, and a round-trip test comparing `Value`s can
71/// see precision loss rather than silently tolerating it (spec R13.3).
72///
73/// ```
74/// use fhir::decimal::Decimal;
75///
76/// let parsed: Decimal = ::serde_json::from_str("0.50").unwrap();
77/// assert_eq!(parsed.as_str(), "0.50");
78/// assert_eq!(::serde_json::to_string(&parsed).unwrap(), "0.50");
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Decimal(::serde_json::Number);
82
83/// The reason a string is not a FHIR `decimal`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct DecimalError(String);
86
87impl std::fmt::Display for DecimalError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "not a FHIR decimal: {:?}", self.0)
90 }
91}
92
93impl std::error::Error for DecimalError {}
94
95impl Decimal {
96 /// A decimal from its lexical form, checked against the FHIR `decimal`
97 /// production `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`.
98 ///
99 /// # Errors
100 ///
101 /// Returns [`DecimalError`] when the text is not a FHIR decimal.
102 pub fn new(lexeme: impl Into<String>) -> Result<Self, DecimalError> {
103 let lexeme = lexeme.into();
104 if !is_fhir_decimal(&lexeme) {
105 return Err(DecimalError(lexeme));
106 }
107 // Parsing the lexeme back through serde_json is what stores it: with
108 // `arbitrary_precision` a `Number` *is* its lexeme.
109 ::serde_json::from_str::<::serde_json::Number>(&lexeme)
110 .map(Decimal)
111 .map_err(|_| DecimalError(lexeme))
112 }
113
114 /// The stored lexical form, exactly as received.
115 #[must_use]
116 pub fn as_str(&self) -> &str {
117 self.0.as_str()
118 }
119
120 /// The value as an `f64`, which is lossy by definition — use it for
121 /// arithmetic, never for storage or comparison.
122 #[must_use]
123 pub fn as_f64(&self) -> f64 {
124 self.0.as_f64().unwrap_or(f64::NAN)
125 }
126
127 /// A decimal from an already-parsed [`serde_json::Number`], for callers
128 /// holding a [`serde_json::Value`]. Lossless, because this crate
129 /// guarantees `arbitrary_precision`.
130 #[must_use]
131 pub fn from_json_number(n: &::serde_json::Number) -> Self {
132 Decimal(n.clone())
133 }
134
135 /// The underlying `serde_json::Number`, for interoperating with code
136 /// that speaks `serde_json` directly.
137 #[must_use]
138 pub fn as_number(&self) -> &::serde_json::Number {
139 &self.0
140 }
141}
142
143impl std::fmt::Display for Decimal {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.write_str(self.0.as_str())
146 }
147}
148
149impl std::str::FromStr for Decimal {
150 type Err = DecimalError;
151
152 fn from_str(s: &str) -> Result<Self, Self::Err> {
153 Decimal::new(s)
154 }
155}
156
157/// Numeric ordering over lexically distinct values: `1.0` and `1.00` are the
158/// same quantity even though they are not the same assertion.
159impl PartialOrd for Decimal {
160 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
161 self.as_f64().partial_cmp(&other.as_f64())
162 }
163}
164
165impl Default for Decimal {
166 fn default() -> Self {
167 Decimal(::serde_json::Number::from(0))
168 }
169}
170
171/// `Decimal` validates its own lexeme (spec R2.6).
172///
173/// One impl for one shared type: were this written per release, compiling two
174/// releases together would be a conflicting-impl error.
175impl crate::validate::Validate for Decimal {
176 fn validate(&self) -> Vec<crate::validate::ValidationIssue> {
177 if is_fhir_decimal(self.as_str()) {
178 Vec::new()
179 } else {
180 // The path is the datatype's own label, which the deriving
181 // parent prefixes with the field name (e.g. `value.decimal`).
182 vec![crate::validate::ValidationIssue::new(
183 "decimal",
184 "must match the FHIR decimal production \
185 -?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?",
186 )]
187 }
188 }
189}
190
191/// The FHIR `decimal` lexical production:
192/// `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`.
193fn is_fhir_decimal(s: &str) -> bool {
194 let b = s.as_bytes();
195 let mut i = 0;
196 if i < b.len() && b[i] == b'-' {
197 i += 1;
198 }
199 // Integer part: `0` alone, or a run that does not lead with zero.
200 let start = i;
201 while i < b.len() && b[i].is_ascii_digit() {
202 i += 1;
203 }
204 if i == start {
205 return false;
206 }
207 if i - start > 1 && b[start] == b'0' {
208 return false;
209 }
210 // Optional fraction, at least one digit.
211 if i < b.len() && b[i] == b'.' {
212 i += 1;
213 let frac = i;
214 while i < b.len() && b[i].is_ascii_digit() {
215 i += 1;
216 }
217 if i == frac {
218 return false;
219 }
220 }
221 // Optional exponent, at least one digit.
222 if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
223 i += 1;
224 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
225 i += 1;
226 }
227 let exp = i;
228 while i < b.len() && b[i].is_ascii_digit() {
229 i += 1;
230 }
231 if i == exp {
232 return false;
233 }
234 }
235 i == b.len()
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn test_default() {
244 assert_eq!(Decimal::default().as_str(), "0");
245 }
246
247 #[test]
248 fn test_serde() {
249 let value: Decimal = ::serde_json::from_str("3.5").expect("from_str");
250 assert_eq!(::serde_json::to_string(&value).expect("to_string"), "3.5");
251 }
252
253 /// Spec 02 acceptance 2a: the values the `serde_json::Number`
254 /// representation silently altered.
255 #[test]
256 fn lexical_form_survives_a_round_trip() {
257 for input in [
258 "0.50",
259 "1.000",
260 "1e-7",
261 "-0.0001",
262 "0.1234567890123456789012345",
263 "12345678901234567890.5",
264 ] {
265 let parsed: Decimal =
266 ::serde_json::from_str(input).unwrap_or_else(|e| panic!("parse {input}: {e}"));
267 let out = ::serde_json::to_string(&parsed).expect("to_string");
268 assert_eq!(out, input, "{input} did not survive");
269 }
270 }
271
272 /// Spec 02 acceptance 2b.
273 #[test]
274 fn equality_is_lexical_and_ordering_is_numeric() {
275 let one_dp = Decimal::new("1.0").expect("valid");
276 let two_dp = Decimal::new("1.00").expect("valid");
277 assert_ne!(one_dp, two_dp);
278 assert_eq!(one_dp.partial_cmp(&two_dp), Some(std::cmp::Ordering::Equal));
279 assert!(Decimal::new("2").expect("valid") > one_dp);
280 }
281
282 #[test]
283 fn rejects_non_decimals() {
284 for bad in [
285 "", "-", ".5", "1.", "01", "1e", "1.2.3", " 1", "1 ", "NaN", "+1",
286 ] {
287 assert!(Decimal::new(bad).is_err(), "{bad:?} should be rejected");
288 }
289 }
290
291 #[test]
292 fn accepts_the_production() {
293 for good in [
294 "0", "-0", "1", "-1", "0.0", "1.5", "1e10", "1E+10", "-2.5e-3",
295 ] {
296 assert!(Decimal::new(good).is_ok(), "{good:?} should be accepted");
297 }
298 }
299}
300
301#[cfg(test)]
302mod oracle_tests {
303 //! Spec R13.3: the round-trip oracle compares `serde_json::Value`s, so it
304 //! can only see a decimal regression if `Value` equality is sensitive to
305 //! the lexeme. That sensitivity comes from `arbitrary_precision` being a
306 //! hard dependency feature (R2.2) — if it ever stops being one, these
307 //! tests fail here rather than letting the whole corpus suite go quietly
308 //! blind.
309
310 #[test]
311 fn value_equality_distinguishes_trailing_zeros() {
312 let two_sig: ::serde_json::Value = ::serde_json::from_str("0.50").expect("parse");
313 let one_sig: ::serde_json::Value = ::serde_json::from_str("0.5").expect("parse");
314 assert_ne!(
315 two_sig, one_sig,
316 "Value equality cannot see decimal precision; the round-trip \
317 oracle is blind and R13.3 is violated"
318 );
319 }
320
321 #[test]
322 fn value_round_trip_keeps_the_lexeme() {
323 for input in ["0.50", "1.000", "12345678901234567890.5"] {
324 let v: ::serde_json::Value = ::serde_json::from_str(input).expect("parse");
325 assert_eq!(::serde_json::to_string(&v).expect("serialize"), input);
326 }
327 }
328}
329
330#[cfg(test)]
331mod validate_tests {
332 use super::*;
333 use crate::validate::Validate;
334
335 #[test]
336 fn a_well_formed_decimal_validates() {
337 assert!(Decimal::new("0.50").expect("valid").is_valid());
338 assert!(Decimal::default().is_valid());
339 }
340
341 #[test]
342 fn a_decimal_that_slipped_past_the_constructor_is_reported() {
343 // Deserialization goes through `serde_json::Number`, whose grammar is
344 // JSON's rather than FHIR's. They agree today, so this guards the
345 // narrow case where they would not.
346 let via_serde: Decimal = ::serde_json::from_str("1e400").expect("json accepts it");
347 let issues = via_serde.validate();
348 assert!(
349 issues.is_empty() || issues[0].message.contains("FHIR decimal production"),
350 "unexpected issue: {issues:?}"
351 );
352 }
353}