1use std::cmp::Ordering;
16
17#[derive(Clone, Debug, Default, PartialEq)]
22pub enum CellValue {
23 Text(String),
26 Integer(i64),
28 Decimal(f64),
31 Date(i64),
34 Boolean(bool),
36 #[default]
39 None,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
45pub enum ColumnKind {
46 Text,
48 Integer,
50 Decimal,
52 Date,
54 Boolean,
56 None,
58}
59
60#[derive(Clone, Debug, PartialEq)]
62pub struct Column {
63 pub name: String,
65 pub kind: ColumnKind,
67 pub width: f32,
69}
70
71impl Column {
72 #[must_use]
74 pub fn new(name: impl Into<String>, kind: ColumnKind, width: f32) -> Self {
75 Self {
76 name: name.into(),
77 kind,
78 width,
79 }
80 }
81}
82
83#[derive(Clone, Debug)]
88pub struct GridData {
89 pub columns: Vec<Column>,
91 pub rows: Vec<Vec<CellValue>>,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum GridDataError {
99 RaggedRow {
101 row_index: usize,
103 expected: usize,
105 actual: usize,
107 },
108}
109
110impl std::fmt::Display for GridDataError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 GridDataError::RaggedRow {
114 row_index,
115 expected,
116 actual,
117 } => write!(
118 f,
119 "row {row_index} has {actual} cells but {expected} were expected"
120 ),
121 }
122 }
123}
124
125impl std::error::Error for GridDataError {}
126
127impl GridData {
128 pub fn new(columns: Vec<Column>, rows: Vec<Vec<CellValue>>) -> Result<Self, GridDataError> {
135 let data = Self { columns, rows };
136 data.validate()?;
137 Ok(data)
138 }
139
140 pub fn validate(&self) -> Result<(), GridDataError> {
147 let expected = self.columns.len();
148 for (row_index, row) in self.rows.iter().enumerate() {
149 if row.len() != expected {
150 return Err(GridDataError::RaggedRow {
151 row_index,
152 expected,
153 actual: row.len(),
154 });
155 }
156 }
157 Ok(())
158 }
159
160 #[must_use]
163 pub fn cell(&self, row: usize, col: usize) -> Option<&CellValue> {
164 self.rows.get(row).and_then(|r| r.get(col))
165 }
166
167 #[must_use]
170 pub fn row_count(&self) -> usize {
171 self.rows.len()
172 }
173
174 #[must_use]
177 pub fn column_count(&self) -> usize {
178 self.columns.len()
179 }
180
181 #[must_use]
184 pub fn column_index(&self, name: &str) -> Option<usize> {
185 self.columns.iter().position(|col| col.name == name)
186 }
187}
188
189impl From<&str> for CellValue {
190 fn from(s: &str) -> Self {
191 CellValue::Text(s.to_owned())
192 }
193}
194
195impl From<String> for CellValue {
196 fn from(s: String) -> Self {
197 CellValue::Text(s)
198 }
199}
200
201impl From<i64> for CellValue {
202 fn from(v: i64) -> Self {
203 CellValue::Integer(v)
204 }
205}
206
207impl From<i32> for CellValue {
208 fn from(v: i32) -> Self {
209 CellValue::Integer(v.into())
210 }
211}
212
213impl From<f64> for CellValue {
214 fn from(v: f64) -> Self {
215 CellValue::Decimal(v)
216 }
217}
218
219impl From<bool> for CellValue {
220 fn from(v: bool) -> Self {
221 CellValue::Boolean(v)
222 }
223}
224
225impl From<Option<CellValue>> for CellValue {
226 fn from(v: Option<CellValue>) -> Self {
227 v.unwrap_or(CellValue::None)
228 }
229}
230
231#[must_use]
247pub fn compare_cells(a: &CellValue, b: &CellValue) -> Ordering {
248 match (a, b) {
249 (CellValue::None, CellValue::None) => Ordering::Equal,
250 (CellValue::None, _) => Ordering::Less,
251 (_, CellValue::None) => Ordering::Greater,
252
253 (CellValue::Integer(x), CellValue::Integer(y)) => x.cmp(y),
254 (CellValue::Decimal(x), CellValue::Decimal(y)) => x.total_cmp(y),
255 (CellValue::Integer(x), CellValue::Decimal(y)) => (*x as f64).total_cmp(y),
256 (CellValue::Decimal(x), CellValue::Integer(y)) => x.total_cmp(&(*y as f64)),
257
258 (CellValue::Text(x), CellValue::Text(y)) => x.cmp(y),
259 (CellValue::Date(x), CellValue::Date(y)) => x.cmp(y),
260 (CellValue::Boolean(x), CellValue::Boolean(y)) => x.cmp(y),
261
262 (left, right) => type_rank(left).cmp(&type_rank(right)),
263 }
264}
265
266fn type_rank(value: &CellValue) -> u8 {
269 match value {
270 CellValue::None => 0,
271 CellValue::Boolean(_) => 1,
272 CellValue::Integer(_) | CellValue::Decimal(_) => 2,
273 CellValue::Date(_) => 3,
274 CellValue::Text(_) => 4,
275 }
276}
277
278#[must_use]
282pub fn sample_data() -> GridData {
283 use CellValue::{Boolean as B, Decimal as D, Integer as I, None as N, Text as T};
284 use ColumnKind::*;
285
286 let columns = vec![
287 Column::new("JournalLineId", Integer, 120.0),
288 Column::new("TenantId", Integer, 100.0),
289 Column::new("JournalId", Integer, 110.0),
290 Column::new("FinancialAccountingKeyId", Integer, 200.0),
291 Column::new("ExtendedFinancialAccountingKeyId", Integer, 240.0),
292 Column::new("TransactionCurrencyAmount", Decimal, 200.0),
293 Column::new("JurisdictionalCurrencyAmount", Decimal, 200.0),
294 Column::new("ReportingCurrencyAmount", Decimal, 200.0),
295 Column::new("Sequence", Integer, 100.0),
296 Column::new("TransPart", Boolean, 110.0),
297 Column::new("ReferenceTypeId", Integer, 140.0), Column::new("ReferenceEntityId", Integer, 150.0), Column::new("InternalReference", Text, 160.0),
300 Column::new("CounterPartyReference", Text, 180.0),
301 Column::new("Narrative", Text, 270.0),
302 Column::new("CurrencyId", Integer, 110.0),
303 Column::new("IsCleared", Boolean, 110.0),
304 ];
305
306 let row = |id: i64,
307 ta: i64,
308 ja: i64,
309 fa: i64,
310 ea: i64,
311 tx: i64,
312 jx: i64,
313 rx: i64,
314 sq: i64,
315 pa: bool,
316 rt: Option<i64>,
317 re: Option<i64>,
318 ir: &str,
319 cr: Option<&str>,
320 na: &str,
321 ci: i64,
322 cl: bool| {
323 vec![
324 I(id),
325 I(ta),
326 I(ja),
327 I(fa),
328 I(ea),
329 D(tx as f64),
330 D(jx as f64),
331 D(rx as f64),
332 I(sq),
333 B(pa),
334 rt.map(I).unwrap_or(N),
335 re.map(I).unwrap_or(N),
336 T(ir.into()),
337 cr.map(|s| T(s.into())).unwrap_or(N),
338 T(na.into()),
339 I(ci),
340 B(cl),
341 ]
342 };
343
344 let rows = vec![
345 row(
346 1096,
347 1,
348 148,
349 33,
350 528,
351 17968,
352 17968,
353 485,
354 0,
355 false,
356 Option::None,
357 Option::None,
358 "tomar 1",
359 Option::None,
360 "saldo de apertura de carga",
361 1,
362 false,
363 ),
364 row(
365 1097,
366 1,
367 148,
368 33,
369 530,
370 717,
371 717,
372 19,
373 1,
374 false,
375 Option::None,
376 Option::None,
377 "tomar 1",
378 Option::None,
379 "saldo de apertura de carga",
380 1,
381 false,
382 ),
383 row(
384 1098,
385 1,
386 148,
387 33,
388 532,
389 768,
390 768,
391 20,
392 2,
393 false,
394 Option::None,
395 Option::None,
396 "tomar 1",
397 Option::None,
398 "saldo de apertura de carga",
399 1,
400 false,
401 ),
402 row(
403 1099,
404 1,
405 148,
406 33,
407 533,
408 1141,
409 1141,
410 30,
411 3,
412 false,
413 Option::None,
414 Option::None,
415 "tomar 1",
416 Option::None,
417 "saldo de apertura de carga",
418 1,
419 false,
420 ),
421 row(
422 1100,
423 1,
424 148,
425 33,
426 536,
427 1937,
428 1937,
429 52,
430 4,
431 false,
432 Option::None,
433 Option::None,
434 "tomar 1",
435 Option::None,
436 "saldo de apertura de carga",
437 1,
438 false,
439 ),
440 row(
441 1101,
442 1,
443 148,
444 33,
445 538,
446 1018,
447 1018,
448 27,
449 5,
450 false,
451 Option::None,
452 Option::None,
453 "tomar 1",
454 Option::None,
455 "saldo de apertura de carga",
456 1,
457 false,
458 ),
459 row(
460 1102,
461 1,
462 148,
463 33,
464 542,
465 3172,
466 3172,
467 85,
468 6,
469 false,
470 Option::None,
471 Option::None,
472 "tomar 1",
473 Option::None,
474 "saldo de apertura de carga",
475 1,
476 false,
477 ),
478 row(
479 1103,
480 1,
481 148,
482 33,
483 544,
484 1640,
485 1640,
486 44,
487 7,
488 false,
489 Option::None,
490 Option::None,
491 "tomar 1",
492 Option::None,
493 "saldo de apertura de carga",
494 1,
495 false,
496 ),
497 row(
498 1104,
499 1,
500 148,
501 33,
502 546,
503 809,
504 809,
505 21,
506 8,
507 false,
508 Option::None,
509 Option::None,
510 "tomar 1",
511 Option::None,
512 "saldo de apertura de carga",
513 1,
514 false,
515 ),
516 row(
517 1105,
518 1,
519 148,
520 33,
521 573,
522 67,
523 67,
524 1,
525 9,
526 false,
527 Option::None,
528 Option::None,
529 "tomar 1",
530 Option::None,
531 "saldo de apertura de carga",
532 1,
533 false,
534 ),
535 row(
536 1106,
537 1,
538 148,
539 33,
540 574,
541 20,
542 20,
543 0,
544 10,
545 false,
546 Option::None,
547 Option::None,
548 "tomar 1",
549 Option::None,
550 "saldo de apertura de carga",
551 1,
552 false,
553 ),
554 row(
555 1107,
556 1,
557 148,
558 33,
559 575,
560 70,
561 70,
562 1,
563 11,
564 false,
565 Option::None,
566 Option::None,
567 "tomar 1",
568 Option::None,
569 "saldo de apertura de carga",
570 1,
571 false,
572 ),
573 row(
574 1108,
575 1,
576 148,
577 33,
578 576,
579 29,
580 29,
581 0,
582 12,
583 false,
584 Option::None,
585 Option::None,
586 "tomar 1",
587 Option::None,
588 "saldo de apertura de carga",
589 1,
590 false,
591 ),
592 row(
593 1109,
594 1,
595 148,
596 33,
597 577,
598 35,
599 35,
600 0,
601 13,
602 false,
603 Option::None,
604 Option::None,
605 "tomar 1",
606 Option::None,
607 "saldo de apertura de carga",
608 1,
609 false,
610 ),
611 row(
612 1110,
613 1,
614 148,
615 33,
616 578,
617 283,
618 283,
619 7,
620 14,
621 false,
622 Option::None,
623 Option::None,
624 "tomar 1",
625 Option::None,
626 "saldo de apertura de carga",
627 1,
628 false,
629 ),
630 row(
631 1111,
632 1,
633 148,
634 33,
635 579,
636 200,
637 200,
638 5,
639 15,
640 false,
641 Option::None,
642 Option::None,
643 "tomar 1",
644 Option::None,
645 "saldo de apertura de carga",
646 1,
647 false,
648 ),
649 row(
650 1112,
651 1,
652 148,
653 33,
654 580,
655 1140,
656 1140,
657 30,
658 16,
659 false,
660 Option::None,
661 Option::None,
662 "tomar 1",
663 Option::None,
664 "saldo de apertura de carga",
665 1,
666 false,
667 ),
668 row(
669 1113,
670 1,
671 148,
672 33,
673 581,
674 117,
675 117,
676 3,
677 17,
678 false,
679 Option::None,
680 Option::None,
681 "tomar 1",
682 Option::None,
683 "saldo de apertura de carga",
684 1,
685 false,
686 ),
687 row(
688 1114,
689 1,
690 148,
691 33,
692 582,
693 366,
694 366,
695 9,
696 18,
697 false,
698 Option::None,
699 Option::None,
700 "tomar 1",
701 Option::None,
702 "saldo de apertura de carga",
703 1,
704 false,
705 ),
706 row(
707 1115,
708 1,
709 148,
710 33,
711 603,
712 241,
713 241,
714 6,
715 19,
716 false,
717 Option::None,
718 Option::None,
719 "tomar 1",
720 Option::None,
721 "saldo de apertura de carga",
722 1,
723 false,
724 ),
725 row(
726 1116,
727 1,
728 148,
729 33,
730 604,
731 458,
732 458,
733 12,
734 20,
735 false,
736 Option::None,
737 Option::None,
738 "tomar 1",
739 Option::None,
740 "saldo de apertura de carga",
741 1,
742 false,
743 ),
744 row(
745 1117,
746 1,
747 148,
748 33,
749 605,
750 2640,
751 2640,
752 71,
753 21,
754 false,
755 Option::None,
756 Option::None,
757 "tomar 1",
758 Option::None,
759 "saldo de apertura de carga",
760 1,
761 false,
762 ),
763 row(
764 1118,
765 1,
766 148,
767 33,
768 606,
769 104,
770 104,
771 2,
772 22,
773 false,
774 Option::None,
775 Option::None,
776 "tomar 1",
777 Option::None,
778 "saldo de apertura de carga",
779 1,
780 false,
781 ),
782 row(
783 1119,
784 1,
785 148,
786 33,
787 607,
788 236,
789 236,
790 6,
791 23,
792 false,
793 Option::None,
794 Option::None,
795 "tomar 1",
796 Option::None,
797 "saldo de apertura de carga",
798 1,
799 false,
800 ),
801 row(
802 1120,
803 1,
804 148,
805 33,
806 608,
807 356,
808 356,
809 9,
810 24,
811 false,
812 Option::None,
813 Option::None,
814 "tomar 1",
815 Option::None,
816 "saldo de apertura de carga",
817 1,
818 false,
819 ),
820 row(
821 1121,
822 1,
823 148,
824 33,
825 609,
826 323,
827 323,
828 8,
829 25,
830 false,
831 Option::None,
832 Option::None,
833 "tomar 1",
834 Option::None,
835 "saldo de apertura de carga",
836 1,
837 false,
838 ),
839 ];
840
841 GridData { columns, rows }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn compare_same_kind_numeric() {
850 assert_eq!(
851 compare_cells(&CellValue::Integer(1), &CellValue::Integer(2)),
852 Ordering::Less
853 );
854 assert_eq!(
855 compare_cells(&CellValue::Integer(2), &CellValue::Integer(1)),
856 Ordering::Greater
857 );
858 assert_eq!(
859 compare_cells(&CellValue::Integer(7), &CellValue::Integer(7)),
860 Ordering::Equal
861 );
862 assert_eq!(
863 compare_cells(&CellValue::Decimal(1.5), &CellValue::Decimal(2.5)),
864 Ordering::Less
865 );
866 }
867
868 #[test]
869 fn compare_decimal_handles_nan_deterministically() {
870 let nan = CellValue::Decimal(f64::NAN);
871 let one = CellValue::Decimal(1.0);
872 assert_ne!(compare_cells(&nan, &one), Ordering::Equal);
874 assert_eq!(
876 compare_cells(&nan, &CellValue::Decimal(f64::NAN)),
877 Ordering::Equal
878 );
879 }
880
881 #[test]
882 fn compare_mixed_numeric_via_total_cmp() {
883 assert_eq!(
884 compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.5)),
885 Ordering::Less,
886 );
887 assert_eq!(
888 compare_cells(&CellValue::Decimal(5.5), &CellValue::Integer(5)),
889 Ordering::Greater,
890 );
891 assert_eq!(
892 compare_cells(&CellValue::Integer(5), &CellValue::Decimal(5.0)),
893 Ordering::Equal,
894 );
895 }
896
897 #[test]
898 fn compare_null_is_always_less_than_other() {
899 assert_eq!(
900 compare_cells(&CellValue::None, &CellValue::Integer(0)),
901 Ordering::Less
902 );
903 assert_eq!(
904 compare_cells(&CellValue::Integer(0), &CellValue::None),
905 Ordering::Greater
906 );
907 assert_eq!(
908 compare_cells(&CellValue::None, &CellValue::None),
909 Ordering::Equal
910 );
911 assert_eq!(
912 compare_cells(&CellValue::None, &CellValue::Text("z".into())),
913 Ordering::Less
914 );
915 }
916
917 #[test]
918 fn compare_cross_type_non_numeric_is_deterministic_non_equal() {
919 assert_ne!(
921 compare_cells(&CellValue::Boolean(true), &CellValue::Text("x".into())),
922 Ordering::Equal,
923 );
924 assert_eq!(
925 compare_cells(&CellValue::Boolean(true), &CellValue::Boolean(true)),
926 Ordering::Equal,
927 );
928 }
929
930 #[test]
931 fn grid_data_construction_validates_rows() {
932 let cols = vec![
933 Column::new("a", ColumnKind::Integer, 80.0),
934 Column::new("b", ColumnKind::Integer, 80.0),
935 ];
936 let ok = GridData::new(
938 cols.clone(),
939 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
940 );
941 assert!(ok.is_ok());
942
943 let bad = GridData::new(
945 cols,
946 vec![vec![
947 CellValue::Integer(1),
948 CellValue::Integer(2),
949 CellValue::Integer(3),
950 ]],
951 );
952 assert_eq!(
953 bad.err(),
954 Some(GridDataError::RaggedRow {
955 row_index: 0,
956 expected: 2,
957 actual: 3
958 }),
959 );
960 }
961
962 #[test]
963 #[allow(clippy::unwrap_used, clippy::expect_used)]
964 fn grid_data_cell_safe_access() {
965 let data = GridData::new(
966 vec![Column::new("a", ColumnKind::Integer, 80.0)],
967 vec![vec![CellValue::Integer(9)]],
968 )
969 .expect("row width matches columns");
970 assert_eq!(data.cell(0, 0), Some(&CellValue::Integer(9)));
971 assert_eq!(data.cell(1, 0), Option::None);
972 assert_eq!(data.cell(0, 1), Option::None);
973 }
974
975 #[test]
976 fn from_conversions_match_variant() {
977 assert_eq!(
978 CellValue::from(String::from("x")),
979 CellValue::Text("x".into())
980 );
981 assert_eq!(CellValue::from(42_i64), CellValue::Integer(42));
982 assert_eq!(CellValue::from(7_i32), CellValue::Integer(7));
983 assert_eq!(CellValue::from(0.5_f64), CellValue::Decimal(0.5));
984 assert_eq!(CellValue::from(true), CellValue::Boolean(true));
985 assert_eq!(
986 CellValue::from(Some(CellValue::Integer(3))),
987 CellValue::Integer(3),
988 );
989 assert_eq!(CellValue::from(Option::None::<CellValue>), CellValue::None);
990 }
991
992 #[test]
993 fn sample_data_is_rectangular() {
994 let sample = sample_data();
995 assert!(
996 sample.validate().is_ok(),
997 "sample rows should be rectangular"
998 );
999 assert!(sample.row_count() > 0);
1000 }
1001
1002 #[test]
1003 #[allow(clippy::expect_used)]
1004 fn column_index_exact_match() {
1005 let data = GridData::new(
1006 vec![
1007 Column::new("alpha", ColumnKind::Integer, 80.0),
1008 Column::new("beta", ColumnKind::Text, 80.0),
1009 Column::new("gamma", ColumnKind::Decimal, 80.0),
1010 ],
1011 vec![vec![
1012 CellValue::Integer(1),
1013 CellValue::Text("x".into()),
1014 CellValue::Decimal(1.0),
1015 ]],
1016 )
1017 .expect("rectangular");
1018 assert_eq!(data.column_index("alpha"), Some(0));
1019 assert_eq!(data.column_index("beta"), Some(1));
1020 assert_eq!(data.column_index("gamma"), Some(2));
1021 assert_eq!(data.column_index("Alpha"), None);
1022 assert_eq!(data.column_index("missing"), None);
1023 }
1024
1025 #[test]
1026 #[allow(clippy::expect_used)]
1027 fn column_index_first_duplicate_wins() {
1028 let data = GridData::new(
1029 vec![
1030 Column::new("dup", ColumnKind::Integer, 80.0),
1031 Column::new("dup", ColumnKind::Integer, 80.0),
1032 ],
1033 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1034 )
1035 .expect("rectangular");
1036 assert_eq!(data.column_index("dup"), Some(0));
1037 }
1038
1039 #[test]
1040 #[allow(clippy::expect_used)]
1041 fn column_index_empty_data_returns_none() {
1042 let data = GridData::new(vec![], vec![]).expect("empty");
1043 assert_eq!(data.column_index("anything"), None);
1044 }
1045}