use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use serde_json::Value;
use crate::error::{Result, SurqlError};
pub fn coerce_datetime(value: &str) -> Result<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(&truncate_fraction(value)) {
return Ok(dt.with_timezone(&Utc));
}
if let Ok(ndt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
return Ok(Utc.from_utc_datetime(&ndt));
}
if let Ok(ndt) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
return Ok(Utc.from_utc_datetime(&ndt));
}
Err(SurqlError::Validation {
reason: format!("could not parse datetime string {value:?}"),
})
}
pub fn coerce_record_datetimes(
data: &serde_json::Map<String, Value>,
datetime_fields: &[&str],
) -> Result<serde_json::Map<String, Value>> {
let mut out = data.clone();
for field in datetime_fields {
let Some(existing) = out.get(*field) else {
continue;
};
if existing.is_null() {
continue;
}
let Value::String(raw) = existing else {
continue;
};
let dt = coerce_datetime(raw)?;
out.insert((*field).to_owned(), Value::String(dt.to_rfc3339()));
}
Ok(out)
}
fn truncate_fraction(value: &str) -> String {
let Some(dot_idx) = value.find('.') else {
return value.to_owned();
};
let bytes = value.as_bytes();
let mut frac_end = dot_idx + 1;
while frac_end < bytes.len() && bytes[frac_end].is_ascii_digit() {
frac_end += 1;
}
let frac = &value[dot_idx + 1..frac_end];
if frac.len() <= 9 {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
out.push_str(&value[..=dot_idx]);
out.push_str(&frac[..9]);
out.push_str(&value[frac_end..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_z_suffix() {
let dt = coerce_datetime("2024-01-15T10:30:00Z").unwrap();
assert_eq!(dt.to_rfc3339(), "2024-01-15T10:30:00+00:00");
}
#[test]
fn parses_offset() {
let dt = coerce_datetime("2024-01-15T10:30:00+00:00").unwrap();
assert_eq!(dt.to_rfc3339(), "2024-01-15T10:30:00+00:00");
}
#[test]
fn parses_nanoseconds() {
let dt = coerce_datetime("2024-01-15T10:30:00.123456789Z").unwrap();
assert_eq!(dt.year(), 2024);
}
use chrono::Datelike;
#[test]
fn parses_naive_date() {
let dt = coerce_datetime("2024-01-15T10:30:00").unwrap();
assert_eq!(dt.to_rfc3339(), "2024-01-15T10:30:00+00:00");
}
#[test]
fn rejects_garbage() {
assert!(coerce_datetime("not-a-date").is_err());
}
#[test]
fn coerces_datetime_field() {
let mut m = serde_json::Map::new();
m.insert("name".into(), json!("Alice"));
m.insert("created_at".into(), json!("2024-01-15T10:30:00Z"));
let out = coerce_record_datetimes(&m, &["created_at"]).unwrap();
assert_eq!(
out.get("created_at").unwrap().as_str().unwrap(),
"2024-01-15T10:30:00+00:00"
);
assert_eq!(out.get("name").unwrap().as_str().unwrap(), "Alice");
}
#[test]
fn skips_missing_and_null_fields() {
let mut m = serde_json::Map::new();
m.insert("deleted_at".into(), Value::Null);
let out = coerce_record_datetimes(&m, &["deleted_at", "not_present"]).unwrap();
assert!(out.get("deleted_at").unwrap().is_null());
}
}