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> {
128 const COLUMNS: &'static [ColumnSpec] = &[
129 ColumnSpec::new("", SqlType::Float).unordered(),
130 ColumnSpec::new("_unit", SqlType::Text).unordered(),
131 ];
132
133 fn encode(&self, out: &mut Vec<SqlValue>) {
134 #[expect(
135 clippy::cast_precision_loss,
136 reason = "a quantity is at most 2^50 thousandths, which a float holds exactly"
137 )]
138 let quantity = self.quantity.0 as f64 / THOUSANDTHS_PER_UNIT;
139 out.push(SqlValue::Float(quantity));
140 out.push(SqlValue::Text(self.unit.name().to_owned()));
141 }
142
143 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
144 let thousandths = (f64::decode(row)? * THOUSANDTHS_PER_UNIT).round();
145 let unit = unit_named(row.text()?)
146 .map_err(|error| Error::internal("a stored unit is not known", error))?;
147 if !thousandths.is_finite() {
148 return Err(Error::internal_message("a stored quantity is not a number"));
149 }
150 #[expect(
151 clippy::cast_possible_truncation,
152 reason = "a cast saturates, and `try_new` refuses whatever lies beyond 2^50"
153 )]
154 Self::try_new(thousandths as i64, unit)
155 .map_err(|error| Error::internal("a stored quantity is out of range", error))
156 }
157}
158
159#[derive(Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct MeasurementFields {
162 quantity: String,
163 unit: String,
164}
165
166impl<U: Unit> Serialize for Measurement<U> {
167 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168 let fields = MeasurementFields {
169 quantity: self.quantity(),
170 unit: self.unit.name().to_owned(),
171 };
172 fields.serialize(serializer)
173 }
174}
175
176impl<'de, U: Unit> Deserialize<'de> for Measurement<U> {
177 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
178 let fields = MeasurementFields::deserialize(deserializer)?;
179 unit_named(&fields.unit)
180 .and_then(|unit| Self::parse(&fields.quantity, unit))
181 .map_err(Error::into_deserialize_error)
182 }
183}
184
185macro_rules! units {
186 ($(#[$doc:meta])* $unit:ident { $($variant:ident = $name:literal, $steps:literal;)+ }) => {
187 $(#[$doc])*
188 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
189 pub enum $unit {
190 $($variant),+
191 }
192
193 impl Unit for $unit {
194 const ALL: &'static [Self] = &[$(Self::$variant),+];
195 const NAMES: &'static [&'static str] = &[$($name),+];
196
197 fn name(self) -> &'static str {
198 match self {
199 $(Self::$variant => $name),+
200 }
201 }
202
203 fn steps(self) -> i64 {
204 match self {
205 $(Self::$variant => $steps),+
206 }
207 }
208 }
209 };
210}
211
212units! {
213 WeightUnit {
215 Milligram = "MILLIGRAM", 1_000_000;
216 Gram = "GRAM", 1_000_000_000;
217 Kilogram = "KILOGRAM", 1_000_000_000_000;
218 Ounce = "OUNCE", 28_349_523_125;
219 Pound = "POUND", 453_592_370_000;
220 }
221}
222
223units! {
224 EnergyUnit {
225 Joule = "JOULE", 1_000;
226 Kilojoule = "KILOJOULE", 1_000_000;
227 Calorie = "CALORIE", 4_184;
228 Kilocalorie = "KILOCALORIE", 4_184_000;
229 KilowattHour = "KILOWATT_HOUR", 3_600_000_000;
230 }
231}
232
233units! {
234 LengthUnit {
235 Millimetre = "MILLIMETRE", 1_000;
236 Centimetre = "CENTIMETRE", 10_000;
237 Metre = "METRE", 1_000_000;
238 Kilometre = "KILOMETRE", 1_000_000_000;
239 Inch = "INCH", 25_400;
240 Foot = "FOOT", 304_800;
241 Mile = "MILE", 1_609_344_000;
242 }
243}
244
245pub type Weight = Measurement<WeightUnit>;
246pub type Energy = Measurement<EnergyUnit>;
247pub type Length = Measurement<LengthUnit>;
248
249impl<U: Unit> Present for Measurement<U> {
250 const INPUT: Input = Input::new(Control::Parts(&[
251 Part {
252 name: "quantity",
253 attributes: &[("type", "text"), ("inputmode", "decimal")],
254 options: &[],
255 },
256 Part {
257 name: "unit",
258 attributes: &[],
259 options: U::NAMES,
260 },
261 ]));
262 const SEARCH: crate::Search = crate::Search::Number;
263
264 fn csv(&self) -> String {
265 format!("{} {}", self.quantity, self.unit.name())
266 }
267
268 #[cfg(feature = "web")]
269 fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
270 conventions.write_number(out, &self.quantity());
271 out.push(' ');
272 out.push_str(self.unit.name());
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use sofie_core::RowReader;
279
280 use super::*;
281 use crate::testing::{round_trip, suffixes};
282
283 fn kilograms(quantity: &str) -> Weight {
284 Weight::parse(quantity, WeightUnit::Kilogram).unwrap()
285 }
286
287 #[test]
288 fn no_column_sorts_a_list_since_units_differ_row_to_row() {
289 assert!(Weight::COLUMNS.iter().all(|column| !column.orders));
290 }
291
292 #[test]
293 fn reads_and_writes_a_quantity_to_a_thousandth() {
294 assert_eq!(kilograms("1.5").thousandths(), 1_500);
295 assert_eq!(kilograms("1.5").quantity(), "1.500");
296 assert_eq!(kilograms("-0.001").thousandths(), -1);
297 for (text, message) in [
298 ("1.0005", "has at most 3 decimals"),
299 (
300 "1,5",
301 "must use one `.` before the decimals and no other separator",
302 ),
303 ("", "must be a number such as 149.90"),
304 ] {
305 let refused = Weight::parse(text, WeightUnit::Gram).unwrap_err();
306 assert_eq!(format!("{refused:#}"), message, "{text:?}");
307 }
308 }
309
310 #[test]
311 fn converts_exactly_then_rounds_once() {
312 let pound = Weight::parse("1", WeightUnit::Pound).unwrap();
313
314 assert_eq!(
315 pound.convert(WeightUnit::Kilogram).unwrap(),
316 kilograms("0.454")
317 );
318 assert_eq!(
319 pound.convert(WeightUnit::Ounce).unwrap(),
320 Weight::parse("16", WeightUnit::Ounce).unwrap()
321 );
322 let mile = Length::parse("1", LengthUnit::Mile).unwrap();
323 assert_eq!(
324 mile.convert(LengthUnit::Foot).unwrap().quantity(),
325 "5280.000"
326 );
327 assert_eq!(
328 mile.convert(LengthUnit::Inch).unwrap().quantity(),
329 "63360.000"
330 );
331 let snack = Energy::parse("250", EnergyUnit::Kilocalorie).unwrap();
332 assert_eq!(
333 snack.convert(EnergyUnit::Kilojoule).unwrap().quantity(),
334 "1046.000"
335 );
336 assert_eq!(
337 Energy::parse("1", EnergyUnit::KilowattHour)
338 .unwrap()
339 .convert(EnergyUnit::Joule)
340 .unwrap()
341 .quantity(),
342 "3600000.000"
343 );
344 }
345
346 #[test]
347 fn refuses_a_conversion_that_overflows() {
348 let huge = Length::try_new(1 << 50, LengthUnit::Kilometre).unwrap();
349
350 assert!(matches!(
351 huge.convert(LengthUnit::Millimetre),
352 Err(Error::Rule(_))
353 ));
354 assert_eq!(huge.convert(LengthUnit::Kilometre).unwrap(), huge);
355 assert!(Length::parse("-1125899906842.625", LengthUnit::Metre).is_err());
356 }
357
358 #[test]
359 fn round_trips_through_a_quantity_and_a_unit_column() {
360 let weight = kilograms("1.5");
361 assert_eq!(
362 round_trip(&weight),
363 vec![SqlValue::Float(1.5), SqlValue::Text("KILOGRAM".into())]
364 );
365 assert_eq!(suffixes::<Weight>(), ["", "_unit"]);
366 }
367
368 #[test]
369 fn every_accepted_quantity_round_trips_through_the_float_column() {
370 for thousandths in [1 << 50, (1 << 50) - 1, (1 << 50) - 7, -(1 << 50)] {
371 round_trip(&Length::try_new(thousandths, LengthUnit::Metre).unwrap());
372 }
373 assert!(Length::try_new(9_007_199_254_540_993, LengthUnit::Metre).is_err());
374 assert!(Length::try_new((1 << 50) + 1, LengthUnit::Metre).is_err());
375 }
376
377 #[test]
378 fn refuses_a_stored_unit_of_another_quantity() {
379 let stored = vec![SqlValue::Float(1.0), SqlValue::Text("METRE".into())];
380 let infinite = vec![
381 SqlValue::Float(f64::INFINITY),
382 SqlValue::Text("GRAM".into()),
383 ];
384
385 assert!(Weight::decode(&mut RowReader::new(&stored)).is_err());
386 assert!(Weight::decode(&mut RowReader::new(&infinite)).is_err());
387 }
388
389 #[test]
390 fn round_trips_through_json() {
391 let weight = kilograms("0.25");
392 let json = serde_json::to_value(weight).unwrap();
393
394 assert_eq!(
395 json,
396 serde_json::json!({"quantity": "0.250", "unit": "KILOGRAM"})
397 );
398 assert_eq!(serde_json::from_value::<Weight>(json).unwrap(), weight);
399 let refused =
400 serde_json::from_value::<Weight>(serde_json::json!({"quantity": "1", "unit": "METRE"}))
401 .unwrap_err();
402 assert_eq!(
403 refused.to_string(),
404 "must be one of MILLIGRAM, GRAM, KILOGRAM, OUNCE, POUND"
405 );
406 }
407}