1#[derive(Debug, Clone)]
6pub struct NumberFormatter {
7 spec: FormatSpec,
8}
9
10#[derive(Debug, Clone)]
11struct FormatSpec {
12 fill: char,
13 align: Align,
14 sign: Sign,
15 symbol: Symbol,
16 _zero: bool,
17 width: Option<usize>,
18 comma: bool,
19 precision: Option<usize>,
20 trim: bool,
21 format_type: FormatType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum Align {
26 Left,
27 Right,
28 Center,
29 SignFirst,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
33enum Sign {
34 Minus,
35 Plus,
36 Space,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
40enum Symbol {
41 None,
42 Dollar,
43 Hash,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47enum FormatType {
48 None,
49 Decimal,
50 Exponent,
51 Fixed,
52 General,
53 Rounded,
54 SiPrefix,
55 Percentage,
56 Hex,
57 HexUpper,
58 Octal,
59 Binary,
60}
61
62fn is_align_char(c: char) -> bool {
63 matches!(c, '<' | '>' | '^' | '=')
64}
65
66fn parse_spec(spec_str: &str) -> FormatSpec {
67 let chars: Vec<char> = spec_str.chars().collect();
68 let len = chars.len();
69 let mut i = 0;
70
71 let mut fill = ' ';
73 let mut align = Align::Right;
74 let mut sign = Sign::Minus;
75 let mut symbol = Symbol::None;
76 let mut zero = false;
77 let mut width: Option<usize> = None;
78 let mut comma = false;
79 let mut precision: Option<usize> = None;
80 let mut trim = false;
81 let mut format_type = FormatType::None;
82 let mut explicit_align = false;
83
84 if len >= 2 && is_align_char(chars[1]) {
86 fill = chars[0];
87 align = match chars[1] {
88 '<' => Align::Left,
89 '>' => Align::Right,
90 '^' => Align::Center,
91 '=' => Align::SignFirst,
92 _ => unreachable!(),
93 };
94 explicit_align = true;
95 i = 2;
96 } else if len >= 1 && is_align_char(chars[0]) {
97 align = match chars[0] {
98 '<' => Align::Left,
99 '>' => Align::Right,
100 '^' => Align::Center,
101 '=' => Align::SignFirst,
102 _ => unreachable!(),
103 };
104 explicit_align = true;
105 i = 1;
106 }
107
108 if i < len {
110 match chars[i] {
111 '-' => { sign = Sign::Minus; i += 1; }
112 '+' => { sign = Sign::Plus; i += 1; }
113 ' ' => { sign = Sign::Space; i += 1; }
114 _ => {}
115 }
116 }
117
118 if i < len {
120 match chars[i] {
121 '$' => { symbol = Symbol::Dollar; i += 1; }
122 '#' => { symbol = Symbol::Hash; i += 1; }
123 _ => {}
124 }
125 }
126
127 if i < len && chars[i] == '0' {
129 zero = true;
133 if !explicit_align {
134 fill = '0';
135 align = Align::SignFirst;
136 }
137 i += 1;
138 }
139
140 {
142 let start = i;
143 while i < len && chars[i].is_ascii_digit() {
144 i += 1;
145 }
146 if i > start {
147 width = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
148 }
149 }
150
151 if i < len && chars[i] == ',' {
153 comma = true;
154 i += 1;
155 }
156
157 if i < len && chars[i] == '.' {
159 i += 1;
160 let start = i;
161 while i < len && chars[i].is_ascii_digit() {
162 i += 1;
163 }
164 if i > start {
165 precision = Some(chars[start..i].iter().collect::<String>().parse().unwrap());
166 } else {
167 precision = Some(0);
168 }
169 }
170
171 if i < len && chars[i] == '~' {
173 trim = true;
174 i += 1;
175 }
176
177 if i < len {
179 format_type = match chars[i] {
180 'd' => FormatType::Decimal,
181 'e' => FormatType::Exponent,
182 'f' => FormatType::Fixed,
183 'g' => FormatType::General,
184 'r' => FormatType::Rounded,
185 's' => FormatType::SiPrefix,
186 '%' => FormatType::Percentage,
187 'x' => FormatType::Hex,
188 'X' => FormatType::HexUpper,
189 'o' => FormatType::Octal,
190 'b' => FormatType::Binary,
191 _ => FormatType::None,
192 };
193 }
194
195 FormatSpec {
196 fill,
197 align,
198 sign,
199 symbol,
200 _zero: zero,
201 width,
202 comma,
203 precision,
204 trim,
205 format_type,
206 }
207}
208
209const SI_PREFIXES: &[(f64, f64, &str)] = &[
211 (1e24, 1e24, "Y"),
212 (1e21, 1e21, "Z"),
213 (1e18, 1e18, "E"),
214 (1e15, 1e15, "P"),
215 (1e12, 1e12, "T"),
216 (1e9, 1e9, "G"),
217 (1e6, 1e6, "M"),
218 (1e3, 1e3, "k"),
219 (1.0, 1.0, ""),
220 (1e-3, 1e-3, "m"),
221 (1e-6, 1e-6, "\u{03bc}"),
222 (1e-9, 1e-9, "n"),
223];
224
225fn si_prefix(value: f64) -> (f64, &'static str) {
226 let abs = value.abs();
227 if abs == 0.0 {
228 return (0.0, "");
229 }
230 for &(threshold, divisor, suffix) in SI_PREFIXES {
231 if abs >= threshold {
232 return (value / divisor, suffix);
233 }
234 }
235 (value / 1e-9, "n")
237}
238
239fn insert_commas(int_part: &str) -> String {
240 let len = int_part.len();
241 if len <= 3 {
242 return int_part.to_string();
243 }
244 let mut result = String::with_capacity(len + len / 3);
245 for (idx, ch) in int_part.chars().enumerate() {
246 if idx > 0 && (len - idx).is_multiple_of(3) {
247 result.push(',');
248 }
249 result.push(ch);
250 }
251 result
252}
253
254fn trim_trailing_zeros(s: &str) -> String {
255 if s.contains('.') {
256 let trimmed = s.trim_end_matches('0');
257 if trimmed.ends_with('.') {
258 trimmed.strip_suffix('.').unwrap_or(trimmed).to_string()
259 } else {
260 trimmed.to_string()
261 }
262 } else {
263 s.to_string()
264 }
265}
266
267fn format_with_commas(formatted: &str) -> String {
268 if let Some(dot_pos) = formatted.find('.') {
271 let int_part = &formatted[..dot_pos];
272 let rest = &formatted[dot_pos..];
273 format!("{}{}", insert_commas(int_part), rest)
274 } else {
275 insert_commas(formatted)
276 }
277}
278
279impl NumberFormatter {
280 pub fn new(spec_str: &str) -> Self {
281 Self {
282 spec: parse_spec(spec_str),
283 }
284 }
285
286 pub fn format(&self, value: f64) -> String {
287 let spec = &self.spec;
288 let is_negative = value < 0.0;
289 let abs_value = value.abs();
290
291 let (mut formatted, suffix) = self.format_core(abs_value);
293
294 if spec.trim {
296 formatted = trim_trailing_zeros(&formatted);
297 }
298
299 if spec.comma {
301 formatted = format_with_commas(&formatted);
302 }
303
304 formatted.push_str(&suffix);
306
307 let sign_str = if is_negative {
309 "-"
310 } else {
311 match spec.sign {
312 Sign::Plus => "+",
313 Sign::Space => " ",
314 Sign::Minus => "",
315 }
316 };
317
318 let symbol_str = match spec.symbol {
320 Symbol::Dollar => "$",
321 Symbol::Hash => "",
323 Symbol::None => "",
324 };
325
326 let body = format!("{}{}{}", sign_str, symbol_str, formatted);
330
331 self.apply_padding(&body, sign_str, symbol_str, &formatted)
333 }
334
335 fn format_core(&self, abs_value: f64) -> (String, String) {
336 let spec = &self.spec;
337 let precision = spec.precision;
338
339 match spec.format_type {
340 FormatType::Fixed => {
341 let p = precision.unwrap_or(6);
342 (format!("{:.prec$}", abs_value, prec = p), String::new())
343 }
344 FormatType::Decimal => {
345 let rounded = abs_value.round() as i64;
346 (format!("{}", rounded), String::new())
347 }
348 FormatType::Exponent => {
349 let p = precision.unwrap_or(6);
350 (format!("{:.prec$e}", abs_value, prec = p), String::new())
351 }
352 FormatType::General => {
353 let p = precision.unwrap_or(6);
354 let formatted = format_general(abs_value, p);
356 (formatted, String::new())
357 }
358 FormatType::Percentage => {
359 let pct_value = abs_value * 100.0;
360 let p = precision.unwrap_or(6);
361 (format!("{:.prec$}", pct_value, prec = p), "%".to_string())
362 }
363 FormatType::SiPrefix => {
364 let (scaled, suffix) = si_prefix(abs_value);
365 let p = precision.unwrap_or(6);
366 if spec.precision.is_some() {
368 let formatted = format_significant(scaled, p);
369 (formatted, suffix.to_string())
370 } else {
371 let formatted = format_default_si(scaled);
373 (formatted, suffix.to_string())
374 }
375 }
376 FormatType::Hex => {
377 let int_val = abs_value.round() as u64;
378 let prefix = if spec.symbol == Symbol::Hash { "0x" } else { "" };
379 (format!("{}{:x}", prefix, int_val), String::new())
380 }
381 FormatType::HexUpper => {
382 let int_val = abs_value.round() as u64;
383 let prefix = if spec.symbol == Symbol::Hash { "0X" } else { "" };
384 (format!("{}{:X}", prefix, int_val), String::new())
385 }
386 FormatType::Octal => {
387 let int_val = abs_value.round() as u64;
388 let prefix = if spec.symbol == Symbol::Hash { "0o" } else { "" };
389 (format!("{}{:o}", prefix, int_val), String::new())
390 }
391 FormatType::Binary => {
392 let int_val = abs_value.round() as u64;
393 let prefix = if spec.symbol == Symbol::Hash { "0b" } else { "" };
394 (format!("{}{:b}", prefix, int_val), String::new())
395 }
396 FormatType::Rounded => {
397 let p = precision.unwrap_or(6);
398 (format_significant(abs_value, p), String::new())
399 }
400 FormatType::None => {
401 let p = precision.unwrap_or(12);
403 let formatted = format_general(abs_value, p);
404 (trim_trailing_zeros(&formatted), String::new())
405 }
406 }
407 }
408
409 fn apply_padding(
410 &self,
411 body: &str,
412 sign_str: &str,
413 symbol_str: &str,
414 number_part: &str,
415 ) -> String {
416 let spec = &self.spec;
417 let w = match spec.width {
418 Some(w) => w,
419 None => return body.to_string(),
420 };
421
422 let body_len = body.chars().count();
423 if body_len >= w {
424 return body.to_string();
425 }
426
427 let pad_len = w - body_len;
428 let padding: String = std::iter::repeat_n(spec.fill, pad_len).collect();
429
430 match spec.align {
431 Align::Left => format!("{}{}", body, padding),
432 Align::Right => format!("{}{}", padding, body),
433 Align::Center => {
434 let left = pad_len / 2;
435 let right = pad_len - left;
436 let lpad: String = std::iter::repeat_n(spec.fill, left).collect();
437 let rpad: String = std::iter::repeat_n(spec.fill, right).collect();
438 format!("{}{}{}", lpad, body, rpad)
439 }
440 Align::SignFirst => {
441 format!("{}{}{}{}", sign_str, symbol_str, padding, number_part)
443 }
444 }
445 }
446}
447
448fn format_general(value: f64, precision: usize) -> String {
449 if value == 0.0 {
450 return format!("{:.prec$}", 0.0, prec = precision.saturating_sub(1).max(0));
451 }
452 let exp = value.log10().floor() as i32;
453 if exp < -4 || exp >= precision as i32 {
454 format!("{:.prec$e}", value, prec = precision.saturating_sub(1))
455 } else {
456 let decimal_places = if precision as i32 > exp + 1 {
457 (precision as i32 - exp - 1) as usize
458 } else {
459 0
460 };
461 format!("{:.prec$}", value, prec = decimal_places)
462 }
463}
464
465fn format_significant(value: f64, sig_figs: usize) -> String {
466 if value == 0.0 {
467 return format!("{:.prec$}", 0.0, prec = sig_figs.saturating_sub(1));
468 }
469 let magnitude = value.abs().log10().floor() as i32;
470 let decimal_places = if sig_figs as i32 > magnitude + 1 {
471 (sig_figs as i32 - magnitude - 1) as usize
472 } else {
473 0
474 };
475 format!("{:.prec$}", value, prec = decimal_places)
476}
477
478fn format_default_si(value: f64) -> String {
479 if value == 0.0 {
482 return "0".to_string();
483 }
484 let magnitude = value.abs().log10().floor() as i32;
485 let sig_figs = 12;
486 let decimal_places = if sig_figs > magnitude + 1 {
487 (sig_figs - magnitude - 1) as usize
488 } else {
489 0
490 };
491 format!("{:.prec$}", value, prec = decimal_places)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_currency_no_decimals() {
500 assert_eq!(NumberFormatter::new("$,.0f").format(1234567.0), "$1,234,567");
501 }
502
503 #[test]
504 fn test_currency_two_decimals() {
505 assert_eq!(NumberFormatter::new("$,.2f").format(1234.5), "$1,234.50");
506 }
507
508 #[test]
509 fn test_comma_no_decimals() {
510 assert_eq!(NumberFormatter::new(",.0f").format(1234567.0), "1,234,567");
511 }
512
513 #[test]
514 fn test_comma_two_decimals() {
515 assert_eq!(NumberFormatter::new(",.2f").format(1234.56), "1,234.56");
516 }
517
518 #[test]
519 fn test_percentage_zero_decimals() {
520 assert_eq!(NumberFormatter::new(".0%").format(0.1234), "12%");
521 }
522
523 #[test]
524 fn test_percentage_one_decimal() {
525 assert_eq!(NumberFormatter::new(".1%").format(0.1234), "12.3%");
526 }
527
528 #[test]
529 fn test_percentage_two_decimals() {
530 assert_eq!(NumberFormatter::new(".2%").format(0.1234), "12.34%");
531 }
532
533 #[test]
534 fn test_si_prefix_trim() {
535 assert_eq!(NumberFormatter::new("~s").format(1234.0), "1.234k");
536 }
537
538 #[test]
539 fn test_si_prefix_precision() {
540 assert_eq!(NumberFormatter::new(".3s").format(1234.0), "1.23k");
541 }
542
543 #[test]
544 fn test_si_prefix_mega_trim() {
545 assert_eq!(NumberFormatter::new("~s").format(1234567.0), "1.234567M");
546 }
547
548 #[test]
549 fn test_decimal() {
550 assert_eq!(NumberFormatter::new("d").format(1234.0), "1234");
551 }
552
553 #[test]
554 fn test_sign_comma() {
555 assert_eq!(NumberFormatter::new("+,.0f").format(1234.0), "+1,234");
556 }
557
558 #[test]
559 fn test_zero_fixed() {
560 assert_eq!(NumberFormatter::new(".0f").format(0.0), "0");
561 }
562
563 #[test]
564 fn test_zero_currency() {
565 assert_eq!(NumberFormatter::new("$,.0f").format(0.0), "$0");
566 }
567
568 #[test]
569 fn test_precision_fixed() {
570 assert_eq!(NumberFormatter::new(".2f").format(3.14159), "3.14");
571 }
572
573 #[test]
574 fn test_negative_currency() {
575 assert_eq!(NumberFormatter::new("$,.0f").format(-1234.0), "-$1,234");
576 }
577
578 #[test]
579 fn test_si_prefix_milli() {
580 assert_eq!(NumberFormatter::new("~s").format(0.001234), "1.234m");
581 }
582
583 #[test]
584 fn test_si_prefix_giga() {
585 assert_eq!(NumberFormatter::new("~s").format(1e9), "1G");
586 }
587
588 #[test]
589 fn test_insert_commas() {
590 assert_eq!(insert_commas("1"), "1");
591 assert_eq!(insert_commas("12"), "12");
592 assert_eq!(insert_commas("123"), "123");
593 assert_eq!(insert_commas("1234"), "1,234");
594 assert_eq!(insert_commas("12345"), "12,345");
595 assert_eq!(insert_commas("123456"), "123,456");
596 assert_eq!(insert_commas("1234567"), "1,234,567");
597 }
598
599 #[test]
600 fn test_parse_spec_basic() {
601 let spec = parse_spec("$,.2f");
602 assert_eq!(spec.symbol, Symbol::Dollar);
603 assert!(spec.comma);
604 assert_eq!(spec.precision, Some(2));
605 assert_eq!(spec.format_type, FormatType::Fixed);
606 }
607
608 #[test]
609 fn test_parse_spec_percentage() {
610 let spec = parse_spec(".1%");
611 assert_eq!(spec.precision, Some(1));
612 assert_eq!(spec.format_type, FormatType::Percentage);
613 }
614
615 #[test]
616 fn test_parse_spec_si() {
617 let spec = parse_spec("~s");
618 assert!(spec.trim);
619 assert_eq!(spec.format_type, FormatType::SiPrefix);
620 }
621
622 #[test]
623 fn test_width_padding() {
624 assert_eq!(NumberFormatter::new("10d").format(42.0), " 42");
625 }
626
627 #[test]
628 fn test_zero_padding() {
629 assert_eq!(NumberFormatter::new("010d").format(42.0), "0000000042");
630 }
631
632 #[test]
633 fn test_left_align() {
634 assert_eq!(NumberFormatter::new("<10d").format(42.0), "42 ");
635 }
636}