Skip to main content

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}