use chrono::{NaiveDate, NaiveDateTime};
use crate::types::ColumnType;
#[derive(Debug, Clone, PartialEq)]
pub enum TypedCell {
Str(String),
I64(i64),
F64(f64),
Bool(bool),
DateDays(i32),
DatetimeMicros(i64),
}
pub fn parse_typed_value(raw: &str, col_type: ColumnType) -> Result<Option<TypedCell>, String> {
let s = raw.trim();
if s.is_empty() {
return Ok(None);
}
let cell = match col_type {
ColumnType::String => TypedCell::Str(raw.to_string()),
ColumnType::Integer | ColumnType::FileSize => TypedCell::I64(
s.parse::<i64>()
.map_err(|_| "not a whole number".to_string())?,
),
ColumnType::Float => {
TypedCell::F64(s.parse::<f64>().map_err(|_| "not a number".to_string())?)
}
ColumnType::Boolean => match s.to_lowercase().as_str() {
"true" | "1" | "yes" | "y" => TypedCell::Bool(true),
"false" | "0" | "no" | "n" => TypedCell::Bool(false),
_ => return Err("not true/false (also 1/0, yes/no)".into()),
},
ColumnType::Percentage => {
let n = s
.replace('%', "")
.trim()
.parse::<f64>()
.map_err(|_| "not a percentage (e.g. 45 or 45%)".to_string())?;
TypedCell::F64(n / 100.0)
}
ColumnType::Currency => {
let bracketed = s.starts_with('(') && s.ends_with(')');
let cleaned: String = s
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();
let n = cleaned
.parse::<f64>()
.map_err(|_| "not an amount (e.g. 1200.50)".to_string())?;
TypedCell::F64(if bracketed { -n.abs() } else { n })
}
ColumnType::Date => {
TypedCell::DateDays(parse_date_days(s).ok_or("not a date (YYYY-MM-DD)")?)
}
ColumnType::Datetime => TypedCell::DatetimeMicros(
parse_datetime_micros(s).ok_or("not a date and time (YYYY-MM-DD HH:MM:SS)")?,
),
};
Ok(Some(cell))
}
fn parse_date_days(s: &str) -> Option<i32> {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?;
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some((d - epoch).num_days() as i32);
}
for fmt in crate::data::DATETIME_FORMATS {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
return Some((dt.date() - epoch).num_days() as i32);
}
}
None
}
fn parse_datetime_micros(s: &str) -> Option<i64> {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?.and_hms_opt(0, 0, 0)?;
for fmt in crate::data::DATETIME_FORMATS {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
let diff = dt - epoch;
return diff
.num_microseconds()
.or_else(|| diff.num_seconds().checked_mul(1_000_000));
}
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let diff = d.and_hms_opt(0, 0, 0)? - epoch;
return diff
.num_microseconds()
.or_else(|| diff.num_seconds().checked_mul(1_000_000));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_is_null_for_every_type() {
for t in ColumnType::all() {
assert_eq!(parse_typed_value(" ", *t), Ok(None), "{:?}", t);
}
}
#[test]
fn every_type_accepts_its_own_and_rejects_the_rest() {
let cases: &[(ColumnType, &str, TypedCell, &str)] = &[
(
ColumnType::String,
"abc",
TypedCell::Str("abc".into()),
"",
),
(ColumnType::Integer, "42", TypedCell::I64(42), "4.5"),
(ColumnType::FileSize, "1024", TypedCell::I64(1024), "1.5 MB"),
(ColumnType::Float, "4.5", TypedCell::F64(4.5), "abc"),
(ColumnType::Boolean, "yes", TypedCell::Bool(true), "abc"),
(
ColumnType::Percentage,
"45%",
TypedCell::F64(0.45),
"half of it",
),
(
ColumnType::Currency,
"($5.00)",
TypedCell::F64(-5.0),
"free",
),
(
ColumnType::Date,
"2024-01-05",
TypedCell::DateDays(19727),
"2024-13-01",
),
(
ColumnType::Datetime,
"2024-01-05 10:30:00",
TypedCell::DatetimeMicros(19727 * 86_400_000_000 + 37_800_000_000),
"yesterday",
),
];
for (t, good, want, bad) in cases {
assert_eq!(
parse_typed_value(good, *t),
Ok(Some(want.clone())),
"{:?} should accept {:?}",
t,
good
);
if !bad.is_empty() {
assert!(
parse_typed_value(bad, *t).is_err(),
"{:?} should reject {:?}",
t,
bad
);
}
}
assert!(parse_typed_value("yesterday", ColumnType::String).is_ok());
}
}