Skip to main content

sqlly_datatable/
format.rs

1//! Pure cell formatting: numbers, dates, strings, booleans, filter matching.
2//!
3//! All functions here are intentionally GPUI-free so they can be reused for
4//! exports, server-side previews, and tests. The widget layer calls
5//! [`format_cell`] on every visible cell during paint and on every
6//! clipboard-copy; tests below document the public behavior.
7
8use crate::config::{
9    DateFormat, NumberFormat, RelativeDateFormat, RelativeUnit, ReplacementRule, ReplacementTiming,
10    ResolvedColumnFormat, StringFormat, TextAlignment, TextCase, TruncationBehavior,
11};
12use crate::data::{CellValue, ColumnKind};
13
14// `std::time::SystemTime::now()` is unimplemented on wasm32-unknown-unknown
15// (it panics), so the web build reads the clock through `web-time`, which has
16// an identical API backed by the JS `Date`.
17#[cfg(not(target_family = "wasm"))]
18use std::time::{SystemTime, UNIX_EPOCH};
19#[cfg(target_family = "wasm")]
20use web_time::{SystemTime, UNIX_EPOCH};
21
22/// Format any cell into the user-visible text plus a "is negative" flag that
23/// lets paint code color it red without re-parsing the text.
24#[must_use]
25pub fn format_cell(value: &CellValue, fmt: &ResolvedColumnFormat) -> (String, bool) {
26    let (text, is_neg) = match (value, &fmt.kind) {
27        (CellValue::Text(s), ColumnKind::Text) => {
28            let s = if fmt.replacement_timing == ReplacementTiming::BeforeFormat {
29                apply_replacements(s, &fmt.replacements)
30            } else {
31                s.clone()
32            };
33            (format_string(&s, &fmt.string), false)
34        }
35        (CellValue::Integer(v), ColumnKind::Integer) => (format_integer(*v, &fmt.number), *v < 0),
36        (CellValue::Decimal(v), ColumnKind::Decimal) => (format_number(*v, &fmt.number), *v < 0.0),
37        (CellValue::Integer(v), ColumnKind::Decimal) => {
38            (format_integer_as_decimal(*v, &fmt.number), *v < 0)
39        }
40        (CellValue::Decimal(v), ColumnKind::Integer) => (format_number(*v, &fmt.number), *v < 0.0),
41        (CellValue::Date(ts), ColumnKind::Date) => (format_date(*ts, &fmt.date), false),
42        (CellValue::Boolean(b), ColumnKind::Boolean) => (format_boolean(*b, &fmt.boolean), false),
43        (CellValue::None, _) => (String::new(), false),
44        (CellValue::Text(s), _) => (s.clone(), false),
45        (CellValue::Integer(v), _) => (v.to_string(), *v < 0),
46        (CellValue::Decimal(v), _) => (v.to_string(), *v < 0.0),
47        (CellValue::Date(ts), _) => (format_date(*ts, &fmt.date), false),
48        (CellValue::Boolean(b), _) => (format_boolean(*b, &fmt.boolean), false),
49    };
50
51    let text = if fmt.replacement_timing == ReplacementTiming::AfterFormat {
52        apply_replacements(&text, &fmt.replacements)
53    } else {
54        text
55    };
56
57    (text, is_neg)
58}
59
60/// Format a `CellValue::Integer` against a [`NumberFormat`] without first
61/// casting through `f64`. This preserves full `i64` precision for values
62/// larger than `2^53`.
63#[must_use]
64pub fn format_integer(value: i64, fmt: &NumberFormat) -> String {
65    if fmt.decimals == 0 {
66        let raw = value.unsigned_abs().to_string();
67        let with_sep = if fmt.thousands_separator {
68            add_thousands_separator(&raw)
69        } else {
70            raw
71        };
72        if value < 0 {
73            if fmt.negative_parentheses {
74                format!("({with_sep})")
75            } else {
76                format!("-{with_sep}")
77            }
78        } else {
79            with_sep
80        }
81    } else {
82        // Decimals require a fractional part; route through `format_number`.
83        // We accept the f64 round-trip because the user explicitly asked for
84        // fractional display.
85        format_number(value as f64, fmt)
86    }
87}
88
89fn format_integer_as_decimal(value: i64, fmt: &NumberFormat) -> String {
90    if fmt.decimals == 0 {
91        format_integer(value, fmt)
92    } else {
93        format_number(value as f64, fmt)
94    }
95}
96
97/// Format a `f64` against a [`NumberFormat`]. Negative formatting
98/// (parentheses vs leading minus) and thousands separators are driven by the
99/// format options.
100#[must_use]
101pub fn format_number(value: f64, fmt: &NumberFormat) -> String {
102    let abs = value.abs();
103    let num_str = format!("{abs:.*}", fmt.decimals);
104    let with_sep = if fmt.thousands_separator {
105        add_thousands_separator(&num_str)
106    } else {
107        num_str
108    };
109    if value < 0.0 {
110        if fmt.negative_parentheses {
111            format!("({with_sep})")
112        } else {
113            format!("-{with_sep}")
114        }
115    } else {
116        with_sep
117    }
118}
119
120fn add_thousands_separator(s: &str) -> String {
121    let (int_part, dec_part) = match s.split_once('.') {
122        Some((i, d)) => (i, format!(".{d}")),
123        None => (s, String::new()),
124    };
125    let chars: Vec<char> = int_part.chars().collect();
126    let mut result = String::new();
127    let len = chars.len();
128    for (i, c) in chars.iter().enumerate() {
129        if i > 0 && (len - i).is_multiple_of(3) {
130            result.push(',');
131        }
132        result.push(*c);
133    }
134    format!("{result}{dec_part}")
135}
136
137/// Format a Unix timestamp (seconds). When `fmt.relative` is set, the result
138/// is a "2 days ago" / "in 3 weeks" string relative to `SystemTime::now()`;
139/// use [`format_date_at`] to inject a frozen clock for tests.
140#[must_use]
141pub fn format_date(ts: i64, fmt: &DateFormat) -> String {
142    let now = current_unix_seconds();
143    format_date_at(ts, now, fmt)
144}
145
146/// Same as [`format_date`] but with an explicit `now` timestamp so tests can
147/// pin the relative-date output to a known clock.
148#[must_use]
149pub fn format_date_at(ts: i64, now: i64, fmt: &DateFormat) -> String {
150    let adjusted_ts = ts + i64::from(fmt.timezone_offset_minutes) * 60;
151    if let Some(relative) = &fmt.relative {
152        let adjusted_now = now + i64::from(fmt.timezone_offset_minutes) * 60;
153        return format_relative_date(adjusted_ts, adjusted_now, relative);
154    }
155    format_date_str(adjusted_ts, &fmt.format)
156}
157
158fn current_unix_seconds() -> i64 {
159    SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .map_or(0, |d| d.as_secs() as i64)
162}
163
164fn format_date_str(ts: i64, format: &str) -> String {
165    let (year, month, day, hour, min, sec) = timestamp_to_components(ts);
166    format
167        .replace("%Y", &format!("{year:04}"))
168        .replace("%m", &format!("{month:02}"))
169        .replace("%d", &format!("{day:02}"))
170        .replace("%H", &format!("{hour:02}"))
171        .replace("%M", &format!("{min:02}"))
172        .replace("%S", &format!("{sec:02}"))
173        .replace("%y", &format!("{:02}", year.rem_euclid(100)))
174        .replace("%B", &month_name(month))
175        .replace("%b", &month_name(month)[..3.min(month_name(month).len())])
176        .replace("%A", &day_name(ts))
177        .replace("%a", &day_name(ts)[..3.min(day_name(ts).len())])
178}
179
180#[must_use]
181pub fn format_relative_date(ts: i64, now: i64, relative: &RelativeDateFormat) -> String {
182    let diff = ts - now;
183    if diff == 0 {
184        return "now".into();
185    }
186    let abs_diff = diff.unsigned_abs();
187    let components = break_down_duration(abs_diff, &relative.units);
188    let parts: Vec<String> = components
189        .iter()
190        .take(relative.max_components)
191        .map(|(unit, count)| format!("{} {}", count, unit_name(unit, *count)))
192        .collect();
193    if parts.is_empty() {
194        return "now".into();
195    }
196    let joined = parts.join(" and ");
197    if diff > 0 {
198        format!("in {joined}")
199    } else {
200        format!("{joined} ago")
201    }
202}
203
204fn break_down_duration(seconds: u64, units: &[RelativeUnit]) -> Vec<(RelativeUnit, u64)> {
205    let mut remaining = seconds;
206    let mut result = vec![];
207    let ordered = order_units_desc(units);
208    for unit in ordered {
209        let size = unit_seconds(unit);
210        if size > 0 && remaining >= size {
211            let count = remaining / size;
212            remaining %= size;
213            result.push((unit, count));
214        }
215    }
216    result
217}
218
219fn order_units_desc(units: &[RelativeUnit]) -> Vec<RelativeUnit> {
220    let all = [
221        RelativeUnit::Year,
222        RelativeUnit::Month,
223        RelativeUnit::Week,
224        RelativeUnit::Day,
225        RelativeUnit::Hour,
226        RelativeUnit::Minute,
227        RelativeUnit::Second,
228    ];
229    all.iter().copied().filter(|u| units.contains(u)).collect()
230}
231
232fn unit_seconds(unit: RelativeUnit) -> u64 {
233    match unit {
234        RelativeUnit::Year => 31_557_600,
235        RelativeUnit::Month => 2_630_016,
236        RelativeUnit::Week => 604_800,
237        RelativeUnit::Day => 86_400,
238        RelativeUnit::Hour => 3_600,
239        RelativeUnit::Minute => 60,
240        RelativeUnit::Second => 1,
241    }
242}
243
244fn unit_name(unit: &RelativeUnit, count: u64) -> &'static str {
245    match unit {
246        RelativeUnit::Year => {
247            if count == 1 {
248                "year"
249            } else {
250                "years"
251            }
252        }
253        RelativeUnit::Month => {
254            if count == 1 {
255                "month"
256            } else {
257                "months"
258            }
259        }
260        RelativeUnit::Week => {
261            if count == 1 {
262                "week"
263            } else {
264                "weeks"
265            }
266        }
267        RelativeUnit::Day => {
268            if count == 1 {
269                "day"
270            } else {
271                "days"
272            }
273        }
274        RelativeUnit::Hour => {
275            if count == 1 {
276                "hour"
277            } else {
278                "hours"
279            }
280        }
281        RelativeUnit::Minute => {
282            if count == 1 {
283                "minute"
284            } else {
285                "minutes"
286            }
287        }
288        RelativeUnit::Second => {
289            if count == 1 {
290                "second"
291            } else {
292                "seconds"
293            }
294        }
295    }
296}
297
298fn format_boolean(b: bool, fmt: &crate::config::BooleanFormat) -> String {
299    if b {
300        fmt.true_text.clone()
301    } else {
302        fmt.false_text.clone()
303    }
304}
305
306/// Format text according to a [`StringFormat`]: case, length, truncation.
307#[must_use]
308pub fn format_string(s: &str, fmt: &StringFormat) -> String {
309    let cased = match fmt.case {
310        TextCase::Upper => s.to_uppercase(),
311        TextCase::Lower => s.to_lowercase(),
312        TextCase::Title => title_case(s),
313        TextCase::None => s.to_owned(),
314    };
315    match fmt.max_length {
316        Some(max) if cased.chars().count() > max => truncate_chars(&cased, max, fmt.truncation),
317        _ => cased,
318    }
319}
320
321fn truncate_chars(s: &str, max: usize, mode: TruncationBehavior) -> String {
322    let truncated: String = s.chars().take(max).collect();
323    match mode {
324        TruncationBehavior::Ellipsis if max >= 3 => {
325            let mut t: String = s.chars().take(max - 3).collect();
326            t.push_str("...");
327            t
328        }
329        TruncationBehavior::Ellipsis => truncated,
330        TruncationBehavior::CutOff | TruncationBehavior::Wrap => truncated,
331    }
332}
333
334fn title_case(s: &str) -> String {
335    s.split_whitespace()
336        .map(|w| {
337            let mut c = w.chars();
338            match c.next() {
339                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
340                None => String::new(),
341            }
342        })
343        .collect::<Vec<_>>()
344        .join(" ")
345}
346
347fn apply_replacements(s: &str, rules: &[ReplacementRule]) -> String {
348    let mut result = s.to_owned();
349    for rule in rules {
350        result = result.replace(&rule.find, &rule.replace);
351    }
352    result
353}
354
355fn timestamp_to_components(ts: i64) -> (i32, u32, u32, u32, u32, u32) {
356    let days = ts.div_euclid(86_400);
357    let secs = ts.rem_euclid(86_400) as u32;
358    let hour = secs / 3600;
359    let min = (secs % 3600) / 60;
360    let sec = secs % 60;
361    let (year, month, day) = days_to_ymd(days);
362    (year, month, day, hour, min, sec)
363}
364
365fn days_to_ymd(days: i64) -> (i32, u32, u32) {
366    let z = days + 719_468;
367    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
368    let doe = (z - era * 146_097) as u32;
369    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
370    let y = yoe as i32 + (era as i32) * 400;
371    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
372    let mp = (5 * doy + 2) / 153;
373    let d = doy - (153 * mp + 2) / 5 + 1;
374    let m = if mp < 10 { mp + 3 } else { mp - 9 };
375    let year = if m <= 2 { y + 1 } else { y };
376    (year, m, d)
377}
378
379fn month_name(m: u32) -> String {
380    match m {
381        1 => "January".into(),
382        2 => "February".into(),
383        3 => "March".into(),
384        4 => "April".into(),
385        5 => "May".into(),
386        6 => "June".into(),
387        7 => "July".into(),
388        8 => "August".into(),
389        9 => "September".into(),
390        10 => "October".into(),
391        11 => "November".into(),
392        12 => "December".into(),
393        _ => "Unknown".into(),
394    }
395}
396
397fn day_name(ts: i64) -> String {
398    let day_of_week = (ts.div_euclid(86_400) + 4).rem_euclid(7) as u32;
399    match day_of_week {
400        0 => "Sunday".into(),
401        1 => "Monday".into(),
402        2 => "Tuesday".into(),
403        3 => "Wednesday".into(),
404        4 => "Thursday".into(),
405        5 => "Friday".into(),
406        6 => "Saturday".into(),
407        _ => "Unknown".into(),
408    }
409}
410
411/// Case-insensitive substring filter against the user-visible rendered text.
412/// Empty filter always matches.
413#[must_use]
414pub fn cell_matches_filter(value: &CellValue, fmt: &ResolvedColumnFormat, filter: &str) -> bool {
415    if filter.is_empty() {
416        return true;
417    }
418    let (formatted, _) = format_cell(value, fmt);
419    formatted.to_lowercase().contains(&filter.to_lowercase())
420}
421
422#[must_use]
423pub fn alignment_for(fmt: &ResolvedColumnFormat) -> TextAlignment {
424    fmt.alignment()
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::config::{BooleanFormat, StringFormat};
431    use crate::data::{Column, ColumnKind};
432    use std::cell::Cell;
433
434    fn plain_resolved(kind: ColumnKind) -> ResolvedColumnFormat {
435        ResolvedColumnFormat {
436            kind,
437            number: NumberFormat::default(),
438            date: DateFormat::default(),
439            boolean: BooleanFormat::default(),
440            string: StringFormat::default(),
441            null: crate::config::NullFormat::default(),
442            replacements: vec![],
443            replacement_timing: ReplacementTiming::AfterFormat,
444        }
445    }
446
447    #[test]
448    fn format_integer_preserves_precision_above_2_pow_53() {
449        // Value that loses exactness through f64; format_integer must keep it.
450        let big = 9_007_199_254_740_993_i64;
451        let fmt = NumberFormat {
452            decimals: 0,
453            thousands_separator: false,
454            ..NumberFormat::default()
455        };
456        let s = format_integer(big, &fmt);
457        assert_eq!(s, "9007199254740993");
458    }
459
460    #[test]
461    fn format_integer_with_separators() {
462        let fmt = NumberFormat {
463            decimals: 0,
464            thousands_separator: true,
465            ..NumberFormat::default()
466        };
467        assert_eq!(format_integer(1_234_567, &fmt), "1,234,567");
468        assert_eq!(format_integer(-1_234_567, &fmt), "-1,234,567");
469    }
470
471    #[test]
472    fn format_integer_with_parentheses() {
473        let fmt = NumberFormat {
474            decimals: 0,
475            negative_parentheses: true,
476            ..NumberFormat::default()
477        };
478        assert_eq!(format_integer(-42, &fmt), "(42)");
479    }
480
481    /// Color-blind safety invariant: a negative value must always carry a
482    /// non-color sign channel in its text (leading `-` or `( … )`), whether or
483    /// not the decorative red fill is enabled. A red/green-color-blind reader
484    /// relies entirely on this glyph, so it can never be gated behind
485    /// `show_negative_red`. Guards WCAG 1.4.1 (use of color) for every
486    /// negative-styling combination, across integer and decimal paths.
487    #[test]
488    fn negatives_always_carry_a_non_color_sign_channel() {
489        for red in [false, true] {
490            for parens in [false, true] {
491                let fmt = NumberFormat {
492                    decimals: 2,
493                    show_negative_red: red,
494                    negative_parentheses: parens,
495                    ..NumberFormat::default()
496                };
497                let signed =
498                    |s: &str| s.starts_with('-') || (s.starts_with('(') && s.ends_with(')'));
499                for dec in [format_number(-1_493.17, &fmt), format_number(-0.01, &fmt)] {
500                    assert!(
501                        signed(&dec),
502                        "decimal negative lacks sign channel: {dec:?} (red={red}, parens={parens})"
503                    );
504                }
505                let int = format_integer(
506                    -42,
507                    &NumberFormat {
508                        decimals: 0,
509                        show_negative_red: red,
510                        negative_parentheses: parens,
511                        ..NumberFormat::default()
512                    },
513                );
514                assert!(
515                    signed(&int),
516                    "integer negative lacks sign channel: {int:?} (red={red}, parens={parens})"
517                );
518            }
519        }
520    }
521
522    #[test]
523    fn format_number_negative_zero_path_does_not_panic() {
524        let fmt = NumberFormat::default();
525        assert_eq!(format_number(-0.0, &fmt), "0.00");
526    }
527
528    /// Hostile numeric input must never panic the formatter — `NaN` and the
529    /// infinities flow straight from SQL sources (0.0/0.0 aggregates, etc.).
530    #[test]
531    fn format_number_nan_and_infinity_do_not_panic() {
532        for sep in [false, true] {
533            for parens in [false, true] {
534                let fmt = NumberFormat {
535                    decimals: 2,
536                    thousands_separator: sep,
537                    negative_parentheses: parens,
538                    ..NumberFormat::default()
539                };
540                assert_eq!(format_number(f64::NAN, &fmt), "NaN");
541                assert_eq!(format_number(f64::INFINITY, &fmt), "inf");
542                let neg_inf = format_number(f64::NEG_INFINITY, &fmt);
543                assert!(
544                    neg_inf == "-inf" || neg_inf == "(inf)",
545                    "negative infinity renders with its sign channel: {neg_inf}"
546                );
547            }
548        }
549    }
550
551    #[test]
552    fn format_number_thousands_separator_with_decimals() {
553        let fmt = NumberFormat {
554            decimals: 2,
555            thousands_separator: true,
556            ..NumberFormat::default()
557        };
558        assert_eq!(format_number(1_234_567.89, &fmt), "1,234,567.89");
559    }
560
561    #[test]
562    fn format_string_truncates_on_chars_not_bytes() {
563        // Emoji is 4 bytes but 1 char. Truncation at char boundary must not panic.
564        let fmt = StringFormat {
565            max_length: Some(3),
566            truncation: TruncationBehavior::Ellipsis,
567            ..StringFormat::default()
568        };
569        // Six emoji => 6 chars; truncation must keep the budget.
570        let out = format_string(
571            "\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{1F600}",
572            &fmt,
573        );
574        assert_eq!(out, "...");
575        assert_eq!(out.chars().count(), 3);
576
577        // Longer budget keeps content + ellipsis.
578        let fmt = StringFormat {
579            max_length: Some(5),
580            truncation: TruncationBehavior::Ellipsis,
581            ..StringFormat::default()
582        };
583        let out = format_string(
584            "\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{1F600}",
585            &fmt,
586        );
587        assert_eq!(out, "\u{1F600}\u{1F600}...");
588    }
589
590    #[test]
591    fn format_string_truncation_modes() {
592        let cases = [
593            (TruncationBehavior::Ellipsis, "ab..."),
594            (TruncationBehavior::CutOff, "abcde"),
595            (TruncationBehavior::Wrap, "abcde"),
596        ];
597        for (mode, expected) in cases {
598            let fmt = StringFormat {
599                max_length: Some(5),
600                truncation: mode,
601                ..StringFormat::default()
602            };
603            assert_eq!(format_string("abcdefgh", &fmt), expected);
604        }
605    }
606
607    #[test]
608    fn format_string_case() {
609        let fmt = StringFormat {
610            case: TextCase::Upper,
611            ..StringFormat::default()
612        };
613        assert_eq!(format_string("hello", &fmt), "HELLO");
614        let fmt = StringFormat {
615            case: TextCase::Lower,
616            ..StringFormat::default()
617        };
618        assert_eq!(format_string("HELLO", &fmt), "hello");
619        let fmt = StringFormat {
620            case: TextCase::Title,
621            ..StringFormat::default()
622        };
623        assert_eq!(format_string("hello world", &fmt), "Hello World");
624    }
625
626    #[test]
627    fn format_relative_date_with_frozen_clock() {
628        thread_local!(static NOW: Cell<i64> = const { Cell::new(0) });
629    }
630
631    #[test]
632    fn format_relative_date_past_and_future() {
633        let relative = RelativeDateFormat {
634            units: vec![RelativeUnit::Day, RelativeUnit::Hour, RelativeUnit::Second],
635            max_components: 2,
636        };
637        let now = 1_700_000_000;
638        assert_eq!(
639            format_relative_date(now - 86_400, now, &relative),
640            "1 day ago",
641        );
642        assert_eq!(
643            format_relative_date(now - (86_400 + 3600), now, &relative),
644            "1 day and 1 hour ago",
645        );
646        assert_eq!(format_relative_date(now, now, &relative), "now");
647        assert_eq!(
648            format_relative_date(now + 86_400, now, &relative),
649            "in 1 day",
650        );
651    }
652
653    #[test]
654    fn format_date_supports_all_documented_tokens() {
655        let fmt = DateFormat {
656            format: "%Y-%m-%d %H:%M:%S %y %B %b %A %a".into(),
657            ..DateFormat::default()
658        };
659        // 2024-01-01 00:00:00 UTC == 1_704_067_200
660        let out = format_date_at(1_704_067_200, 1_704_067_200, &fmt);
661        assert!(out.contains("2024"), "{out}");
662        assert!(out.contains("January"), "{out}");
663        assert!(out.contains("Jan"), "{out}");
664        assert!(out.contains("Monday"), "{out}");
665        assert!(out.contains("Mon"), "{out}");
666    }
667
668    #[test]
669    fn format_date_2_digit_year_handles_centuries() {
670        let fmt = DateFormat {
671            format: "%y".into(),
672            ..DateFormat::default()
673        };
674        assert_eq!(format_date_at(1_704_067_200, 0, &fmt), "24");
675    }
676
677    #[test]
678    fn cell_matches_filter_is_case_insensitive() {
679        let fmt = plain_resolved(ColumnKind::Text);
680        assert!(cell_matches_filter(
681            &CellValue::Text("Hello".into()),
682            &fmt,
683            "ELL"
684        ));
685        assert!(cell_matches_filter(
686            &CellValue::Text("Hello".into()),
687            &fmt,
688            ""
689        ));
690        assert!(!cell_matches_filter(
691            &CellValue::Text("Hello".into()),
692            &fmt,
693            "zzz"
694        ));
695    }
696
697    #[test]
698    fn cell_matches_filter_uses_formatted_value_for_numbers() {
699        let fmt = plain_resolved(ColumnKind::Decimal);
700        assert!(cell_matches_filter(
701            &CellValue::Decimal(1234.5),
702            &fmt,
703            "1,234"
704        ));
705        // Default decimal formatting emits a leading minus, not parentheses.
706        assert!(cell_matches_filter(
707            &CellValue::Decimal(-5.0),
708            &fmt,
709            "-5.00"
710        ));
711    }
712
713    #[test]
714    fn resolve_resolves_for_columns() {
715        let cols = vec![
716            Column::new("a", ColumnKind::Text, 80.0),
717            Column::new("b", ColumnKind::Decimal, 100.0),
718        ];
719        let cfg = crate::config::GridConfig::default();
720        let resolved = cfg.resolve_all(&cols);
721        assert_eq!(resolved.len(), 2);
722        assert_eq!(resolved[0].kind, ColumnKind::Text);
723        assert_eq!(resolved[1].kind, ColumnKind::Decimal);
724    }
725}