1use std::fmt::{self, Debug};
2use std::hash::Hash;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use sofie_core::show::Fields;
6use sofie_core::{
7 Cast, ColumnReader, ColumnSpec, Error, FieldError, Result, Show, Shown, SqlType, SqlValue,
8};
9
10#[cfg(feature = "web")]
11use crate::Conventions;
12use crate::decimal::{OUT_OF_RANGE, Refusal, divide_rounded, parse_scaled, scaled_text};
13use crate::refusal::{refusal, refusal_of};
14use crate::{Control, Input, Part, Present};
15
16const DECIMALS: u8 = 3;
17const THOUSANDTHS_PER_UNIT: f64 = 1000.0;
18const MAXIMUM_THOUSANDTHS: i64 = 1 << 50;
21
22pub trait Unit: Copy + Eq + Hash + Debug + 'static {
24 const ALL: &'static [Self];
25 const NAMES: &'static [&'static str];
26
27 fn name(self) -> &'static str;
28
29 fn steps(self) -> i64;
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub struct Measurement<U> {
35 quantity: Thousandths,
36 unit: U,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40struct Thousandths(i64);
41
42impl fmt::Display for Thousandths {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 formatter.write_str(&scaled_text(self.0, DECIMALS))
45 }
46}
47
48impl<U: Unit> Measurement<U> {
49 pub fn try_new(thousandths: i64, unit: U) -> Result<Self> {
50 if !(-MAXIMUM_THOUSANDTHS..=MAXIMUM_THOUSANDTHS).contains(&thousandths) {
51 let (key, english) = OUT_OF_RANGE;
52 return Err(refusal("quantity", key, english));
53 }
54 Ok(Self {
55 quantity: Thousandths(thousandths),
56 unit,
57 })
58 }
59
60 pub fn parse(quantity: &str, unit: U) -> Result<Self> {
61 let thousandths = parse_scaled(quantity, DECIMALS).map_err(|refused| {
62 let (key, english) = match refused {
63 Refusal::TooManyDecimals => {
64 ("validation.quantity.decimals", "has at most 3 decimals")
65 }
66 other => other.reason(),
67 };
68 refusal("quantity", key, english)
69 })?;
70 Self::try_new(thousandths, unit)
71 }
72
73 #[must_use]
74 pub fn thousandths(self) -> i64 {
75 self.quantity.0
76 }
77
78 #[must_use]
79 pub fn unit(self) -> U {
80 self.unit
81 }
82
83 #[must_use]
84 pub fn quantity(self) -> String {
85 self.quantity.to_string()
86 }
87
88 pub fn convert(self, unit: U) -> Result<Self> {
89 let steps = i128::from(self.quantity.0) * i128::from(self.unit.steps());
90 divide_rounded(steps, i128::from(unit.steps()))
91 .and_then(|thousandths| Self::try_new(thousandths, unit).ok())
92 .ok_or_else(|| {
93 Error::Rule(format!(
94 "converting {} {} to {} leaves the range of a quantity",
95 self.quantity,
96 self.unit.name(),
97 unit.name()
98 ))
99 })
100 }
101}
102
103fn unit_named<U: Unit>(name: &str) -> Result<U> {
104 U::ALL
105 .iter()
106 .copied()
107 .find(|unit| unit.name() == name)
108 .ok_or_else(|| {
109 let units = U::NAMES.join(", ");
110 refusal_of(
111 FieldError::new(
112 "unit",
113 "validation.unit.one_of",
114 format!("must be one of {units}"),
115 )
116 .with("units", units),
117 )
118 })
119}
120
121impl<U: Unit> Show for Measurement<U> {
122 fn show(&self) -> Shown<'_> {
123 Shown::Record(self)
124 }
125}
126
127impl<U: Unit> Fields for Measurement<U> {
128 fn name(&self, index: usize) -> Option<&str> {
129 ["quantity", "unit"].get(index).copied()
130 }
131
132 fn get(&self, name: &str) -> Option<Shown<'_>> {
133 match name {
134 "quantity" => Some(Shown::Formatted(&self.quantity)),
135 "unit" => Some(Shown::Text(self.unit.name())),
136 _ => None,
137 }
138 }
139}
140
141impl<U: Unit> Cast for Measurement<U> {
144 const COLUMNS: &'static [ColumnSpec] = &[
145 ColumnSpec::new("", SqlType::Float).unordered(),
146 ColumnSpec::new("_unit", SqlType::Text).unordered(),
147 ];
148
149 fn encode(&self, out: &mut Vec<SqlValue>) {
150 #[expect(
151 clippy::cast_precision_loss,
152 reason = "a quantity is at most 2^50 thousandths, which a float holds exactly"
153 )]
154 let quantity = self.quantity.0 as f64 / THOUSANDTHS_PER_UNIT;
155 out.push(SqlValue::Float(quantity));
156 out.push(SqlValue::Text(self.unit.name().to_owned()));
157 }
158
159 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
160 let thousandths = (f64::decode(row)? * THOUSANDTHS_PER_UNIT).round();
161 let unit = unit_named(row.text()?)
162 .map_err(|error| Error::internal("a stored unit is not known", error))?;
163 if !thousandths.is_finite() {
164 return Err(Error::internal_message("a stored quantity is not a number"));
165 }
166 #[expect(
167 clippy::cast_possible_truncation,
168 reason = "a cast saturates, and `try_new` refuses whatever lies beyond 2^50"
169 )]
170 Self::try_new(thousandths as i64, unit)
171 .map_err(|error| Error::internal("a stored quantity is out of range", error))
172 }
173}
174
175#[derive(Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct MeasurementFields {
178 quantity: String,
179 unit: String,
180}
181
182impl<U: Unit> Serialize for Measurement<U> {
183 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
184 let fields = MeasurementFields {
185 quantity: self.quantity(),
186 unit: self.unit.name().to_owned(),
187 };
188 fields.serialize(serializer)
189 }
190}
191
192impl<'de, U: Unit> Deserialize<'de> for Measurement<U> {
193 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
194 let fields = MeasurementFields::deserialize(deserializer)?;
195 unit_named(&fields.unit)
196 .and_then(|unit| Self::parse(&fields.quantity, unit))
197 .map_err(Error::into_deserialize_error)
198 }
199}
200
201macro_rules! units {
202 ($(#[$doc:meta])* $unit:ident { $($variant:ident = $name:literal, $steps:literal;)+ }) => {
203 $(#[$doc])*
204 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
205 pub enum $unit {
206 $($variant),+
207 }
208
209 impl Unit for $unit {
210 const ALL: &'static [Self] = &[$(Self::$variant),+];
211 const NAMES: &'static [&'static str] = &[$($name),+];
212
213 fn name(self) -> &'static str {
214 match self {
215 $(Self::$variant => $name),+
216 }
217 }
218
219 fn steps(self) -> i64 {
220 match self {
221 $(Self::$variant => $steps),+
222 }
223 }
224 }
225 };
226}
227
228units! {
229 WeightUnit {
231 Milligram = "MILLIGRAM", 1_000_000;
232 Gram = "GRAM", 1_000_000_000;
233 Kilogram = "KILOGRAM", 1_000_000_000_000;
234 Ounce = "OUNCE", 28_349_523_125;
235 Pound = "POUND", 453_592_370_000;
236 }
237}
238
239units! {
240 EnergyUnit {
241 Joule = "JOULE", 1_000;
242 Kilojoule = "KILOJOULE", 1_000_000;
243 Calorie = "CALORIE", 4_184;
244 Kilocalorie = "KILOCALORIE", 4_184_000;
245 KilowattHour = "KILOWATT_HOUR", 3_600_000_000;
246 }
247}
248
249units! {
250 LengthUnit {
251 Millimetre = "MILLIMETRE", 1_000;
252 Centimetre = "CENTIMETRE", 10_000;
253 Metre = "METRE", 1_000_000;
254 Kilometre = "KILOMETRE", 1_000_000_000;
255 Inch = "INCH", 25_400;
256 Foot = "FOOT", 304_800;
257 Mile = "MILE", 1_609_344_000;
258 }
259}
260
261pub type Weight = Measurement<WeightUnit>;
262pub type Energy = Measurement<EnergyUnit>;
263pub type Length = Measurement<LengthUnit>;
264
265impl<U: Unit> Present for Measurement<U> {
266 const INPUT: Input = Input::new(Control::Parts(&[
267 Part {
268 name: "quantity",
269 attributes: &[("type", "text"), ("inputmode", "decimal")],
270 options: &[],
271 },
272 Part {
273 name: "unit",
274 attributes: &[],
275 options: U::NAMES,
276 },
277 ]));
278 const SEARCH: crate::Search = crate::Search::Number;
279
280 fn csv(&self) -> String {
281 format!("{} {}", self.quantity, self.unit.name())
282 }
283
284 #[cfg(feature = "web")]
285 fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
286 conventions.write_number(out, &self.quantity());
287 out.push(' ');
288 out.push_str(self.unit.name());
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use sofie_core::RowReader;
295
296 use super::*;
297 use crate::testing::{round_trip, suffixes};
298
299 fn kilograms(quantity: &str) -> Weight {
300 Weight::parse(quantity, WeightUnit::Kilogram).unwrap()
301 }
302
303 #[test]
304 fn no_column_sorts_a_list_since_units_differ_row_to_row() {
305 assert!(Weight::COLUMNS.iter().all(|column| !column.orders));
306 }
307
308 #[test]
309 fn reads_and_writes_a_quantity_to_a_thousandth() {
310 assert_eq!(kilograms("1.5").thousandths(), 1_500);
311 assert_eq!(kilograms("1.5").quantity(), "1.500");
312 assert_eq!(kilograms("-0.001").thousandths(), -1);
313 for (text, message) in [
314 ("1.0005", "has at most 3 decimals"),
315 (
316 "1,5",
317 "must use one `.` before the decimals and no other separator",
318 ),
319 ("", "must be a number such as 149.90"),
320 ] {
321 let refused = Weight::parse(text, WeightUnit::Gram).unwrap_err();
322 assert_eq!(format!("{refused:#}"), message, "{text:?}");
323 }
324 }
325
326 #[test]
327 fn converts_exactly_then_rounds_once() {
328 let pound = Weight::parse("1", WeightUnit::Pound).unwrap();
329
330 assert_eq!(
331 pound.convert(WeightUnit::Kilogram).unwrap(),
332 kilograms("0.454")
333 );
334 assert_eq!(
335 pound.convert(WeightUnit::Ounce).unwrap(),
336 Weight::parse("16", WeightUnit::Ounce).unwrap()
337 );
338 let mile = Length::parse("1", LengthUnit::Mile).unwrap();
339 assert_eq!(
340 mile.convert(LengthUnit::Foot).unwrap().quantity(),
341 "5280.000"
342 );
343 assert_eq!(
344 mile.convert(LengthUnit::Inch).unwrap().quantity(),
345 "63360.000"
346 );
347 let snack = Energy::parse("250", EnergyUnit::Kilocalorie).unwrap();
348 assert_eq!(
349 snack.convert(EnergyUnit::Kilojoule).unwrap().quantity(),
350 "1046.000"
351 );
352 assert_eq!(
353 Energy::parse("1", EnergyUnit::KilowattHour)
354 .unwrap()
355 .convert(EnergyUnit::Joule)
356 .unwrap()
357 .quantity(),
358 "3600000.000"
359 );
360 }
361
362 #[test]
363 fn refuses_a_conversion_that_overflows() {
364 let huge = Length::try_new(1 << 50, LengthUnit::Kilometre).unwrap();
365
366 assert!(matches!(
367 huge.convert(LengthUnit::Millimetre),
368 Err(Error::Rule(_))
369 ));
370 assert_eq!(huge.convert(LengthUnit::Kilometre).unwrap(), huge);
371 assert!(Length::parse("-1125899906842.625", LengthUnit::Metre).is_err());
372 }
373
374 #[test]
375 fn round_trips_through_a_quantity_and_a_unit_column() {
376 let weight = kilograms("1.5");
377 assert_eq!(
378 round_trip(&weight),
379 vec![SqlValue::Float(1.5), SqlValue::Text("KILOGRAM".into())]
380 );
381 assert_eq!(suffixes::<Weight>(), ["", "_unit"]);
382 }
383
384 #[test]
385 fn every_accepted_quantity_round_trips_through_the_float_column() {
386 for thousandths in [1 << 50, (1 << 50) - 1, (1 << 50) - 7, -(1 << 50)] {
387 round_trip(&Length::try_new(thousandths, LengthUnit::Metre).unwrap());
388 }
389 assert!(Length::try_new(9_007_199_254_540_993, LengthUnit::Metre).is_err());
390 assert!(Length::try_new((1 << 50) + 1, LengthUnit::Metre).is_err());
391 }
392
393 #[test]
394 fn refuses_a_stored_unit_of_another_quantity() {
395 let stored = vec![SqlValue::Float(1.0), SqlValue::Text("METRE".into())];
396 let infinite = vec![
397 SqlValue::Float(f64::INFINITY),
398 SqlValue::Text("GRAM".into()),
399 ];
400
401 assert!(Weight::decode(&mut RowReader::new(&stored)).is_err());
402 assert!(Weight::decode(&mut RowReader::new(&infinite)).is_err());
403 }
404
405 #[test]
406 fn round_trips_through_json() {
407 let weight = kilograms("0.25");
408 let json = serde_json::to_value(weight).unwrap();
409
410 assert_eq!(
411 json,
412 serde_json::json!({"quantity": "0.250", "unit": "KILOGRAM"})
413 );
414 assert_eq!(serde_json::from_value::<Weight>(json).unwrap(), weight);
415 let refused =
416 serde_json::from_value::<Weight>(serde_json::json!({"quantity": "1", "unit": "METRE"}))
417 .unwrap_err();
418 assert_eq!(
419 refused.to_string(),
420 "must be one of MILLIGRAM, GRAM, KILOGRAM, OUNCE, POUND"
421 );
422 }
423}