Skip to main content

ravenrs_criteria/
format.rs

1//! Culture-aware formatting with .NET-style format strings, as report
2//! designers know them: `{0:C2}`, `{0:N0}`, `{0:dd/MM/yyyy}`, `{0:P1}`.
3
4use crate::value::{DateTime, Value};
5
6/// Number, currency and date conventions of a language and region.
7#[derive(Clone, Debug, PartialEq)]
8pub struct Culture {
9    /// The culture name, e.g. `pt-BR`.
10    pub name: &'static str,
11    /// Decimal separator.
12    pub decimal: char,
13    /// Thousands separator.
14    pub group: char,
15    /// Currency symbol.
16    pub currency: &'static str,
17    /// Currency symbol before the number, with a space between them when true.
18    pub currency_space: bool,
19    /// Short date pattern (`d`).
20    pub short_date: &'static str,
21    /// Long date pattern (`D`).
22    pub long_date: &'static str,
23    /// Short time pattern (`t`).
24    pub short_time: &'static str,
25    /// Month names, January first.
26    pub months: [&'static str; 12],
27    /// Day names, Sunday first.
28    pub days: [&'static str; 7],
29    /// Words for true and false.
30    pub yes_no: [&'static str; 2],
31}
32
33const PT_BR: Culture = Culture {
34    name: "pt-BR",
35    decimal: ',',
36    group: '.',
37    currency: "R$",
38    currency_space: true,
39    short_date: "dd/MM/yyyy",
40    long_date: "dddd, d 'de' MMMM 'de' yyyy",
41    short_time: "HH:mm",
42    months: [
43        "janeiro",
44        "fevereiro",
45        "março",
46        "abril",
47        "maio",
48        "junho",
49        "julho",
50        "agosto",
51        "setembro",
52        "outubro",
53        "novembro",
54        "dezembro",
55    ],
56    days: [
57        "domingo",
58        "segunda-feira",
59        "terça-feira",
60        "quarta-feira",
61        "quinta-feira",
62        "sexta-feira",
63        "sábado",
64    ],
65    yes_no: ["Sim", "Não"],
66};
67
68const EN_US: Culture = Culture {
69    name: "en-US",
70    decimal: '.',
71    group: ',',
72    currency: "$",
73    currency_space: false,
74    short_date: "M/d/yyyy",
75    long_date: "dddd, MMMM d, yyyy",
76    short_time: "h:mm tt",
77    months: [
78        "January",
79        "February",
80        "March",
81        "April",
82        "May",
83        "June",
84        "July",
85        "August",
86        "September",
87        "October",
88        "November",
89        "December",
90    ],
91    days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
92    yes_no: ["Yes", "No"],
93};
94
95impl Culture {
96    /// Brazilian Portuguese.
97    pub fn pt_br() -> Self {
98        PT_BR
99    }
100
101    /// US English.
102    pub fn en_us() -> Self {
103        EN_US
104    }
105
106    /// The culture for a name such as `pt-BR` or `en`, falling back to `en-US`.
107    pub fn named(name: &str) -> Self {
108        match name.to_ascii_lowercase().as_str() {
109            "pt-br" | "pt" | "pt_br" => PT_BR,
110            _ => EN_US,
111        }
112    }
113
114    /// `1234567.891` with `decimals` places and thousands separators: `1.234.567,89`.
115    pub fn number(&self, n: f64, decimals: usize, grouping: bool) -> String {
116        NumberStyle {
117            decimals,
118            group: grouping.then_some(self.group),
119            decimal: self.decimal,
120            prefix: "",
121            suffix: "",
122        }
123        .format(n)
124    }
125
126    /// A currency amount: `R$ 1.234,50` or `$1,234.50`; negatives as `-R$ 1,00`.
127    pub fn currency(&self, n: f64, decimals: usize) -> String {
128        let space = if self.currency_space { "\u{a0}" } else { "" };
129        NumberStyle {
130            decimals,
131            group: Some(self.group),
132            decimal: self.decimal,
133            prefix: &format!("{}{space}", self.currency),
134            suffix: "",
135        }
136        .format(n)
137    }
138}
139
140/// How to write a number: decimal places, separators and text around it. This is the one place
141/// that lays out digits, for [`Culture`] and for anything with separators of its own.
142///
143/// ```
144/// use ravenrs_criteria::NumberStyle;
145///
146/// let style = NumberStyle { decimals: 2, group: Some('.'), decimal: ',', prefix: "R$ ", suffix: "" };
147/// assert_eq!(style.format(-1234.5), "-R$ 1.234,50");
148/// ```
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub struct NumberStyle<'a> {
151    /// Digits after the decimal separator.
152    pub decimals: usize,
153    /// Thousands separator, if digits are grouped.
154    pub group: Option<char>,
155    /// Decimal separator.
156    pub decimal: char,
157    /// Text before the number, after the sign (e.g. a currency symbol).
158    pub prefix: &'a str,
159    /// Text after the number (e.g. `%`).
160    pub suffix: &'a str,
161}
162
163impl NumberStyle<'_> {
164    /// Writes `n`, rounded to the style's decimals. A value that rounds to zero has no sign.
165    pub fn format(&self, n: f64) -> String {
166        let fixed = format!("{:.*}", self.decimals, n.abs());
167        let (int, frac) = fixed.split_once('.').unwrap_or((&fixed, ""));
168        let mut out =
169            String::with_capacity(fixed.len() + int.len() / 3 + self.prefix.len() + self.suffix.len());
170        if n < 0.0 && fixed.chars().any(|c| c.is_ascii_digit() && c != '0') {
171            out.push('-');
172        }
173        out.push_str(self.prefix);
174        for (i, c) in int.chars().enumerate() {
175            if let Some(group) = self.group
176                && i > 0
177                && (int.len() - i) % 3 == 0
178            {
179                out.push(group);
180            }
181            out.push(c);
182        }
183        if !frac.is_empty() {
184            out.push(self.decimal);
185            out.push_str(frac);
186        }
187        out.push_str(self.suffix);
188        out
189    }
190}
191
192/// Formats `value` with a .NET-style format specifier (`C2`, `N0`,
193/// `dd/MM/yyyy`, `#,##0.00`...). An empty specifier gives the plain text.
194pub fn format_value(value: &Value, spec: &str, culture: &Culture) -> String {
195    let spec = spec.trim();
196    match value {
197        Value::Null => String::new(),
198        Value::Number(n) => format_number(*n, spec, culture),
199        Value::Date(d) => format_date(*d, spec, culture),
200        Value::Bool(b) if spec.is_empty() => culture.yes_no[usize::from(!*b)].to_owned(),
201        Value::Text(s) if !spec.is_empty() => {
202            // Text that looks like a number or a date takes number and date formats.
203            if let Some(d) = DateTime::parse(s).filter(|_| is_date_spec(spec)) {
204                format_date(d, spec, culture)
205            } else if let Some(n) = s.trim().parse::<f64>().ok().filter(|_| !is_date_spec(spec)) {
206                format_number(n, spec, culture)
207            } else {
208                s.clone()
209            }
210        }
211        other => other.to_text(),
212    }
213}
214
215fn is_date_spec(spec: &str) -> bool {
216    matches!(spec, "d" | "D" | "t" | "T" | "g" | "G" | "f" | "F" | "M" | "m" | "Y" | "y")
217        || spec.contains("yy")
218        || spec.contains("MM")
219        || spec.contains("dd")
220        || spec.contains("HH")
221        || spec.contains("mm")
222}
223
224/// Formats a composite string: `{0}`, `{1:C2}`, `{{` and `}}` for braces.
225pub fn format_composite(pattern: &str, args: &[Value], culture: &Culture) -> String {
226    let mut out = String::new();
227    let mut chars = pattern.chars().peekable();
228    while let Some(c) = chars.next() {
229        match c {
230            '{' if chars.peek() == Some(&'{') => {
231                chars.next();
232                out.push('{');
233            }
234            '}' if chars.peek() == Some(&'}') => {
235                chars.next();
236                out.push('}');
237            }
238            '{' => {
239                let mut inner = String::new();
240                for c in chars.by_ref() {
241                    if c == '}' {
242                        break;
243                    }
244                    inner.push(c);
245                }
246                let (index, spec) = inner.split_once(':').unwrap_or((&inner, ""));
247                let index = index.split(',').next().unwrap_or_default().trim();
248                match index.parse::<usize>().ok().and_then(|i| args.get(i)) {
249                    Some(v) => out.push_str(&format_value(v, spec, culture)),
250                    None => {
251                        out.push('{');
252                        out.push_str(&inner);
253                        out.push('}');
254                    }
255                }
256            }
257            c => out.push(c),
258        }
259    }
260    out
261}
262
263fn format_number(n: f64, spec: &str, culture: &Culture) -> String {
264    if spec.is_empty() || spec.eq_ignore_ascii_case("g") {
265        let plain = crate::value::format_plain(n);
266        return plain.replace('.', &culture.decimal.to_string());
267    }
268    let mut chars = spec.chars();
269    let kind = chars.next().unwrap_or('G');
270    let digits: Option<usize> = chars.as_str().parse().ok();
271    let standard = chars.as_str().is_empty() || digits.is_some();
272    if standard {
273        match kind.to_ascii_uppercase() {
274            'C' => return culture.currency(n, digits.unwrap_or(2)),
275            'N' => return culture.number(n, digits.unwrap_or(2), true),
276            'F' => return culture.number(n, digits.unwrap_or(2), false),
277            'P' => return format!("{}%", culture.number(n * 100.0, digits.unwrap_or(2), true)),
278            'D' => {
279                let s = format!("{:0width$}", (n.round() as i64).abs(), width = digits.unwrap_or(1));
280                return if n < 0.0 { format!("-{s}") } else { s };
281            }
282            'X' => {
283                let s = format!("{:0width$X}", n.round() as i64, width = digits.unwrap_or(1));
284                return if kind == 'x' { s.to_lowercase() } else { s };
285            }
286            _ => {}
287        }
288    }
289    format_custom_number(n, spec, culture)
290}
291
292/// Custom numeric patterns: `#,##0.00`, `0.0%`, `000`, with literal text around
293/// them and an optional `;` section for negatives.
294fn format_custom_number(n: f64, spec: &str, culture: &Culture) -> String {
295    let (spec, n) = match spec.split_once(';') {
296        Some((_, negative)) if n < 0.0 => (negative, n.abs()),
297        Some((positive, _)) => (positive, n),
298        None => (spec, n),
299    };
300    let is_digit = |c: char| matches!(c, '0' | '#' | ',' | '.');
301    let start = spec.find(is_digit);
302    let end = spec.rfind(is_digit).map(|i| i + 1);
303    let (Some(start), Some(end)) = (start, end) else { return spec.to_owned() };
304    let (prefix, pattern, suffix) = (&spec[..start], &spec[start..end], &spec[end..]);
305    let percent = prefix.contains('%') || suffix.contains('%');
306    let value = if percent { n * 100.0 } else { n };
307    let (int_pat, frac_pat) = pattern.split_once('.').unwrap_or((pattern, ""));
308    let min_frac = frac_pat.chars().filter(|c| *c == '0').count();
309    let max_frac = frac_pat.chars().filter(|c| matches!(c, '0' | '#')).count();
310    let min_int = int_pat.chars().filter(|c| *c == '0').count();
311    let mut body = culture.number(value, max_frac, int_pat.contains(','));
312    // Drop optional (`#`) trailing decimals that are zero.
313    if max_frac > min_frac
314        && let Some(dot) = body.rfind(culture.decimal)
315    {
316        let mut keep = body.len();
317        while keep > dot + 1 + min_frac && body[..keep].ends_with('0') {
318            keep -= 1;
319        }
320        body.truncate(keep);
321        if body.ends_with(culture.decimal) {
322            body.pop();
323        }
324    }
325    // Pad the integer part to the required zeros.
326    let negative = body.starts_with('-');
327    let digits_start = usize::from(negative);
328    let int_len = body[digits_start..].find(culture.decimal).unwrap_or(body.len() - digits_start);
329    let int_digits = body[digits_start..digits_start + int_len].chars().filter(char::is_ascii_digit).count();
330    if int_digits < min_int {
331        body.insert_str(digits_start, &"0".repeat(min_int - int_digits));
332    }
333    if min_int == 0 && body[digits_start..].starts_with(&format!("0{}", culture.decimal)) {
334        body.remove(digits_start);
335    }
336    format!("{}{body}{}", unquote(prefix), unquote(suffix))
337}
338
339fn unquote(s: &str) -> String {
340    s.replace(['\'', '"'], "")
341}
342
343fn format_date(d: DateTime, spec: &str, culture: &Culture) -> String {
344    let pattern = match spec {
345        "" | "G" => format!(
346            "{} {}",
347            culture.short_date,
348            if culture.name == "pt-BR" { "HH:mm:ss" } else { "h:mm:ss tt" }
349        ),
350        "d" => culture.short_date.to_owned(),
351        "D" => culture.long_date.to_owned(),
352        "t" => culture.short_time.to_owned(),
353        "T" => (if culture.name == "pt-BR" { "HH:mm:ss" } else { "h:mm:ss tt" }).to_owned(),
354        "g" => format!("{} {}", culture.short_date, culture.short_time),
355        "M" | "m" => (if culture.name == "pt-BR" { "d 'de' MMMM" } else { "MMMM d" }).to_owned(),
356        "Y" | "y" => (if culture.name == "pt-BR" { "MMMM 'de' yyyy" } else { "MMMM yyyy" }).to_owned(),
357        custom => custom.to_owned(),
358    };
359    // A date without a time shows only the date part for the general format.
360    let pattern = if spec.is_empty() && d.seconds == 0 { culture.short_date.to_owned() } else { pattern };
361    let (year, month, day) = d.ymd();
362    let (hour, minute, second) = d.hms();
363    let mut out = String::new();
364    let chars: Vec<char> = pattern.chars().collect();
365    let mut i = 0;
366    while i < chars.len() {
367        let c = chars[i];
368        if c == '\'' || c == '"' {
369            i += 1;
370            while i < chars.len() && chars[i] != c {
371                out.push(chars[i]);
372                i += 1;
373            }
374            i += 1;
375            continue;
376        }
377        let mut run = 1;
378        while i + run < chars.len() && chars[i + run] == c {
379            run += 1;
380        }
381        let text = match (c, run) {
382            ('y', 1) => (year % 100).to_string(),
383            ('y', 2) => format!("{:02}", year % 100),
384            ('y', _) => format!("{year:0run$}"),
385            ('M', 1) => month.to_string(),
386            ('M', 2) => format!("{month:02}"),
387            ('M', 3) => abbreviate(culture.months[(month - 1) as usize]),
388            ('M', _) => culture.months[(month - 1) as usize].to_owned(),
389            ('d', 1) => day.to_string(),
390            ('d', 2) => format!("{day:02}"),
391            ('d', 3) => abbreviate(culture.days[d.weekday() as usize]),
392            ('d', _) => culture.days[d.weekday() as usize].to_owned(),
393            ('H', 1) => hour.to_string(),
394            ('H', _) => format!("{hour:02}"),
395            ('h', n) => {
396                let h12 = if hour % 12 == 0 { 12 } else { hour % 12 };
397                if n == 1 { h12.to_string() } else { format!("{h12:02}") }
398            }
399            ('m', 1) => minute.to_string(),
400            ('m', _) => format!("{minute:02}"),
401            ('s', 1) => second.to_string(),
402            ('s', _) => format!("{second:02}"),
403            ('t', _) => (if hour < 12 { "AM" } else { "PM" }).to_owned(),
404            (c, n) => c.to_string().repeat(n),
405        };
406        out.push_str(&text);
407        i += run;
408    }
409    out
410}
411
412fn abbreviate(name: &str) -> String {
413    name.chars().take(3).collect()
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn pt(v: impl Into<Value>, spec: &str) -> String {
421        format_value(&v.into(), spec, &Culture::pt_br())
422    }
423
424    fn en(v: impl Into<Value>, spec: &str) -> String {
425        format_value(&v.into(), spec, &Culture::en_us())
426    }
427
428    #[test]
429    fn standard_number_formats() {
430        assert_eq!(pt(1234.5, "C2"), "R$\u{a0}1.234,50");
431        assert_eq!(en(1234.5, "C"), "$1,234.50");
432        assert_eq!(pt(-1234.5, "C2"), "-R$\u{a0}1.234,50");
433        assert_eq!(pt(1234567.891, "N2"), "1.234.567,89");
434        assert_eq!(en(1234567.891, "N0"), "1,234,568");
435        assert_eq!(pt(0.1234, "P1"), "12,3%");
436        assert_eq!(en(42.0, "D5"), "00042");
437        assert_eq!(pt(1.23456, "F3"), "1,235");
438        assert_eq!(pt(-0.001, "N2"), "0,00", "no negative zero");
439        assert_eq!(pt(2.5, ""), "2,5");
440    }
441
442    #[test]
443    fn custom_number_patterns() {
444        assert_eq!(en(1234.5, "#,##0.00"), "1,234.50");
445        assert_eq!(en(1234.5, "#,##0.##"), "1,234.5");
446        assert_eq!(en(7.0, "000"), "007");
447        assert_eq!(en(0.256, "0.0%"), "25.6%");
448        assert_eq!(en(0.5, "#.##"), ".5");
449        assert_eq!(en(-5.0, "0;(0)"), "(5)");
450        assert_eq!(pt(12.0, "'Qtd: '0"), "Qtd: 12");
451    }
452
453    #[test]
454    fn date_formats() {
455        let d = Value::Date(DateTime::parse("2026-11-01T09:05:07").expect("valid"));
456        assert_eq!(pt(d.clone(), "d"), "01/11/2026");
457        assert_eq!(en(d.clone(), "d"), "11/1/2026");
458        assert_eq!(pt(d.clone(), "D"), "domingo, 1 de novembro de 2026");
459        assert_eq!(en(d.clone(), "D"), "Sunday, November 1, 2026");
460        assert_eq!(pt(d.clone(), "dd/MM/yy HH:mm"), "01/11/26 09:05");
461        assert_eq!(en(d.clone(), "MMM d, h:mm tt"), "Nov 1, 9:05 AM");
462        assert_eq!(pt(d, "MMMM 'de' yyyy"), "novembro de 2026");
463        assert_eq!(pt("2026-03-15", "dd/MM/yyyy"), "15/03/2026", "ISO text formats as a date");
464    }
465
466    #[test]
467    fn composite_strings() {
468        let c = Culture::pt_br();
469        let s = format_composite("Página {0} de {1}", &[Value::from(2_i64), Value::from(9_i64)], &c);
470        assert_eq!(s, "Página 2 de 9");
471        assert_eq!(format_composite("{0:C2} {{x}}", &[Value::from(5.0)], &c), "R$\u{a0}5,00 {x}");
472        assert_eq!(format_composite("{3}", &[], &c), "{3}", "missing arguments stay visible");
473        assert_eq!(pt(true, ""), "Sim");
474    }
475}