graphddb_runtime 0.7.6

Rust runtime for GraphDDB — interprets the language-neutral IR (manifest.json + operations.json) and executes the validated access patterns against DynamoDB.
Documentation
//! ISO-8601 instant handling for hydration parity.
//!
//! The Python hydrator restores a `format: "datetime"` string to a `datetime` and
//! a `format: "date"` string to midnight-UTC; the conformance runner then renders
//! it back with `Date.toISOString()` semantics (`_to_ts_iso`): always millisecond
//! precision + trailing `Z`. The net observable value is the canonical ISO string.
//!
//! This module reproduces that render directly on the string (no chrono
//! dependency): parse the ISO components and re-emit `YYYY-MM-DDTHH:MM:SS.mmmZ`,
//! matching `Date.toISOString()` and the Python `_to_ts_iso`.

use crate::errors::GraphDDBError;

/// Normalize a `format: "datetime"` value into the canonical
/// `YYYY-MM-DDTHH:MM:SS.mmmZ` form (millisecond precision, `Z` suffix), matching
/// `Date.toISOString()` / the Python runner's `_to_ts_iso`.
pub fn normalize_datetime(value: &str) -> Result<String, GraphDDBError> {
    let (y, mo, d, h, mi, s, ms) = parse_iso(value)?;
    Ok(render(y, mo, d, h, mi, s, ms))
}

/// Normalize a `format: "date"` value (a bare `YYYY-MM-DD`) into midnight UTC in
/// the canonical form, matching the Python hydrator (`value + "T00:00:00.000Z"`).
pub fn normalize_date(value: &str) -> Result<String, GraphDDBError> {
    normalize_datetime(&format!("{value}T00:00:00.000Z"))
}

#[allow(clippy::type_complexity)]
fn parse_iso(value: &str) -> Result<(i64, u32, u32, u32, u32, u32, u32), GraphDDBError> {
    let err = || GraphDDBError::hydration(format!("invalid ISO 8601 datetime: '{value}'"));
    // Accept a trailing 'Z' or '+00:00'; the fixtures use the TS toISOString form.
    let core = value
        .strip_suffix('Z')
        .or_else(|| value.strip_suffix("+00:00"))
        .unwrap_or(value);
    let (date, time) = core.split_once('T').ok_or_else(err)?;
    let mut dparts = date.split('-');
    let y: i64 = dparts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
    let mo: u32 = dparts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
    let d: u32 = dparts.next().ok_or_else(err)?.parse().map_err(|_| err())?;

    let (hms, frac) = match time.split_once('.') {
        Some((a, b)) => (a, b),
        None => (time, ""),
    };
    let mut tparts = hms.split(':');
    let h: u32 = tparts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
    let mi: u32 = tparts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
    let s: u32 = tparts.next().unwrap_or("0").parse().map_err(|_| err())?;
    // Milliseconds: take the first 3 fractional digits, right-padded.
    let ms: u32 = if frac.is_empty() {
        0
    } else {
        let digits: String = frac.chars().take(3).collect();
        let padded = format!("{digits:0<3}");
        padded.parse().map_err(|_| err())?
    };
    Ok((y, mo, d, h, mi, s, ms))
}

fn render(y: i64, mo: u32, d: u32, h: u32, mi: u32, s: u32, ms: u32) -> String {
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{ms:03}Z")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn datetime_round_trips_to_millis_z() {
        assert_eq!(
            normalize_datetime("2020-01-01T00:00:00.000Z").unwrap(),
            "2020-01-01T00:00:00.000Z"
        );
        assert_eq!(
            normalize_datetime("2021-06-15T12:00:00Z").unwrap(),
            "2021-06-15T12:00:00.000Z"
        );
    }

    #[test]
    fn date_becomes_midnight() {
        assert_eq!(
            normalize_date("2020-01-01").unwrap(),
            "2020-01-01T00:00:00.000Z"
        );
    }
}