1#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum NumberStyle {
18 #[default]
19 Decimal,
21 Percent,
23 Currency,
25 Unit,
27}
28
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum CurrencySign {
32 #[default]
33 Standard,
35 Accounting,
37}
38
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum UnitDisplay {
42 Narrow,
44 #[default]
45 Short,
47 Long,
49}
50
51#[must_use = "builder methods return a new value; the original is unchanged"]
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct NumberFormat {
55 pub style: NumberStyle,
57 pub currency: Option<&'static str>,
59 pub currency_sign: CurrencySign,
61 pub unit: Option<&'static str>,
63 pub unit_display: UnitDisplay,
65 pub minimum_fraction_digits: Option<u8>,
67 pub maximum_fraction_digits: Option<u8>,
69 pub use_grouping: bool,
71}
72
73impl Default for NumberFormat {
74 fn default() -> Self {
75 Self {
76 style: NumberStyle::Decimal,
77 currency: None,
78 currency_sign: CurrencySign::Standard,
79 unit: None,
80 unit_display: UnitDisplay::Short,
81 minimum_fraction_digits: None,
82 maximum_fraction_digits: None,
83 use_grouping: true,
84 }
85 }
86}
87
88impl NumberFormat {
89 pub fn decimal() -> Self {
91 Self::default()
92 }
93
94 pub fn percent() -> Self {
96 Self {
97 style: NumberStyle::Percent,
98 ..Default::default()
99 }
100 }
101
102 pub fn currency(code: &'static str) -> Self {
104 Self {
105 style: NumberStyle::Currency,
106 currency: Some(code),
107 ..Default::default()
108 }
109 }
110
111 pub fn unit(name: &'static str) -> Self {
113 Self {
114 style: NumberStyle::Unit,
115 unit: Some(name),
116 ..Default::default()
117 }
118 }
119
120 pub fn currency_sign(mut self, sign: CurrencySign) -> Self {
122 self.currency_sign = sign;
123 self
124 }
125
126 pub fn unit_display(mut self, display: UnitDisplay) -> Self {
128 self.unit_display = display;
129 self
130 }
131
132 pub fn minimum_fraction_digits(mut self, n: u8) -> Self {
134 self.minimum_fraction_digits = Some(n);
135 self
136 }
137
138 pub fn maximum_fraction_digits(mut self, n: u8) -> Self {
140 self.maximum_fraction_digits = Some(n);
141 self
142 }
143
144 pub fn use_grouping(mut self, v: bool) -> Self {
146 self.use_grouping = v;
147 self
148 }
149
150 fn fraction_range(&self) -> (u8, u8) {
153 let (default_min, default_max) = match self.style {
154 NumberStyle::Currency => (2, 2),
155 _ => (0, 3),
156 };
157 let min = self.minimum_fraction_digits.unwrap_or(default_min);
158 let max = self.maximum_fraction_digits.unwrap_or(default_max.max(min));
160 (min, max.max(min))
161 }
162
163 #[allow(clippy::float_cmp)]
168 pub fn format(&self, value: f64) -> String {
169 let scaled = match self.style {
170 NumberStyle::Percent => value * 100.0,
171 _ => value,
172 };
173 let negative = scaled < 0.0 || (scaled == 0.0 && scaled.is_sign_negative());
174 let digits = self.digits(scaled.abs());
175
176 let body = match self.style {
177 NumberStyle::Percent => format!("{digits}%"),
178 NumberStyle::Currency => format!("{}{digits}", currency_symbol(self.currency)),
179 NumberStyle::Unit => match (self.unit, self.unit_display) {
180 (Some(u), UnitDisplay::Long) => {
181 #[allow(clippy::float_cmp)]
184 let singular = scaled.abs() == 1.0;
185 format!("{digits} {}", long_unit(u, singular))
186 }
187 (Some(u), UnitDisplay::Narrow) => format!("{digits}{}", short_unit(u)),
188 (Some(u), UnitDisplay::Short) => format!("{digits} {}", short_unit(u)),
189 (None, _) => digits,
190 },
191 NumberStyle::Decimal => digits,
192 };
193
194 match (negative, self.style, self.currency_sign) {
195 (true, NumberStyle::Currency, CurrencySign::Accounting) => format!("({body})"),
196 (true, _, _) => format!("-{body}"),
197 (false, _, _) => body,
198 }
199 }
200
201 fn digits(&self, magnitude: f64) -> String {
203 let (min, max) = self.fraction_range();
204 let mut text = format!("{:.*}", max as usize, magnitude);
205 if max > min {
206 if text.contains('.') {
208 let keep = text.len() - text.trim_end_matches('0').len();
209 let removable = (max - min) as usize;
210 text.truncate(text.len() - keep.min(removable));
211 if text.ends_with('.') {
212 text.pop();
213 }
214 }
215 }
216 let (int, frac) = match text.split_once('.') {
217 Some((i, f)) => (i.to_owned(), Some(f.to_owned())),
218 None => (text, None),
219 };
220 let int = if self.use_grouping { group(&int) } else { int };
221 match frac {
222 Some(f) => format!("{int}.{f}"),
223 None => int,
224 }
225 }
226}
227
228fn group(digits: &str) -> String {
230 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
231 for (i, c) in digits.chars().enumerate() {
232 if i > 0 && (digits.len() - i).is_multiple_of(3) {
233 out.push(',');
234 }
235 out.push(c);
236 }
237 out
238}
239
240fn currency_symbol(code: Option<&str>) -> String {
243 match code {
244 Some("USD") => "$".into(),
245 Some("EUR") => "€".into(),
246 Some("GBP") => "£".into(),
247 Some("JPY") => "¥".into(),
248 Some(other) => format!("{other} "),
249 None => String::new(),
250 }
251}
252
253fn short_unit(unit: &str) -> &str {
254 match unit {
255 "kilogram" => "kg",
256 "gram" => "g",
257 "pound" => "lb",
258 "meter" => "m",
259 "centimeter" => "cm",
260 "kilometer" => "km",
261 "mile" => "mi",
262 "liter" => "L",
263 "byte" => "byte",
264 "kilobyte" => "kB",
265 "megabyte" => "MB",
266 "gigabyte" => "GB",
267 "second" => "sec",
268 "minute" => "min",
269 "hour" => "hr",
270 "day" => "day",
271 "percent" => "%",
272 "celsius" => "°C",
273 "fahrenheit" => "°F",
274 other => other,
275 }
276}
277
278fn long_unit(unit: &str, singular: bool) -> String {
279 if singular {
280 unit.to_owned()
281 } else {
282 match unit {
284 "inch" => "inches".to_owned(),
285 other => format!("{other}s"),
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn decimal_groups_thousands() {
296 assert_eq!(NumberFormat::decimal().format(1234567.0), "1,234,567");
297 assert_eq!(NumberFormat::decimal().format(999.0), "999");
298 assert_eq!(
299 NumberFormat::decimal()
300 .use_grouping(false)
301 .format(1234567.0),
302 "1234567"
303 );
304 }
305
306 #[test]
307 fn percent_scales_by_a_hundred() {
308 assert_eq!(NumberFormat::percent().format(0.42), "42%");
309 assert_eq!(NumberFormat::percent().format(1.0), "100%");
311 }
312
313 #[test]
314 fn currency_defaults_to_two_fraction_digits() {
315 assert_eq!(NumberFormat::currency("USD").format(1200.0), "$1,200.00");
316 assert_eq!(NumberFormat::currency("EUR").format(0.5), "€0.50");
317 assert_eq!(NumberFormat::currency("XYZ").format(3.0), "XYZ 3.00");
319 }
320
321 #[test]
322 fn accounting_parenthesises_a_negative() {
323 let f = NumberFormat::currency("EUR").currency_sign(CurrencySign::Accounting);
324 assert_eq!(f.format(-12.0), "(€12.00)");
325 assert_eq!(f.format(12.0), "€12.00");
326 assert_eq!(NumberFormat::currency("EUR").format(-12.0), "-€12.00");
328 }
329
330 #[test]
331 fn fraction_digits_pad_and_trim() {
332 let f = NumberFormat::decimal()
333 .minimum_fraction_digits(2)
334 .maximum_fraction_digits(2);
335 assert_eq!(f.format(3.0), "3.00");
336 assert_eq!(f.format(3.456), "3.46");
337 assert_eq!(NumberFormat::decimal().format(2.50), "2.5");
339 assert_eq!(NumberFormat::decimal().format(2.0), "2");
340 }
341
342 #[test]
343 fn units_render_short_narrow_and_long() {
344 let kg = NumberFormat::unit("kilogram");
345 assert_eq!(kg.format(5.0), "5 kg");
346 assert_eq!(
347 kg.clone().unit_display(UnitDisplay::Narrow).format(5.0),
348 "5kg"
349 );
350 assert_eq!(
351 kg.clone().unit_display(UnitDisplay::Long).format(5.0),
352 "5 kilograms"
353 );
354 assert_eq!(kg.unit_display(UnitDisplay::Long).format(1.0), "1 kilogram");
355 }
356
357 #[test]
358 fn an_explicit_minimum_raises_the_maximum() {
359 let f = NumberFormat::decimal().minimum_fraction_digits(4);
361 assert_eq!(f.format(1.5), "1.5000");
362 }
363}