Skip to main content

sqlly_datatable/
data.rs

1//! Core data model for the grid: cell values, columns, and the rectangular
2//! [`GridData`] container.
3//!
4//! `GridData` is intentionally simple — a column list paired with a `Vec` of
5//! rectangular rows of [`CellValue`]. It carries no rendering, sorting, or
6//! filtering state: those live on [`crate::grid::GridState`]. Keeping the data
7//! layer pure makes it reusable from outside the widget (export pipelines,
8//! server-side previews, test fixtures).
9//!
10//! [`CellValue`] does not implement [`Eq`]/[`Ord`] because [`CellValue::Decimal`]
11//! holds an `f64`. Use [`compare_cells`] when you need a deterministic total
12//! ordering that handles `NaN` and mixed numeric kinds deliberately rather
13//! than collapsing to `Equal`.
14
15use std::cmp::Ordering;
16
17/// A single cell value.
18///
19/// Decimal values are stored as `f64`; for very large integers that exceed
20/// `2^53`, route them through [`CellValue::Text`] instead.
21#[derive(Clone, Debug, PartialEq)]
22pub enum CellValue {
23    /// Free-form text. The grid will case-fold, truncate, and align it per
24    /// [`crate::config::StringFormat`].
25    Text(String),
26    /// 64-bit signed integer.
27    Integer(i64),
28    /// 64-bit floating point. `NaN` is permitted; [`compare_cells`] places it
29    /// after all finite numbers so sorting remains stable.
30    Decimal(f64),
31    /// Unix timestamp in seconds. Formatting is driven by
32    /// [`crate::config::DateFormat`].
33    Date(i64),
34    /// Boolean value rendered with [`crate::config::BooleanFormat`].
35    Boolean(bool),
36    /// Explicit "no value" — distinct from empty string and zero. Sorts before
37    /// every other variant.
38    None,
39}
40
41/// Declared column kind. Drives the default [`crate::config::ResolvedColumnFormat`]
42/// when no [`crate::config::ColumnOverride`] is supplied.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
44pub enum ColumnKind {
45    /// Text columns (`StringFormat`).
46    Text,
47    /// Integer columns (`NumberFormat`, default decimals = 0).
48    Integer,
49    /// Decimal columns (`NumberFormat`, default decimals = 2).
50    Decimal,
51    /// Date columns (`DateFormat`).
52    Date,
53    /// Boolean columns (`BooleanFormat`).
54    Boolean,
55    /// Unknown / un-inferred kind. Falls back to [`crate::config::StringFormat`] for display.
56    None,
57}
58
59/// A single column declaration.
60#[derive(Clone, Debug, PartialEq)]
61pub struct Column {
62    /// Human-readable column name. Rendered as the header label.
63    pub name: String,
64    /// Inferred kind driving default formatting.
65    pub kind: ColumnKind,
66    /// Initial column width in logical pixels. Resizable by the user at runtime.
67    pub width: f32,
68}
69
70impl Column {
71    /// Convenience constructor.
72    #[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/// Rectangular grid data: `rows.len()` rows each of length `columns.len()`.
83///
84/// The library does not silently fix ragged rows; use [`GridData::new`] or
85/// [`GridData::validate`] to detect and reject them.
86#[derive(Clone, Debug)]
87pub struct GridData {
88    /// Column metadata. `columns.len()` is the row width for every row.
89    pub columns: Vec<Column>,
90    /// Row contents. Every row must have exactly `columns.len()` cells.
91    pub rows: Vec<Vec<CellValue>>,
92}
93
94/// Error returned when [`GridData`] cannot be constructed or validated because
95/// at least one row's length disagrees with the column count.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum GridDataError {
98    /// A row had a different number of cells than `columns.len()`.
99    RaggedRow {
100        /// Index of the offending row.
101        row_index: usize,
102        /// Expected number of cells (always `columns.len()`).
103        expected: usize,
104        /// Actual number of cells found in the row.
105        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    /// Construct a new `GridData`, validating that every row has exactly
128    /// `columns.len()` cells.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
133    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    /// Validate the rectangular invariant. Cheap; called by [`GridData::new`]
140    /// and by debug assertions in the paint/copy hot paths.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`GridDataError::RaggedRow`] pointing at the first mis-sized row.
145    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    /// Safe accessor for cell `(row, col)`. Returns `None` if either index is
160    /// out of bounds.
161    #[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    /// Number of rows (after sort/filter this reflects the live `display_indices`
167    /// length, not `rows.len()`).
168    #[must_use]
169    pub fn row_count(&self) -> usize {
170        self.rows.len()
171    }
172
173    /// Number of columns. Always equal to any row's length if [`Self::validate`]
174    /// succeeded.
175    #[must_use]
176    pub fn column_count(&self) -> usize {
177        self.columns.len()
178    }
179
180    /// Ordinal index of the first column whose name matches `name` exactly
181    /// (case-sensitive). If duplicate names exist, the first match wins.
182    #[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/// Total deterministic ordering for `CellValue`.
231///
232/// Behavior:
233///
234/// * Same-kind numeric values compare numerically; decimals use
235///   [`f64::total_cmp`] so `NaN` is ordered consistently (after all finite
236///   values, and `0.0` before `-0.0` is reversed by `total_cmp` semantics —
237///   we keep that contract).
238/// * Mixed `Integer` / `Decimal` pairs compare numerically.
239/// * `None` always sorts before every other variant.
240/// * Cross-type non-numeric pairs fall back to a stable type-rank order so
241///   the return value is never `Equal` for genuinely different values.
242///
243/// Use [`std::cmp::Ordering`] directly via `slice::sort_by`; do not rely on
244/// whatever a future `PartialOrd` derive might produce.
245#[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
265/// Stable rank used as a tie-breaker when two cells have different, non-numeric
266/// kinds. Sort order is `None < Boolean < Integer == Decimal < Date < Text`.
267fn 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/// A handful of synthetic ledger-style rows for examples and the sample
278/// application. Kept here so examples have a known shape without pulling in a
279/// separate data file. Production code should construct [`GridData`] directly.
280#[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), // Nullable
297        Column::new("ReferenceEntityId", Integer, 150.0), // Nullable
298        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        // `NaN` should not collapse to Equal: it sits at a defined slot in total_cmp.
872        assert_ne!(compare_cells(&nan, &one), Ordering::Equal);
873        // Two `NaN` should be equal under total_cmp.
874        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        // Different kinds, neither numeric, both non-null -> type-rank, Equal only by rank.
919        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        // Good.
936        let ok = GridData::new(
937            cols.clone(),
938            vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
939        );
940        assert!(ok.is_ok());
941
942        // Ragged row.
943        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}