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