csv_managed/
data.rs

1use std::fmt;
2
3use anyhow::{Context, Result, anyhow, bail};
4use chrono::{NaiveDate, NaiveDateTime};
5use evalexpr;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::schema::ColumnType;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub enum Value {
13    String(String),
14    Integer(i64),
15    Float(f64),
16    Boolean(bool),
17    Date(NaiveDate),
18    DateTime(NaiveDateTime),
19    Guid(Uuid),
20}
21
22impl Eq for Value {}
23
24impl Value {
25    pub fn as_display(&self) -> String {
26        match self {
27            Value::String(s) => s.clone(),
28            Value::Integer(i) => i.to_string(),
29            Value::Float(f) => {
30                if f.fract() == 0.0 {
31                    (*f as i64).to_string()
32                } else {
33                    f.to_string()
34                }
35            }
36            Value::Boolean(b) => b.to_string(),
37            Value::Date(d) => d.format("%Y-%m-%d").to_string(),
38            Value::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
39            Value::Guid(g) => g.to_string(),
40        }
41    }
42}
43
44impl Ord for Value {
45    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
46        match (self, other) {
47            (Value::String(a), Value::String(b)) => a.cmp(b),
48            (Value::Integer(a), Value::Integer(b)) => a.cmp(b),
49            (Value::Float(a), Value::Float(b)) => a.total_cmp(b),
50            (Value::Boolean(a), Value::Boolean(b)) => a.cmp(b),
51            (Value::Date(a), Value::Date(b)) => a.cmp(b),
52            (Value::DateTime(a), Value::DateTime(b)) => a.cmp(b),
53            (Value::Guid(a), Value::Guid(b)) => a.cmp(b),
54            _ => panic!("Cannot compare heterogeneous Value variants"),
55        }
56    }
57}
58
59impl PartialOrd for Value {
60    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
61        Some(self.cmp(other))
62    }
63}
64
65impl fmt::Display for Value {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "{}", self.as_display())
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ComparableValue(pub Option<Value>);
73
74impl Ord for ComparableValue {
75    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
76        match (&self.0, &other.0) {
77            (None, None) => std::cmp::Ordering::Equal,
78            (None, Some(_)) => std::cmp::Ordering::Less,
79            (Some(_), None) => std::cmp::Ordering::Greater,
80            (Some(left), Some(right)) => left.cmp(right),
81        }
82    }
83}
84
85impl PartialOrd for ComparableValue {
86    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
87        Some(self.cmp(other))
88    }
89}
90
91pub fn parse_naive_date(value: &str) -> Result<NaiveDate> {
92    const DATE_FORMATS: &[&str] = &["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d", "%d-%m-%Y"];
93    for fmt in DATE_FORMATS {
94        if let Ok(parsed) = NaiveDate::parse_from_str(value, fmt) {
95            return Ok(parsed);
96        }
97    }
98    Err(anyhow!("Failed to parse '{value}' as date"))
99}
100
101pub fn parse_naive_datetime(value: &str) -> Result<NaiveDateTime> {
102    const DATETIME_FORMATS: &[&str] = &[
103        "%Y-%m-%d %H:%M:%S",
104        "%Y-%m-%dT%H:%M:%S",
105        "%d/%m/%Y %H:%M:%S",
106        "%m/%d/%Y %H:%M:%S",
107        "%Y-%m-%d %H:%M",
108        "%Y-%m-%dT%H:%M",
109    ];
110    for fmt in DATETIME_FORMATS {
111        if let Ok(parsed) = NaiveDateTime::parse_from_str(value, fmt) {
112            return Ok(parsed);
113        }
114    }
115    Err(anyhow!("Failed to parse '{value}' as datetime"))
116}
117
118pub fn normalize_column_name(name: &str) -> String {
119    name.chars()
120        .map(|c| match c {
121            'a'..='z' | 'A'..='Z' | '0'..='9' => c,
122            _ => '_',
123        })
124        .collect::<String>()
125        .to_ascii_lowercase()
126}
127
128pub fn parse_typed_value(value: &str, ty: &ColumnType) -> Result<Option<Value>> {
129    if value.is_empty() {
130        return Ok(None);
131    }
132    let parsed = match ty {
133        ColumnType::String => Value::String(value.to_string()),
134        ColumnType::Integer => {
135            let parsed: i64 = value
136                .parse()
137                .with_context(|| format!("Failed to parse '{value}' as integer"))?;
138            Value::Integer(parsed)
139        }
140        ColumnType::Float => {
141            let parsed: f64 = value
142                .parse()
143                .with_context(|| format!("Failed to parse '{value}' as float"))?;
144            Value::Float(parsed)
145        }
146        ColumnType::Boolean => {
147            let lowered = value.to_ascii_lowercase();
148            let parsed = match lowered.as_str() {
149                "true" | "t" | "yes" | "y" | "1" => true,
150                "false" | "f" | "no" | "n" | "0" => false,
151                _ => bail!("Failed to parse '{value}' as boolean"),
152            };
153            Value::Boolean(parsed)
154        }
155        ColumnType::Date => {
156            let parsed = parse_naive_date(value)?;
157            Value::Date(parsed)
158        }
159        ColumnType::DateTime => {
160            let parsed = parse_naive_datetime(value)?;
161            Value::DateTime(parsed)
162        }
163        ColumnType::Guid => {
164            let trimmed = value.trim().trim_matches(|c| matches!(c, '{' | '}'));
165            let parsed = Uuid::parse_str(trimmed)
166                .with_context(|| format!("Failed to parse '{value}' as GUID"))?;
167            Value::Guid(parsed)
168        }
169    };
170    Ok(Some(parsed))
171}
172
173pub fn value_to_evalexpr(value: &Value) -> evalexpr::Value {
174    match value {
175        Value::String(s) => evalexpr::Value::String(s.clone()),
176        Value::Integer(i) => evalexpr::Value::Int(*i),
177        Value::Float(f) => evalexpr::Value::Float(*f),
178        Value::Boolean(b) => evalexpr::Value::Boolean(*b),
179        Value::Date(d) => evalexpr::Value::String(d.format("%Y-%m-%d").to_string()),
180        Value::DateTime(dt) => evalexpr::Value::String(dt.format("%Y-%m-%d %H:%M:%S").to_string()),
181        Value::Guid(g) => evalexpr::Value::String(g.to_string()),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use chrono::{NaiveDate, NaiveDateTime};
189    use evalexpr::Value as EvalValue;
190    use uuid::Uuid;
191
192    #[test]
193    fn normalize_column_name_replaces_non_alphanumeric() {
194        assert_eq!(normalize_column_name("Order ID"), "order_id");
195        assert_eq!(normalize_column_name("$Percent%"), "_percent_");
196    }
197
198    #[test]
199    fn parse_naive_date_supports_multiple_formats() {
200        let expected = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
201        assert_eq!(parse_naive_date("2024-05-06").unwrap(), expected);
202        assert_eq!(parse_naive_date("06/05/2024").unwrap(), expected);
203        assert_eq!(parse_naive_date("2024/05/06").unwrap(), expected);
204    }
205
206    #[test]
207    fn parse_naive_datetime_supports_multiple_formats() {
208        let expected =
209            NaiveDateTime::parse_from_str("2024-05-06 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
210        assert_eq!(
211            parse_naive_datetime("2024-05-06T14:30:00").unwrap(),
212            expected
213        );
214        assert_eq!(
215            parse_naive_datetime("06/05/2024 14:30:00").unwrap(),
216            expected
217        );
218        assert_eq!(parse_naive_datetime("2024-05-06 14:30").unwrap(), expected);
219    }
220
221    #[test]
222    fn parse_typed_value_handles_empty_and_boolean_inputs() {
223        assert_eq!(parse_typed_value("", &ColumnType::Integer).unwrap(), None);
224
225        let truthy = parse_typed_value("Yes", &ColumnType::Boolean)
226            .unwrap()
227            .unwrap();
228        assert_eq!(truthy, Value::Boolean(true));
229
230        let falsy = parse_typed_value("0", &ColumnType::Boolean)
231            .unwrap()
232            .unwrap();
233        assert_eq!(falsy, Value::Boolean(false));
234
235        assert!(parse_typed_value("maybe", &ColumnType::Boolean).is_err());
236    }
237
238    #[test]
239    fn parse_typed_value_supports_guid_inputs() {
240        let raw = "550e8400-e29b-41d4-a716-446655440000";
241        let parsed = parse_typed_value(raw, &ColumnType::Guid).unwrap().unwrap();
242        match parsed {
243            Value::Guid(g) => {
244                assert_eq!(g, Uuid::parse_str(raw).unwrap());
245            }
246            other => panic!("Expected GUID value, got {other:?}"),
247        }
248
249        let braced = "{550e8400-e29b-41d4-a716-446655440000}";
250        let parsed_braced = parse_typed_value(braced, &ColumnType::Guid)
251            .unwrap()
252            .unwrap();
253        assert!(matches!(parsed_braced, Value::Guid(_)));
254
255        assert!(parse_typed_value("not-a-guid", &ColumnType::Guid).is_err());
256    }
257
258    #[test]
259    fn value_to_evalexpr_preserves_variants() {
260        assert_eq!(value_to_evalexpr(&Value::Integer(42)), EvalValue::Int(42));
261        assert_eq!(
262            value_to_evalexpr(&Value::Boolean(false)),
263            EvalValue::Boolean(false)
264        );
265
266        let date = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
267        assert_eq!(
268            value_to_evalexpr(&Value::Date(date)),
269            EvalValue::String("2024-05-06".to_string())
270        );
271    }
272
273    #[test]
274    fn comparable_value_orders_none_before_some() {
275        let none = ComparableValue(None);
276        let some = ComparableValue(Some(Value::Integer(0)));
277        assert!(none < some);
278    }
279}