cooklang_format/quantity.rs
1//! Deterministic ordering for [`GroupedQuantity`].
2//!
3//! Everything that renders or serialises a grouped quantity goes through
4//! [`ordered_components`] or [`grouped_quantity_fmt`], so the human table,
5//! Markdown, JSON, YAML, LaTeX, Typst, schema.org and library outputs all
6//! agree on one order.
7//!
8//! That ordering is a published contract, not a local convention: `cookcli-core`
9//! renders its shopping list through these functions, and the snapshot tests
10//! pinning the order live in CookCLI, two crates downstream. Changing it
11//! changes their output.
12
13use cooklang::quantity::{GroupedQuantity, Quantity};
14
15/// The components of `grouped`, ordered by unit name, the unitless component
16/// first.
17///
18/// # The rule
19///
20/// **Components are ordered by unit name, with the unitless component first —
21/// it counts as an empty unit name — and components sharing a unit keep the
22/// order they were added.**
23///
24/// # Why this exists
25///
26/// A [`GroupedQuantity`] holds the quantities that could not be added into one
27/// another: `1 cup` of flour plus `100 g` of flour stays two components,
28/// because no conversion between them exists. It keeps the ones whose unit the
29/// converter does not know in a [`HashMap`](std::collections::HashMap) keyed by
30/// unit name, and Rust randomises `HashMap` iteration order per process, so
31/// [`GroupedQuantity::iter`] — and the `Display` impl built on it — yield those
32/// components in a different order on every run:
33///
34/// ```text
35/// flour 1 cup, 100 g
36/// flour 100 g, 1 cup
37/// ```
38///
39/// This became visible when `cooklang`'s `bundled_units` feature was switched
40/// off so quantities keep the units they were authored in: without the unit
41/// database no unit is "known", so every component lands in that map and any
42/// ingredient measured two ways prints in random order.
43///
44/// The order the units were *written* in cannot be recovered here — the map has
45/// already lost it — so ordering by unit name is chosen instead: it is
46/// implementable from the data at hand, identical on every run and platform
47/// (byte-wise comparison of the unit text, no locale involved), and short
48/// enough to explain in one sentence.
49pub fn ordered_components(grouped: &GroupedQuantity) -> Vec<&Quantity> {
50 let mut components: Vec<&Quantity> = grouped.iter().collect();
51 // `sort_by` is stable, so components sharing a unit — which `cooklang`
52 // keeps apart only when it could not add them, such as a text value — stay
53 // in the order they were added.
54 components.sort_by(|a, b| unit_key(a).cmp(unit_key(b)));
55 components
56}
57
58/// Render `grouped` the way its own `Display` impl does — the components joined
59/// with `", "` — but in [`ordered_components`] order.
60pub fn grouped_quantity_fmt(grouped: &GroupedQuantity) -> String {
61 ordered_components(grouped)
62 .into_iter()
63 .map(|q| q.to_string())
64 .collect::<Vec<_>>()
65 .join(", ")
66}
67
68/// A component's sort key: its unit, or `""` when it has none, which is what
69/// puts the unitless component first.
70fn unit_key(qty: &Quantity) -> &str {
71 qty.unit().unwrap_or_default()
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use crate::test_support::PARSER;
78
79 /// Build a group by adding each quantity in turn, the way the shopping
80 /// list and the recipe formatters do.
81 fn group(quantities: &[Quantity]) -> GroupedQuantity {
82 let mut grouped = GroupedQuantity::empty();
83 for qty in quantities {
84 grouped.add(qty, PARSER.converter());
85 }
86 grouped
87 }
88
89 fn qty(value: f64, unit: Option<&str>) -> Quantity {
90 Quantity::new(
91 cooklang::quantity::Value::Number(value.into()),
92 unit.map(|u| u.to_string()),
93 )
94 }
95
96 /// The bug this module exists for: two units that cannot be added must
97 /// render in the same order every time, whichever order they arrived in.
98 ///
99 /// A single run could pass by luck, so both input orders are checked and
100 /// the exact rendered string is asserted rather than "one of two orders".
101 #[test]
102 fn inconvertible_units_render_in_unit_name_order() {
103 let cup_first = group(&[qty(1.0, Some("cup")), qty(100.0, Some("g"))]);
104 let gram_first = group(&[qty(100.0, Some("g")), qty(1.0, Some("cup"))]);
105
106 assert_eq!(grouped_quantity_fmt(&cup_first), "1 cup, 100 g");
107 assert_eq!(grouped_quantity_fmt(&gram_first), "1 cup, 100 g");
108 }
109
110 /// Repeat the group often enough that a `HashMap` iterating in insertion
111 /// order by chance cannot hide an unsorted implementation.
112 ///
113 /// The units here are ones no converter knows, so every component lands in
114 /// the randomised map whether or not `cooklang`'s unit database is
115 /// compiled in.
116 #[test]
117 fn the_order_does_not_vary_between_groups() {
118 let rendered: Vec<String> = (0..64)
119 .map(|_| {
120 grouped_quantity_fmt(&group(&[
121 qty(1.0, Some("sprig")),
122 qty(2.0, Some("clove")),
123 qty(3.0, Some("knob")),
124 qty(4.0, Some("glug")),
125 ]))
126 })
127 .collect();
128
129 assert!(
130 rendered
131 .iter()
132 .all(|r| r == "2 clove, 4 glug, 3 knob, 1 sprig"),
133 "every group must render identically: {rendered:?}"
134 );
135 }
136
137 #[test]
138 fn the_unitless_component_comes_first() {
139 let grouped = group(&[qty(2.0, Some("g")), qty(3.0, None), qty(1.0, Some("cup"))]);
140 assert_eq!(grouped_quantity_fmt(&grouped), "3, 1 cup, 2 g");
141 }
142
143 #[test]
144 fn an_empty_group_renders_as_nothing() {
145 assert_eq!(grouped_quantity_fmt(&GroupedQuantity::empty()), "");
146 assert!(ordered_components(&GroupedQuantity::empty()).is_empty());
147 }
148}