1use 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#[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#[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 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#[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#[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#[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#[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#[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 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 let fmt = StringFormat {
494 max_length: Some(3),
495 truncation: TruncationBehavior::Ellipsis,
496 ..StringFormat::default()
497 };
498 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 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 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 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}