Skip to main content

datui_lib/
numfmt.rs

1//! Display-time number formatting: digit grouping (thousands separators),
2//! decimal separator choice, and optional fixed float precision.
3//!
4//! **This is display-only.** Exports, queries, filter values, views, and
5//! group-by key strings always use raw values — see [`format_any_value`] call
6//! sites. Nothing here is ever fed back into Polars.
7//!
8//! # Performance
9//!
10//! Formatting runs once per *visible* cell per frame (roughly
11//! `visible_rows * visible_cols`), so the hot path is deliberately allocation
12//! free beyond the destination `String` the caller already needs:
13//!
14//! - Integers are written digit-by-digit into a stack buffer with separators
15//!   emitted inline — one pass, no intermediate string.
16//! - [`NumberFormat::width_i64`] computes display width arithmetically, so the
17//!   locked-column measurement pass never builds a string it throws away.
18//! - Per-column decisions (dtype eligibility, exclude globs) resolve to a
19//!   [`CellFormatter`] once per column per frame, never per cell.
20
21use std::borrow::Cow;
22use std::fmt::Write as _;
23
24use polars::prelude::{AnyValue, DataType};
25
26/// Digit grouping style.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Grouping {
29    /// No grouping: `1234567`
30    #[default]
31    None,
32    /// Western grouping in threes: `1,234,567`
33    Thousands,
34    /// Indian grouping — three, then twos: `12,34,567` (lakh / crore)
35    Indian,
36}
37
38impl Grouping {
39    /// Number of separators a value with `digits` integer digits will contain.
40    fn separator_count(self, digits: usize) -> usize {
41        match self {
42            Grouping::None => 0,
43            Grouping::Thousands => digits.saturating_sub(1) / 3,
44            Grouping::Indian => {
45                if digits <= 3 {
46                    0
47                } else {
48                    1 + (digits - 4) / 2
49                }
50            }
51        }
52    }
53
54    /// Whether a separator goes before the next digit, given how many digits
55    /// have already been emitted (counting from the right).
56    #[inline]
57    fn breaks_after(self, digits_emitted: u32) -> bool {
58        match self {
59            Grouping::None => false,
60            Grouping::Thousands => digits_emitted.is_multiple_of(3),
61            Grouping::Indian => {
62                digits_emitted == 3 || (digits_emitted > 3 && digits_emitted % 2 == 1)
63            }
64        }
65    }
66}
67
68/// How numbers are rendered. Resolved from config once at load time.
69#[derive(Debug, Clone, PartialEq)]
70pub struct NumberFormat {
71    pub grouping: Grouping,
72    /// Character placed between digit groups.
73    pub group_sep: char,
74    /// Character used as the decimal point.
75    pub decimal_sep: char,
76    /// Apply grouping to float columns as well as integer columns.
77    pub floats: bool,
78    /// Fixed decimal places for floats. `None` keeps the default rendering.
79    pub float_precision: Option<u8>,
80}
81
82impl Default for NumberFormat {
83    fn default() -> Self {
84        Self::PLAIN
85    }
86}
87
88impl NumberFormat {
89    /// Renders numbers exactly as Polars would — no grouping, no precision
90    /// override. This is the default so an upgrade changes nothing.
91    pub const PLAIN: Self = Self {
92        grouping: Grouping::None,
93        group_sep: ',',
94        decimal_sep: '.',
95        floats: true,
96        float_precision: None,
97    };
98
99    /// Look up a named preset. Presets cover the common locale conventions
100    /// without pulling in ICU/CLDR data — see `docs/user-guide/configuration.md`
101    /// for why formatting is explicit rather than auto-detected from the
102    /// environment.
103    pub fn preset(name: &str) -> Option<Self> {
104        let base = Self::PLAIN;
105        Some(match name {
106            // 1234567
107            "none" | "plain" => base,
108            // 1,234,567.89
109            "thousands" => Self {
110                grouping: Grouping::Thousands,
111                group_sep: ',',
112                decimal_sep: '.',
113                ..base
114            },
115            // 1.234.567,89
116            "european" => Self {
117                grouping: Grouping::Thousands,
118                group_sep: '.',
119                decimal_sep: ',',
120                ..base
121            },
122            // 1 234 567.89 (ISO 31-0 / SI)
123            "si" => Self {
124                grouping: Grouping::Thousands,
125                group_sep: '\u{202f}', // narrow no-break space
126                decimal_sep: '.',
127                ..base
128            },
129            // 1'234'567.89
130            "swiss" => Self {
131                grouping: Grouping::Thousands,
132                group_sep: '\'',
133                decimal_sep: '.',
134                ..base
135            },
136            // 12,34,567.89
137            "indian" => Self {
138                grouping: Grouping::Indian,
139                group_sep: ',',
140                decimal_sep: '.',
141                ..base
142            },
143            // 1_234_567.89
144            "underscore" => Self {
145                grouping: Grouping::Thousands,
146                group_sep: '_',
147                decimal_sep: '.',
148                ..base
149            },
150            _ => return None,
151        })
152    }
153
154    /// Comma grouping with no digit threshold, used for the application's own
155    /// labels rather than the user's data.
156    pub const CHROME: Self = Self {
157        grouping: Grouping::Thousands,
158        group_sep: ',',
159        decimal_sep: '.',
160        floats: true,
161        float_precision: None,
162    };
163
164    /// Every preset name, for CLI value parsing and error messages.
165    pub const PRESET_NAMES: &'static [&'static str] = &[
166        "none",
167        "thousands",
168        "european",
169        "si",
170        "swiss",
171        "indian",
172        "underscore",
173    ];
174
175    /// True when this format would render every value exactly as Polars does,
176    /// so callers can take the zero-cost passthrough path.
177    pub fn is_noop(&self) -> bool {
178        self.grouping == Grouping::None && self.decimal_sep == '.' && self.float_precision.is_none()
179    }
180
181    /// Whether values get digit grouping at all.
182    ///
183    /// Every value in a formatted column is grouped, with no magnitude
184    /// threshold: within a table, uniform treatment of a column reads better
185    /// than the prose convention of leaving four-digit numbers alone. Columns
186    /// holding identifiers rather than quantities are named in `exclude`.
187    #[inline]
188    fn groups(&self) -> bool {
189        self.grouping != Grouping::None
190    }
191
192    /// Display width (in characters) of `v` as this format would render it,
193    /// computed arithmetically — no string is built.
194    pub fn width_i64(&self, v: i64) -> usize {
195        self.width_u64(v.unsigned_abs()) + usize::from(v < 0)
196    }
197
198    /// Display width (in characters) of `v` as this format would render it.
199    pub fn width_u64(&self, v: u64) -> usize {
200        let digits = digit_count(v);
201        let seps = if self.groups() {
202            self.grouping.separator_count(digits)
203        } else {
204            0
205        };
206        digits + seps
207    }
208
209    /// Append `v` to `out`, returning the display width in characters.
210    pub fn write_i64(&self, v: i64, out: &mut String) -> usize {
211        self.write_magnitude(v.unsigned_abs(), v < 0, out)
212    }
213
214    /// Append `v` to `out`, returning the display width in characters.
215    pub fn write_u64(&self, v: u64, out: &mut String) -> usize {
216        self.write_magnitude(v, false, out)
217    }
218
219    /// Core integer path: writes digits back-to-front into a stack buffer,
220    /// emitting separators inline, then appends the finished slice in one go.
221    fn write_magnitude(&self, mag: u64, negative: bool, out: &mut String) -> usize {
222        // u64::MAX is 20 digits; Indian grouping tops out at 9 separators, each
223        // at most 4 UTF-8 bytes; plus a sign.
224        let mut buf = [0u8; 64];
225        let mut pos = buf.len();
226        let mut width = 0usize;
227
228        let group = self.groups();
229        let mut sep_bytes = [0u8; 4];
230        let sep = self.group_sep.encode_utf8(&mut sep_bytes);
231        let sep = sep.as_bytes();
232
233        let mut n = mag;
234        let mut emitted: u32 = 0;
235        loop {
236            let d = (n % 10) as u8;
237            n /= 10;
238            pos -= 1;
239            buf[pos] = b'0' + d;
240            emitted += 1;
241            width += 1;
242            if n == 0 {
243                break;
244            }
245            if group && self.grouping.breaks_after(emitted) {
246                pos -= sep.len();
247                buf[pos..pos + sep.len()].copy_from_slice(sep);
248                width += 1;
249            }
250        }
251
252        if negative {
253            pos -= 1;
254            buf[pos] = b'-';
255            width += 1;
256        }
257
258        // Every byte written is either ASCII or a complete UTF-8 encoding of
259        // `group_sep`, so the slice is valid UTF-8 by construction.
260        debug_assert!(std::str::from_utf8(&buf[pos..]).is_ok());
261        match std::str::from_utf8(&buf[pos..]) {
262            Ok(s) => {
263                out.push_str(s);
264                width
265            }
266            // Unreachable. Report zero rather than `width` anyway: callers size
267            // table columns from this return value, so a width that does not
268            // match what was actually pushed would corrupt the layout instead
269            // of failing visibly.
270            Err(_) => 0,
271        }
272    }
273
274    /// Append `v` to `out`, returning the display width in characters.
275    ///
276    /// `scratch` is a caller-owned reusable buffer; after the first call in a
277    /// render pass it has enough capacity and no longer allocates.
278    pub fn write_f64(&self, v: f64, scratch: &mut String, out: &mut String) -> usize {
279        scratch.clear();
280        match self.float_precision {
281            Some(p) => {
282                let _ = write!(scratch, "{:.*}", p as usize, v);
283            }
284            None => {
285                let _ = write!(scratch, "{}", v);
286            }
287        }
288        self.regroup_decimal(scratch, out)
289    }
290
291    /// Insert group separators into the integer part of an already-rendered
292    /// decimal string and apply `decimal_sep`. Returns the display width.
293    ///
294    /// Used for floats so Polars' own rendering is preserved and only the
295    /// grouping is layered on — toggling formatting never changes how many
296    /// decimal places a value shows. Anything that is not a plain decimal
297    /// (`NaN`, `inf`, scientific notation) is copied through unchanged.
298    pub fn regroup_decimal(&self, src: &str, out: &mut String) -> usize {
299        let body = src.strip_prefix('-').unwrap_or(src);
300        let negative = body.len() != src.len();
301        let (int_part, frac_part) = match body.find('.') {
302            Some(i) => (&body[..i], Some(&body[i + 1..])),
303            None => (body, None),
304        };
305
306        let plain = !int_part.is_empty()
307            && int_part.bytes().all(|b| b.is_ascii_digit())
308            && frac_part.is_none_or(|f| f.bytes().all(|b| b.is_ascii_digit()));
309        if !plain {
310            // NaN, inf, 1e300 — not something to regroup.
311            out.push_str(src);
312            return src.chars().count();
313        }
314
315        let mut width = 0usize;
316        if negative {
317            out.push('-');
318            width += 1;
319        }
320
321        let digits = int_part.len();
322        if self.groups() {
323            for (i, ch) in int_part.chars().enumerate() {
324                // A separator precedes this digit when the digits still to come
325                // (including this one) land on a group boundary.
326                let remaining = (digits - i) as u32;
327                if i > 0 && self.grouping.breaks_after(remaining) {
328                    out.push(self.group_sep);
329                    width += 1;
330                }
331                out.push(ch);
332                width += 1;
333            }
334        } else {
335            out.push_str(int_part);
336            width += digits;
337        }
338
339        if let Some(frac) = frac_part {
340            out.push(self.decimal_sep);
341            width += 1 + frac.len();
342            out.push_str(frac);
343        }
344        width
345    }
346}
347
348/// Comma-group a count for the application's own chrome — the control bar's
349/// row count, info-panel totals, and similar labels.
350///
351/// Deliberately unconditional: these are datui's labels, not the user's data,
352/// so they stay readable regardless of `display.number_format` or the `,`
353/// toggle. Keeping the distinction means turning formatting off to read exact
354/// data values never makes the surrounding UI harder to read.
355pub fn group_chrome(n: usize) -> String {
356    let mut out = String::new();
357    NumberFormat::CHROME.write_u64(n as u64, &mut out);
358    out
359}
360
361/// Digits in the base-10 representation of `n` (`0` counts as one digit).
362#[inline]
363fn digit_count(n: u64) -> usize {
364    n.checked_ilog10().map_or(0, |l| l as usize) + 1
365}
366
367/// Per-column formatting decision, resolved once per column per frame.
368#[derive(Debug, Clone, PartialEq)]
369pub enum CellFormatter {
370    /// Render exactly as Polars does. Zero added cost.
371    Passthrough,
372    /// Apply `NumberFormat` to numeric values.
373    Number(NumberFormat),
374}
375
376impl CellFormatter {
377    #[inline]
378    pub fn is_passthrough(&self) -> bool {
379        matches!(self, CellFormatter::Passthrough)
380    }
381}
382
383/// True for dtypes whose values are numbers we group.
384pub fn is_numeric_dtype(dtype: &DataType) -> bool {
385    matches!(
386        dtype,
387        DataType::Int8
388            | DataType::Int16
389            | DataType::Int32
390            | DataType::Int64
391            | DataType::UInt8
392            | DataType::UInt16
393            | DataType::UInt32
394            | DataType::UInt64
395            | DataType::Float32
396            | DataType::Float64
397    )
398}
399
400/// True for dtypes that should render flush-right in the data table.
401///
402/// Temporal types are deliberately excluded: they render fixed-width, so
403/// alignment buys nothing, and moving them would be a change unrelated to
404/// number formatting.
405pub fn is_right_aligned_dtype(dtype: &DataType) -> bool {
406    is_numeric_dtype(dtype)
407}
408
409/// Fully resolved display-formatting settings, held in the render context.
410#[derive(Debug, Clone)]
411pub struct NumberFormatSettings {
412    /// The configured format.
413    pub format: NumberFormat,
414    /// Runtime `,` toggle. When false every column is `Passthrough`.
415    pub enabled: bool,
416    /// Columns never formatted (precompiled globs).
417    pub exclude: Vec<Glob>,
418    /// Right-align numeric columns and their headers.
419    pub align_numeric_right: bool,
420}
421
422impl Default for NumberFormatSettings {
423    fn default() -> Self {
424        Self {
425            format: NumberFormat::PLAIN,
426            enabled: true,
427            exclude: Vec::new(),
428            align_numeric_right: true,
429        }
430    }
431}
432
433impl NumberFormatSettings {
434    /// Resolve the formatter for one column. Called once per column per frame —
435    /// never per cell, so glob matching stays off the hot path.
436    pub fn formatter_for(&self, col_name: &str, dtype: &DataType) -> CellFormatter {
437        if !self.enabled || self.format.is_noop() || !is_numeric_dtype(dtype) {
438            return CellFormatter::Passthrough;
439        }
440        if self.exclude.iter().any(|g| g.matches(col_name)) {
441            return CellFormatter::Passthrough;
442        }
443        let mut fmt = self.format.clone();
444        if !fmt.floats && matches!(dtype, DataType::Float32 | DataType::Float64) {
445            // Grouping is off for floats, but a decimal separator or fixed
446            // precision may still apply.
447            fmt.grouping = Grouping::None;
448            if fmt.is_noop() {
449                return CellFormatter::Passthrough;
450            }
451        }
452        CellFormatter::Number(fmt)
453    }
454}
455
456/// Format one Polars value for display.
457///
458/// Returns `Cow::Borrowed` whenever no formatting applies, so unformatted
459/// columns cost exactly what they cost today.
460pub fn format_any_value<'v>(
461    fmt: &CellFormatter,
462    value: &'v AnyValue<'v>,
463    scratch: &mut String,
464) -> Cow<'v, str> {
465    if matches!(value, AnyValue::Null) {
466        return Cow::Borrowed("");
467    }
468    let nf = match fmt {
469        CellFormatter::Passthrough => return crate::exact::str_value(value),
470        CellFormatter::Number(nf) => nf,
471    };
472    let mut out = String::new();
473    match *value {
474        AnyValue::Int8(v) => nf.write_i64(v as i64, &mut out),
475        AnyValue::Int16(v) => nf.write_i64(v as i64, &mut out),
476        AnyValue::Int32(v) => nf.write_i64(v as i64, &mut out),
477        AnyValue::Int64(v) => nf.write_i64(v, &mut out),
478        AnyValue::UInt8(v) => nf.write_u64(v as u64, &mut out),
479        AnyValue::UInt16(v) => nf.write_u64(v as u64, &mut out),
480        AnyValue::UInt32(v) => nf.write_u64(v as u64, &mut out),
481        AnyValue::UInt64(v) => nf.write_u64(v, &mut out),
482        // With no explicit precision, floats route through Polars' own
483        // rendering so toggling formatting never changes how many decimal
484        // places a value shows — only the grouping is layered on.
485        AnyValue::Float32(f) => match nf.float_precision {
486            Some(_) => nf.write_f64(f as f64, scratch, &mut out),
487            None => nf.regroup_decimal(&value.str_value(), &mut out),
488        },
489        AnyValue::Float64(f) => match nf.float_precision {
490            Some(_) => nf.write_f64(f, scratch, &mut out),
491            None => nf.regroup_decimal(&value.str_value(), &mut out),
492        },
493        _ => return crate::exact::str_value(value),
494    };
495    Cow::Owned(out)
496}
497
498/// Cells a value takes on screen without building its string, where possible.
499///
500/// Integers take the arithmetic path; everything else is rendered and measured in
501/// terminal cells, as the table measures it. For a pass that needs widths only.
502pub fn display_width(fmt: &CellFormatter, value: &AnyValue, scratch: &mut String) -> usize {
503    if matches!(value, AnyValue::Null) {
504        return 0;
505    }
506    if let CellFormatter::Number(nf) = fmt {
507        match *value {
508            AnyValue::Int8(v) => return nf.width_i64(v as i64),
509            AnyValue::Int16(v) => return nf.width_i64(v as i64),
510            AnyValue::Int32(v) => return nf.width_i64(v as i64),
511            AnyValue::Int64(v) => return nf.width_i64(v),
512            AnyValue::UInt8(v) => return nf.width_u64(v as u64),
513            AnyValue::UInt16(v) => return nf.width_u64(v as u64),
514            AnyValue::UInt32(v) => return nf.width_u64(v as u64),
515            AnyValue::UInt64(v) => return nf.width_u64(v),
516            _ => {}
517        }
518    }
519    crate::glyphs::cell_width(&format_any_value(fmt, value, scratch))
520}
521
522/// Minimal glob matcher supporting `*` (any run) and `?` (one character).
523///
524/// A dependency would be overkill for matching column names; this is the whole
525/// feature surface the config documents.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct Glob {
528    pattern: String,
529    has_wildcard: bool,
530}
531
532impl Glob {
533    pub fn new(pattern: impl Into<String>) -> Self {
534        let pattern = pattern.into();
535        let has_wildcard = pattern.contains('*') || pattern.contains('?');
536        Self {
537            pattern,
538            has_wildcard,
539        }
540    }
541
542    pub fn matches(&self, name: &str) -> bool {
543        if !self.has_wildcard {
544            return self.pattern == name;
545        }
546        let p: Vec<char> = self.pattern.chars().collect();
547        let n: Vec<char> = name.chars().collect();
548        // Standard two-pointer wildcard match with backtracking on the last `*`.
549        let (mut pi, mut ni) = (0usize, 0usize);
550        let (mut star, mut mark) = (usize::MAX, 0usize);
551        while ni < n.len() {
552            // `*` is tested before the literal comparison, not after. With the order
553            // reversed a `*` in the pattern matched a literal `*` in the name and then
554            // stopped being a wildcard, so `*` failed to match a name like "a*b".
555            // Found by the `glob_match` fuzz target.
556            if pi < p.len() && p[pi] == '*' {
557                star = pi;
558                mark = ni;
559                pi += 1;
560            } else if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
561                pi += 1;
562                ni += 1;
563            } else if star != usize::MAX {
564                pi = star + 1;
565                mark += 1;
566                ni = mark;
567            } else {
568                return false;
569            }
570        }
571        while pi < p.len() && p[pi] == '*' {
572            pi += 1;
573        }
574        pi == p.len()
575    }
576}
577
578/// Map a POSIX locale / language tag to the preset whose conventions match.
579///
580/// Only consulted when the user explicitly opts in with `grouping = "system"`.
581/// Deliberately a small static table rather than ICU/CLDR: locale data is
582/// multiple megabytes, and a single-binary TUI should not carry it to choose a
583/// separator character.
584pub fn preset_for_locale_tag(tag: &str) -> &'static str {
585    // Strip encoding/modifier suffixes: "de_DE.UTF-8@euro" -> "de_DE"
586    let base = tag
587        .split(['.', '@'])
588        .next()
589        .unwrap_or(tag)
590        .replace('_', "-");
591    let lower = base.to_ascii_lowercase();
592    let lang = lower.split('-').next().unwrap_or(&lower);
593    let region = lower.split('-').nth(1).unwrap_or("");
594
595    // Swiss variants group with apostrophes regardless of language.
596    if region == "ch" {
597        return "swiss";
598    }
599    match lang {
600        "de" | "es" | "it" | "pt" | "nl" | "id" | "tr" | "da" | "el" | "ro" | "ca" | "vi"
601        | "sl" | "hr" | "sr" | "is" => "european",
602        "fr" | "nb" | "no" | "sv" | "fi" | "cs" | "sk" | "pl" | "ru" | "uk" | "hu" | "lv"
603        | "lt" | "et" | "bg" => "si",
604        "hi" | "bn" | "ta" | "te" | "mr" | "gu" | "kn" | "ml" | "pa" | "or" | "as" | "ne" => {
605            "indian"
606        }
607        // C / POSIX / unset and everything else: plain Western grouping.
608        _ => "thousands",
609    }
610}
611
612/// Read the environment's numeric locale, honouring POSIX precedence.
613/// Returns `None` when unset or explicitly the C/POSIX locale.
614pub fn system_locale_tag() -> Option<String> {
615    for var in ["LC_ALL", "LC_NUMERIC", "LANG"] {
616        if let Ok(v) = std::env::var(var) {
617            let v = v.trim();
618            if v.is_empty() {
619                continue;
620            }
621            if v == "C" || v == "POSIX" || v.starts_with("C.") {
622                return None;
623            }
624            return Some(v.to_string());
625        }
626    }
627    None
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn fmt_i64(nf: &NumberFormat, v: i64) -> String {
635        let mut s = String::new();
636        let w = nf.write_i64(v, &mut s);
637        assert_eq!(w, s.chars().count(), "reported width disagrees with output");
638        assert_eq!(w, nf.width_i64(v), "width_i64 disagrees with write_i64");
639        s
640    }
641
642    fn thousands() -> NumberFormat {
643        NumberFormat::preset("thousands").unwrap()
644    }
645
646    /// A date past the calendar is its stored number in a cell, formatted or
647    /// not, and measured as that: Polars panics formatting it.
648    #[test]
649    fn a_date_past_the_calendar_is_its_stored_number_in_a_cell() {
650        let mut scratch = String::new();
651        let thousands = NumberFormatSettings {
652            format: NumberFormat::preset("thousands").unwrap(),
653            ..NumberFormatSettings::default()
654        };
655        let value = AnyValue::Datetime(i64::MIN + 1, polars::prelude::TimeUnit::Microseconds, None);
656        let dtype = DataType::Datetime(polars::prelude::TimeUnit::Microseconds, None);
657        for fmt in [
658            CellFormatter::Passthrough,
659            thousands.formatter_for("t", &dtype),
660            CellFormatter::Number(NumberFormat::preset("thousands").unwrap()),
661        ] {
662            let text = format_any_value(&fmt, &value, &mut scratch).into_owned();
663            assert_eq!(text, "-9223372036854775807 us since 1970-01-01 UTC");
664            assert_eq!(display_width(&fmt, &value, &mut scratch), text.len());
665        }
666        let date = AnyValue::Date(i32::MAX);
667        assert_eq!(
668            format_any_value(&CellFormatter::Passthrough, &date, &mut scratch),
669            "2147483647 days since 1970-01-01"
670        );
671    }
672    #[test]
673    fn digit_count_basics() {
674        assert_eq!(digit_count(0), 1);
675        assert_eq!(digit_count(9), 1);
676        assert_eq!(digit_count(10), 2);
677        assert_eq!(digit_count(999), 3);
678        assert_eq!(digit_count(1000), 4);
679        assert_eq!(digit_count(u64::MAX), 20);
680    }
681
682    #[test]
683    fn every_value_in_a_formatted_column_is_grouped() {
684        // No magnitude threshold: a column must not mix "1000" and
685        // "248,956,422". Columns holding identifiers are named in `exclude`
686        // instead of being guessed at by size.
687        let nf = thousands();
688        assert_eq!(fmt_i64(&nf, 0), "0");
689        assert_eq!(fmt_i64(&nf, 999), "999");
690        assert_eq!(fmt_i64(&nf, 1000), "1,000");
691        assert_eq!(fmt_i64(&nf, 2024), "2,024");
692        assert_eq!(fmt_i64(&nf, 9999), "9,999");
693        assert_eq!(fmt_i64(&nf, 10000), "10,000");
694        assert_eq!(fmt_i64(&nf, 1234567), "1,234,567");
695    }
696
697    #[test]
698    fn negatives_and_extremes() {
699        let nf = thousands();
700        assert_eq!(fmt_i64(&nf, -1234567), "-1,234,567");
701        assert_eq!(fmt_i64(&nf, -999), "-999");
702        assert_eq!(fmt_i64(&nf, i64::MIN), "-9,223,372,036,854,775,808");
703        assert_eq!(fmt_i64(&nf, i64::MAX), "9,223,372,036,854,775,807");
704
705        let mut s = String::new();
706        let w = nf.write_u64(u64::MAX, &mut s);
707        assert_eq!(s, "18,446,744,073,709,551,615");
708        assert_eq!(w, s.chars().count());
709        assert_eq!(w, nf.width_u64(u64::MAX));
710    }
711
712    #[test]
713    fn bed_style_coordinates() {
714        // The case from issue #51: genomic coordinates in the millions/billions.
715        let nf = thousands();
716        assert_eq!(fmt_i64(&nf, 248_956_422), "248,956,422");
717        assert_eq!(fmt_i64(&nf, 3_088_269_832), "3,088,269,832");
718    }
719
720    #[test]
721    fn indian_grouping() {
722        let nf = NumberFormat::preset("indian").unwrap();
723        assert_eq!(fmt_i64(&nf, 100), "100");
724        assert_eq!(fmt_i64(&nf, 1000), "1,000");
725        assert_eq!(fmt_i64(&nf, 12345), "12,345");
726        assert_eq!(fmt_i64(&nf, 123456), "1,23,456");
727        assert_eq!(fmt_i64(&nf, 1234567), "12,34,567");
728        assert_eq!(fmt_i64(&nf, 12345678), "1,23,45,678");
729        assert_eq!(fmt_i64(&nf, -12345678), "-1,23,45,678");
730    }
731
732    #[test]
733    fn all_presets_render() {
734        let cases = [
735            ("none", "1234567"),
736            ("thousands", "1,234,567"),
737            ("european", "1.234.567"),
738            ("si", "1\u{202f}234\u{202f}567"),
739            ("swiss", "1'234'567"),
740            ("indian", "12,34,567"),
741            ("underscore", "1_234_567"),
742        ];
743        for (name, expected) in cases {
744            let nf = NumberFormat::preset(name).unwrap();
745            assert_eq!(fmt_i64(&nf, 1234567), expected, "preset {name}");
746        }
747        assert!(NumberFormat::preset("klingon").is_none());
748        for name in NumberFormat::PRESET_NAMES {
749            assert!(NumberFormat::preset(name).is_some(), "preset {name}");
750        }
751    }
752
753    #[test]
754    fn width_matches_rendered_length_across_range() {
755        for nf in NumberFormat::PRESET_NAMES
756            .iter()
757            .map(|n| NumberFormat::preset(n).unwrap())
758        {
759            let mut v: i64 = 1;
760            for _ in 0..19 {
761                for probe in [v, v - 1, -v, v * 3 / 2] {
762                    let mut s = String::new();
763                    let w = nf.write_i64(probe, &mut s);
764                    assert_eq!(w, s.chars().count(), "{:?} on {probe}", nf.grouping);
765                    assert_eq!(w, nf.width_i64(probe), "{:?} on {probe}", nf.grouping);
766                }
767                v = v.saturating_mul(10);
768            }
769        }
770    }
771
772    #[test]
773    fn floats_regroup_integer_part_only() {
774        let nf = thousands();
775        let mut s = String::new();
776        let w = nf.regroup_decimal("1234567.891", &mut s);
777        assert_eq!(s, "1,234,567.891");
778        assert_eq!(w, s.chars().count());
779
780        s.clear();
781        nf.regroup_decimal("-1234.5", &mut s);
782        assert_eq!(s, "-1,234.5");
783    }
784
785    #[test]
786    fn european_swaps_decimal_separator() {
787        let nf = NumberFormat::preset("european").unwrap();
788        let mut s = String::new();
789        let w = nf.regroup_decimal("1234567.89", &mut s);
790        assert_eq!(s, "1.234.567,89");
791        assert_eq!(w, s.chars().count());
792    }
793
794    #[test]
795    fn non_decimal_strings_pass_through_untouched() {
796        let nf = thousands();
797        for src in ["NaN", "inf", "-inf", "1e300", "1.5e-8", ""] {
798            let mut s = String::new();
799            let w = nf.regroup_decimal(src, &mut s);
800            assert_eq!(s, src, "{src} should pass through");
801            assert_eq!(w, src.chars().count());
802        }
803    }
804
805    #[test]
806    fn float_precision_is_applied() {
807        let nf = NumberFormat {
808            float_precision: Some(2),
809            ..thousands()
810        };
811        let (mut scratch, mut out) = (String::new(), String::new());
812        let w = nf.write_f64(1234.5678, &mut scratch, &mut out);
813        assert_eq!(out, "1,234.57");
814        assert_eq!(w, out.chars().count());
815
816        out.clear();
817        nf.write_f64(-0.5, &mut scratch, &mut out);
818        assert_eq!(out, "-0.50");
819    }
820
821    #[test]
822    fn is_noop_detects_the_free_path() {
823        assert!(NumberFormat::PLAIN.is_noop());
824        assert!(!thousands().is_noop());
825        assert!(
826            !NumberFormat {
827                float_precision: Some(2),
828                ..NumberFormat::PLAIN
829            }
830            .is_noop()
831        );
832        assert!(
833            !NumberFormat {
834                decimal_sep: ',',
835                ..NumberFormat::PLAIN
836            }
837            .is_noop()
838        );
839    }
840
841    #[test]
842    fn chrome_grouping_is_unconditional() {
843        // The app's own labels group regardless of the user's data settings,
844        // and with no digit threshold: "Rows: 1,234" not "Rows: 1234".
845        assert_eq!(group_chrome(0), "0");
846        assert_eq!(group_chrome(999), "999");
847        assert_eq!(group_chrome(1234), "1,234");
848        assert_eq!(group_chrome(1_234_567), "1,234,567");
849        assert_eq!(group_chrome(usize::MAX), "18,446,744,073,709,551,615");
850    }
851
852    #[test]
853    fn glob_matching() {
854        assert!(Glob::new("year").matches("year"));
855        assert!(!Glob::new("year").matches("years"));
856        assert!(Glob::new("*_id").matches("sample_id"));
857        assert!(Glob::new("*_id").matches("_id"));
858        assert!(!Glob::new("*_id").matches("id_sample"));
859        assert!(Glob::new("chrom*").matches("chromStart"));
860        assert!(Glob::new("*").matches("anything"));
861        assert!(Glob::new("c?rom").matches("chrom"));
862        assert!(!Glob::new("c?rom").matches("chhrom"));
863        assert!(Glob::new("a*b*c").matches("axxbyyc"));
864        assert!(!Glob::new("a*b*c").matches("axxbyy"));
865
866        // Wildcard characters appearing literally in the *name*. Found by the
867        // `glob_match` fuzz target: the matcher compared for equality before testing
868        // for `*`, so a `*` in the name consumed the pattern's wildcard and stopped it
869        // wildcarding anything further.
870        assert!(Glob::new("*").matches("*]"));
871        assert!(Glob::new("*").matches("a*b"));
872        assert!(Glob::new("*").matches("?"));
873        assert!(Glob::new("a*c").matches("a*c"));
874        assert!(Glob::new("a*c").matches("a*x*c"));
875        assert!(Glob::new("?").matches("*"));
876    }
877
878    #[test]
879    fn settings_resolve_per_column() {
880        let settings = NumberFormatSettings {
881            format: thousands(),
882            enabled: true,
883            exclude: vec![Glob::new("*_id"), Glob::new("year")],
884            align_numeric_right: true,
885        };
886        // Numeric column: formatted.
887        assert!(
888            !settings
889                .formatter_for("chromStart", &DataType::Int64)
890                .is_passthrough()
891        );
892        // Non-numeric: never formatted.
893        assert!(
894            settings
895                .formatter_for("chrom", &DataType::String)
896                .is_passthrough()
897        );
898        assert!(
899            settings
900                .formatter_for("when", &DataType::Date)
901                .is_passthrough()
902        );
903        // Excluded by glob.
904        assert!(
905            settings
906                .formatter_for("sample_id", &DataType::Int64)
907                .is_passthrough()
908        );
909        assert!(
910            settings
911                .formatter_for("year", &DataType::Int32)
912                .is_passthrough()
913        );
914    }
915
916    #[test]
917    fn settings_disabled_is_all_passthrough() {
918        let settings = NumberFormatSettings {
919            format: thousands(),
920            enabled: false,
921            ..Default::default()
922        };
923        assert!(
924            settings
925                .formatter_for("chromStart", &DataType::Int64)
926                .is_passthrough()
927        );
928    }
929
930    #[test]
931    fn plain_format_is_always_passthrough() {
932        let settings = NumberFormatSettings::default();
933        assert!(
934            settings
935                .formatter_for("chromStart", &DataType::Int64)
936                .is_passthrough()
937        );
938    }
939
940    #[test]
941    fn floats_flag_disables_grouping_for_floats_only() {
942        let settings = NumberFormatSettings {
943            format: NumberFormat {
944                floats: false,
945                ..thousands()
946            },
947            ..Default::default()
948        };
949        assert!(
950            !settings
951                .formatter_for("count", &DataType::Int64)
952                .is_passthrough()
953        );
954        assert!(
955            settings
956                .formatter_for("ratio", &DataType::Float64)
957                .is_passthrough()
958        );
959    }
960
961    #[test]
962    fn any_value_formatting_and_width_agree() {
963        let fmt = CellFormatter::Number(thousands());
964        let mut scratch = String::new();
965        let cases: Vec<AnyValue> = vec![
966            AnyValue::Int32(1234567),
967            AnyValue::Int64(-9876543),
968            AnyValue::UInt32(4000000),
969            AnyValue::UInt64(u64::MAX),
970            AnyValue::Int8(-12),
971        ];
972        for v in cases {
973            let s = format_any_value(&fmt, &v, &mut scratch).into_owned();
974            assert_eq!(
975                display_width(&fmt, &v, &mut scratch),
976                s.chars().count(),
977                "width mismatch for {v:?} -> {s}"
978            );
979        }
980        assert_eq!(
981            format_any_value(&fmt, &AnyValue::Int32(1234567), &mut scratch),
982            "1,234,567"
983        );
984    }
985
986    #[test]
987    fn nulls_and_strings_are_untouched() {
988        let fmt = CellFormatter::Number(thousands());
989        let mut scratch = String::new();
990        assert_eq!(format_any_value(&fmt, &AnyValue::Null, &mut scratch), "");
991        // A string value in a "numeric" formatter still passes through.
992        assert_eq!(
993            format_any_value(&fmt, &AnyValue::String("chr1"), &mut scratch),
994            "chr1"
995        );
996        assert_eq!(
997            format_any_value(
998                &CellFormatter::Passthrough,
999                &AnyValue::Int64(1234567),
1000                &mut scratch
1001            ),
1002            "1234567"
1003        );
1004    }
1005
1006    /// The CLI crate duplicates the preset list to give clap `--help` output and
1007    /// completion, since it cannot depend on this crate. Catch drift here.
1008    #[test]
1009    fn number_format_values_match_presets() {
1010        let mut cli: Vec<&str> = datui_cli::NUMBER_FORMAT_VALUES.to_vec();
1011        let mut expected: Vec<&str> = NumberFormat::PRESET_NAMES.to_vec();
1012        // "system" is CLI/config-only: it resolves to a preset, it is not one.
1013        expected.push("system");
1014        cli.sort_unstable();
1015        expected.sort_unstable();
1016        assert_eq!(
1017            cli, expected,
1018            "datui_cli::NUMBER_FORMAT_VALUES is out of sync with NumberFormat::PRESET_NAMES"
1019        );
1020    }
1021
1022    #[test]
1023    fn locale_tags_map_to_presets() {
1024        assert_eq!(preset_for_locale_tag("en_US.UTF-8"), "thousands");
1025        assert_eq!(preset_for_locale_tag("de_DE.UTF-8"), "european");
1026        assert_eq!(preset_for_locale_tag("de_DE.UTF-8@euro"), "european");
1027        assert_eq!(preset_for_locale_tag("fr_FR"), "si");
1028        assert_eq!(preset_for_locale_tag("hi_IN"), "indian");
1029        assert_eq!(preset_for_locale_tag("de_CH"), "swiss");
1030        assert_eq!(preset_for_locale_tag("it-CH"), "swiss");
1031        assert_eq!(preset_for_locale_tag("ja_JP"), "thousands");
1032        // Unknown tags fall back rather than failing.
1033        assert_eq!(preset_for_locale_tag("xx_YY"), "thousands");
1034    }
1035}