cooklang_format/
number.rs1pub fn format_number(value: f64) -> String {
9 let rounded = (value * 1000000.0).round() / 1000000.0;
12
13 if (rounded.fract()).abs() < 0.0000001 {
15 return format!("{rounded:.0}");
16 }
17
18 if let Some(fraction) = decimal_to_fraction(rounded) {
20 return fraction;
21 }
22
23 let rounded_to_3 = (rounded * 1000.0).round() / 1000.0;
26
27 let mut result = if (rounded_to_3 * 100.0).fract().abs() < 0.001 {
29 format!("{rounded_to_3:.2}")
31 } else {
32 format!("{rounded_to_3:.3}")
34 };
35
36 if result.contains('.') {
38 result = result
39 .trim_end_matches('0')
40 .trim_end_matches('.')
41 .to_string();
42 }
43
44 result
45}
46
47fn decimal_to_fraction(value: f64) -> Option<String> {
49 const EPSILON: f64 = 0.0001;
50
51 if value < 0.0 {
56 return None;
57 }
58
59 let whole = value.floor();
61 let fract = value - whole;
62
63 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 for &(decimal, fraction_str) in &common_fractions {
78 if (fract - decimal).abs() < EPSILON {
79 if whole > 0.0 {
80 return None;
82 } else {
83 return Some(fraction_str.to_string());
84 }
85 }
86 }
87
88 None
89}
90
91pub 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 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 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 #[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 assert_eq!(format_number(-2.0), "-2");
143 assert_eq!(format_number(-1.9999999999), "-2");
144 }
145
146 #[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}