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