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