Skip to main content

orbital_data/
value.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3
4use chrono::NaiveDate;
5use serde::{Deserialize, Serialize};
6
7/// Typed cell value — replaces stringly-typed `HashMap<String, String>`.
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub enum DataValue {
10    Text(String),
11    Number(f64),
12    Bool(bool),
13    Date(NaiveDate),
14    Category(String),
15    Null,
16}
17
18/// Column/field data type descriptor.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub enum DataType {
21    #[default]
22    Text,
23    Number,
24    Bool,
25    Date,
26    Category,
27}
28
29/// Column type hint for typed comparison (mirrors data table `ColumnType` without coupling).
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum CompareHint {
32    #[default]
33    Text,
34    Number,
35    Date,
36    Boolean,
37    SingleSelect,
38}
39
40impl DataValue {
41    /// Human-readable string for display and text-based filtering.
42    pub fn display_string(&self) -> String {
43        match self {
44            Self::Text(s) | Self::Category(s) => s.clone(),
45            Self::Number(n) => n.to_string(),
46            Self::Bool(b) => b.to_string(),
47            Self::Date(d) => d.format("%Y-%m-%d").to_string(),
48            Self::Null => String::new(),
49        }
50    }
51
52    /// Whether this value is considered empty for filter operators.
53    pub fn is_empty_value(&self) -> bool {
54        matches!(self, Self::Null) || matches!(self, Self::Text(s) if s.is_empty())
55    }
56
57    /// Case-insensitive text for Contains/StartsWith operators.
58    pub fn filter_text(&self) -> String {
59        self.display_string().to_lowercase()
60    }
61
62    /// Typed partial comparison for sorting. Cross-variant or invalid pairs return `None`.
63    pub fn partial_cmp_typed(&self, other: &Self, hint: CompareHint) -> Option<Ordering> {
64        match (self, other) {
65            (Self::Null, Self::Null) => Some(Ordering::Equal),
66            (Self::Null, _) => Some(Ordering::Greater),
67            (_, Self::Null) => Some(Ordering::Less),
68            (Self::Number(a), Self::Number(b)) if matches!(hint, CompareHint::Number) => {
69                a.partial_cmp(b)
70            }
71            (Self::Date(a), Self::Date(b)) if matches!(hint, CompareHint::Date) => Some(a.cmp(b)),
72            (Self::Bool(a), Self::Bool(b)) if matches!(hint, CompareHint::Boolean) => {
73                Some(a.cmp(b))
74            }
75            (Self::Text(a) | Self::Category(a), Self::Text(b) | Self::Category(b))
76                if matches!(hint, CompareHint::Text | CompareHint::SingleSelect) =>
77            {
78                Some(a.to_lowercase().cmp(&b.to_lowercase()))
79            }
80            (Self::Number(a), Self::Number(b)) => a.partial_cmp(b),
81            (Self::Date(a), Self::Date(b)) => Some(a.cmp(b)),
82            (Self::Bool(a), Self::Bool(b)) => Some(a.cmp(b)),
83            (Self::Text(a) | Self::Category(a), Self::Text(b) | Self::Category(b)) => {
84                Some(a.to_lowercase().cmp(&b.to_lowercase()))
85            }
86            _ => None,
87        }
88    }
89
90    /// Infer a [`DataType`] from this value variant.
91    pub fn data_type(&self) -> DataType {
92        match self {
93            Self::Text(_) => DataType::Text,
94            Self::Number(_) => DataType::Number,
95            Self::Bool(_) => DataType::Bool,
96            Self::Date(_) => DataType::Date,
97            Self::Category(_) => DataType::Category,
98            Self::Null => DataType::Text,
99        }
100    }
101}
102
103impl From<String> for DataValue {
104    fn from(value: String) -> Self {
105        Self::Text(value)
106    }
107}
108
109impl From<&str> for DataValue {
110    fn from(value: &str) -> Self {
111        Self::Text(value.to_string())
112    }
113}
114
115impl From<f64> for DataValue {
116    fn from(value: f64) -> Self {
117        Self::Number(value)
118    }
119}
120
121impl From<bool> for DataValue {
122    fn from(value: bool) -> Self {
123        Self::Bool(value)
124    }
125}
126
127/// Convert a string map into typed text values.
128pub fn text_map_from_strings(cells: HashMap<String, String>) -> HashMap<String, DataValue> {
129    cells
130        .into_iter()
131        .map(|(k, v)| (k, DataValue::Text(v)))
132        .collect()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn number_sort_order() {
141        let two = DataValue::Number(2.0);
142        let ten = DataValue::Number(10.0);
143        assert_eq!(
144            two.partial_cmp_typed(&ten, CompareHint::Number),
145            Some(Ordering::Less)
146        );
147    }
148
149    #[test]
150    fn null_sorts_last() {
151        let text = DataValue::Text("a".into());
152        let null = DataValue::Null;
153        assert_eq!(
154            text.partial_cmp_typed(&null, CompareHint::Text),
155            Some(Ordering::Less)
156        );
157    }
158}