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