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