1use crate::config::{
9 DateFormat, NumberFormat, RelativeDateFormat, RelativeUnit, ReplacementRule, ReplacementTiming,
10 ResolvedColumnFormat, StringFormat, TextAlignment, TextCase, TruncationBehavior,
11};
12use crate::data::{CellValue, ColumnKind};
13
14#[cfg(not(target_family = "wasm"))]
18use std::time::{SystemTime, UNIX_EPOCH};
19#[cfg(target_family = "wasm")]
20use web_time::{SystemTime, UNIX_EPOCH};
21
22#[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#[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 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#[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#[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#[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#[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#[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 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 #[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 #[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 let fmt = StringFormat {
565 max_length: Some(3),
566 truncation: TruncationBehavior::Ellipsis,
567 ..StringFormat::default()
568 };
569 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 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 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 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}