Skip to main content

cooklang_format/
number.rs

1//! Human-friendly rendering of quantity numbers.
2//!
3//! Used by the web UI and menu handlers, where `0.5` should read as `1/2`
4//! rather than as a float.
5
6/// Formats a floating-point number as a human-readable string with fractions
7/// Based on the approach from cooklang-rs/bindings/src/lib.rs
8pub fn format_number(value: f64) -> String {
9    // Round to reasonable precision to handle floating point errors
10    // This handles cases like 0.89999999999 -> 0.9
11    let rounded = (value * 1000000.0).round() / 1000000.0;
12
13    // Check if it's effectively a whole number
14    if (rounded.fract()).abs() < 0.0000001 {
15        return format!("{rounded:.0}");
16    }
17
18    // Try to convert to a common fraction
19    if let Some(fraction) = decimal_to_fraction(rounded) {
20        return fraction;
21    }
22
23    // For decimals, determine appropriate precision
24    // Round to at most 3 decimal places, but remove trailing zeros
25    let rounded_to_3 = (rounded * 1000.0).round() / 1000.0;
26
27    // Format with appropriate precision
28    let mut result = if (rounded_to_3 * 100.0).fract().abs() < 0.001 {
29        // Has at most 2 decimal places
30        format!("{rounded_to_3:.2}")
31    } else {
32        // Needs 3 decimal places
33        format!("{rounded_to_3:.3}")
34    };
35
36    // Remove trailing zeros and decimal point if not needed
37    if result.contains('.') {
38        result = result
39            .trim_end_matches('0')
40            .trim_end_matches('.')
41            .to_string();
42    }
43
44    result
45}
46
47/// Converts common decimal values to fraction strings
48fn decimal_to_fraction(value: f64) -> Option<String> {
49    const EPSILON: f64 = 0.0001;
50
51    // Negatives take the decimal path. `floor` rounds away from zero, so
52    // `-0.25` would otherwise give `fract == 0.75` and render as "3/4" —
53    // sign and magnitude both lost, and the `whole > 0.0` guard below never
54    // fires to catch it.
55    if value < 0.0 {
56        return None;
57    }
58
59    // Split into whole and fractional parts
60    let whole = value.floor();
61    let fract = value - whole;
62
63    // Common fractions and their decimal equivalents
64    let common_fractions = [
65        (0.125, "1/8"),
66        (0.25, "1/4"),
67        (0.333333, "1/3"),
68        (0.375, "3/8"),
69        (0.5, "1/2"),
70        (0.625, "5/8"),
71        (0.666667, "2/3"),
72        (0.75, "3/4"),
73        (0.875, "7/8"),
74    ];
75
76    // Check if the fractional part matches any common fraction
77    for &(decimal, fraction_str) in &common_fractions {
78        if (fract - decimal).abs() < EPSILON {
79            if whole > 0.0 {
80                // For values > 1, return decimal format instead of mixed fraction
81                return None;
82            } else {
83                return Some(fraction_str.to_string());
84            }
85        }
86    }
87
88    None
89}
90
91/// Formats a quantity value for display
92pub fn format_quantity(value: &cooklang::Value) -> Option<String> {
93    match value {
94        cooklang::Value::Number(n) => Some(format_number(n.value())),
95        cooklang::Value::Range { start, end } => Some(format!(
96            "{} - {}",
97            format_number(start.value()),
98            format_number(end.value())
99        )),
100        cooklang::Value::Text(s) => Some(s.clone()),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_format_fractions() {
110        // Fractions less than 1 should be displayed as fractions
111        assert_eq!(format_number(0.5), "1/2");
112        assert_eq!(format_number(0.25), "1/4");
113        assert_eq!(format_number(0.75), "3/4");
114        assert_eq!(format_number(0.333333), "1/3");
115        assert_eq!(format_number(0.666667), "2/3");
116
117        // Values greater than 1 should be displayed as decimals
118        assert_eq!(format_number(1.5), "1.5");
119        assert_eq!(format_number(2.25), "2.25");
120        assert_eq!(format_number(1.75), "1.75");
121        assert_eq!(format_number(2.333333), "2.333");
122    }
123
124    #[test]
125    fn test_format_whole_numbers() {
126        assert_eq!(format_number(2.0), "2");
127        assert_eq!(format_number(1.9999999999), "2");
128    }
129
130    /// Negatives used to be mangled: `floor` rounds away from zero, so the
131    /// fractional part of `-0.25` came out as `0.75` and rendered as "3/4",
132    /// dropping the sign and changing the magnitude. They now take the
133    /// decimal path.
134    #[test]
135    fn negatives_keep_their_sign_and_magnitude() {
136        assert_eq!(format_number(-0.5), "-0.5");
137        assert_eq!(format_number(-0.25), "-0.25");
138        assert_eq!(format_number(-0.75), "-0.75");
139        assert_eq!(format_number(-1.5), "-1.5");
140        assert_eq!(format_number(-2.333333), "-2.333");
141        // Whole negatives never reached the fraction path, but pin them too.
142        assert_eq!(format_number(-2.0), "-2");
143        assert_eq!(format_number(-1.9999999999), "-2");
144    }
145
146    /// `format_quantity` is the entry point the web UI actually calls.
147    #[test]
148    fn quantities_render_numbers_ranges_and_text() {
149        use cooklang::Value;
150        let num = |v: f64| Value::Number(v.into());
151        assert_eq!(format_quantity(&num(0.5)).as_deref(), Some("1/2"));
152        assert_eq!(format_quantity(&num(-0.5)).as_deref(), Some("-0.5"));
153        assert_eq!(
154            format_quantity(&Value::Range {
155                start: 0.5.into(),
156                end: 2.25.into()
157            })
158            .as_deref(),
159            Some("1/2 - 2.25")
160        );
161        assert_eq!(
162            format_quantity(&Value::Text("a pinch".into())).as_deref(),
163            Some("a pinch")
164        );
165    }
166
167    #[test]
168    fn test_format_decimals() {
169        assert_eq!(format_number(1.23), "1.23");
170        assert_eq!(format_number(0.899), "0.899");
171        assert_eq!(format_number(0.89999999999), "0.9");
172        assert_eq!(format_number(0.30000000001), "0.3");
173    }
174}