sumo-types 0.2.0

Reads and writes SUMO (Eclipse SUMO) .net.xml, .rou.xml and .add.xml files as well-typed Rust structs
Documentation
//! Conversion from layer 1 (the private `schema` module, generated by
//! xsd-parser from
//! the SUMO XSDs) to layer 2 ([`crate::routes::domain`]).
//!
//! Mirrors `net`'s own `schema_mapper` in spirit — parsing the few fields
//! SUMO only restricts with a text pattern or union, translating enums —
//! but is independent code: `routes` doesn't require `net` to be enabled,
//! so it can't reuse `net`'s conversions even for a shared SUMO primitive
//! like `boolType` would be (`colorType` and `departType`, the ones this
//! module actually needs, aren't used by `net` at all).

use super::domain::{
    Color, Depart, NamedColor, Route, RouteId, Routes, Vehicle, VehicleId, VehicleType,
    VehicleTypeId,
};
use crate::schema;
use crate::sumo::{parse_finite, split_ids};
use crate::{Error, Result};
use uom::si::f64::{Length, Time, Velocity};
use uom::si::length::meter;
use uom::si::time::second;
use uom::si::velocity::meter_per_second;

/// SUMO accepts both `"grey"` and `"gray"` for the same named color.
impl From<schema::ColorType6Type> for NamedColor {
    fn from(value: schema::ColorType6Type) -> Self {
        use schema::ColorType6Type as S;
        match value {
            S::red => NamedColor::Red,
            S::green => NamedColor::Green,
            S::blue => NamedColor::Blue,
            S::yellow => NamedColor::Yellow,
            S::cyan => NamedColor::Cyan,
            S::magenta => NamedColor::Magenta,
            S::orange => NamedColor::Orange,
            S::white => NamedColor::White,
            S::black => NamedColor::Black,
            S::grey | S::gray => NamedColor::Grey,
            S::invisible => NamedColor::Invisible,
            S::random => NamedColor::Random,
        }
    }
}

/// Parses `colorType`'s numeric form: `"r,g,b"` or `"r,g,b,a"` (`a` defaults
/// to `1.0` when omitted), in either of the two scales SUMO accepts — see
/// [`Color::Rgba`] for why they aren't told apart here.
impl TryFrom<&str> for Color {
    type Error = Error;

    fn try_from(raw: &str) -> Result<Self> {
        let parts = raw
            .split(',')
            .map(|part| parse_finite(part, "SUMO color component"))
            .collect::<Result<Vec<_>>>()?;

        match *parts.as_slice() {
            [r, g, b] => Ok(Color::Rgba { r, g, b, a: 1.0 }),
            [r, g, b, a] => Ok(Color::Rgba { r, g, b, a }),
            _ => Err(Error::InvalidColor {
                value: raw.to_owned(),
            }),
        }
    }
}

/// Matches a raw color string against SUMO's named-color spellings,
/// independent of `schema::ColorType6Type`'s own enum matching — see the
/// `TryFrom<schema::ColorType> for Color` impl below for why this crate
/// can't just rely on that instead.
fn named_color(raw: &str) -> Option<NamedColor> {
    Some(match raw {
        "red" => NamedColor::Red,
        "green" => NamedColor::Green,
        "blue" => NamedColor::Blue,
        "yellow" => NamedColor::Yellow,
        "cyan" => NamedColor::Cyan,
        "magenta" => NamedColor::Magenta,
        "orange" => NamedColor::Orange,
        "white" => NamedColor::White,
        "black" => NamedColor::Black,
        "grey" | "gray" => NamedColor::Grey,
        "invisible" => NamedColor::Invisible,
        "random" => NamedColor::Random,
        _ => return None,
    })
}

impl TryFrom<schema::ColorType> for Color {
    type Error = Error;

    /// `colorType`'s `xsd:union` declares its numeric member
    /// (`ColorType4Type`) before its named-color one (`ColorType6Type`),
    /// and xsd-parser's generated `DeserializeBytes for ColorType` tries
    /// union members in that same declaration order — but `ColorType4Type`
    /// is `types/base.xsd`'s `xsd:string`-restricted-by-pattern numeric
    /// form rendered as a bare, unpatterned `String` (same reason
    /// `floatType` arrives as text for [`crate::sumo::parse_finite`] to
    /// validate: xsd-parser doesn't enforce `xsd:pattern`). A bare `String`
    /// deserializes successfully from *any* text, so the union always
    /// resolves to `ColorType4`, even for `color="grey"` — `ColorType6`
    /// (the proper, pattern-checked enum) is never actually reached. Every
    /// `ColorType4(raw)` is therefore checked against the named spellings
    /// here before falling back to numeric parsing, recovering the
    /// distinction the generated union deserializer can't make.
    fn try_from(value: schema::ColorType) -> Result<Self> {
        match value {
            schema::ColorType::ColorType4(raw) => match named_color(&raw) {
                Some(named) => Ok(Color::Named(named)),
                None => Color::try_from(raw.as_str()),
            },
            schema::ColorType::ColorType6(named) => Ok(Color::Named(NamedColor::from(named))),
        }
    }
}

/// Parses `timeType`'s text-pattern union member: `"H:M:S"` or
/// `"H:M:S:MS"`.
fn parse_clock_time(raw: &str) -> Result<Time> {
    let parts = raw
        .split(':')
        .map(|part| parse_finite(part, "SUMO clock time component"))
        .collect::<Result<Vec<_>>>()?;

    let seconds = match *parts.as_slice() {
        [h, m, s] => h * 3600.0 + m * 60.0 + s,
        [h, m, s, ms] => h * 3600.0 + m * 60.0 + s + ms / 1000.0,
        _ => {
            return Err(Error::InvalidClockTime {
                value: raw.to_owned(),
            });
        }
    };
    Ok(Time::new::<second>(seconds))
}

/// `schema::SumoTimeType` is `types/base.xsd`'s `timeType`, renamed at
/// build time (see `RENAMED_TYPES` in `build.rs`) to avoid colliding with
/// xsd-parser's generated `TimeType` primitive.
impl TryFrom<schema::SumoTimeType> for Time {
    type Error = Error;

    fn try_from(value: schema::SumoTimeType) -> Result<Self> {
        match value {
            schema::SumoTimeType::nonNegativeFloatType(seconds) => {
                Ok(Time::new::<second>(f64::from(seconds)))
            }
            schema::SumoTimeType::SumoTimeType9(raw) => parse_clock_time(&raw),
        }
    }
}

/// Matches a raw `sumoTimeType`/`SumoTimeType9` string against
/// `departType`'s trigger keywords, independent of `DepartType13Type`'s own
/// enum matching — see the `TryFrom<schema::DepartType> for Depart` impl
/// below for why this crate can't just rely on that instead.
fn depart_trigger(raw: &str) -> Option<Depart> {
    Some(match raw {
        "triggered" => Depart::Triggered,
        "containerTriggered" => Depart::ContainerTriggered,
        "split" => Depart::Split,
        "begin" => Depart::Begin,
        _ => return None,
    })
}

impl TryFrom<schema::DepartType> for Depart {
    type Error = Error;

    /// Same union-priority problem as `colorType`'s conversion above, one
    /// level deeper: `departType`'s first member, `sumoTimeType`
    /// (`timeType`), is itself a union whose second member
    /// (`SumoTimeType9`, `timeType`'s `"H:M:S"` text pattern) is an
    /// unpatterned `String` — so it absorbs `"triggered"`, `"begin"`, and
    /// the rest of `DepartType13Type`'s keywords before the outer union
    /// ever reaches that branch. Every `SumoTimeType9(raw)` is checked
    /// against those keywords via [`depart_trigger`] first, and only handed
    /// to [`Time`]'s own conversion (as clock-time text) when it doesn't
    /// match one.
    fn try_from(value: schema::DepartType) -> Result<Self> {
        if let schema::DepartType::sumoTimeType(schema::SumoTimeType::SumoTimeType9(raw)) = &value {
            if let Some(depart) = depart_trigger(raw) {
                return Ok(depart);
            }
        }

        match value {
            schema::DepartType::sumoTimeType(time) => Ok(Depart::Time(Time::try_from(time)?)),
            schema::DepartType::DepartType13(trigger) => {
                use schema::DepartType13Type as S;
                Ok(match trigger {
                    S::triggered => Depart::Triggered,
                    S::containerTriggered => Depart::ContainerTriggered,
                    S::split => Depart::Split,
                    S::begin => Depart::Begin,
                })
            }
        }
    }
}

impl TryFrom<schema::RouteType> for Route {
    type Error = Error;

    fn try_from(value: schema::RouteType) -> Result<Self> {
        Ok(Route {
            id: RouteId(value.id),
            edges: value.edges.as_deref().map(split_ids),
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

impl TryFrom<schema::VTypeType> for VehicleType {
    type Error = Error;

    fn try_from(value: schema::VTypeType) -> Result<Self> {
        Ok(VehicleType {
            id: VehicleTypeId(value.id),
            vehicle_class: value.v_class,
            length: value.length.map(|l| Length::new::<meter>(f64::from(l))),
            max_speed: value
                .max_speed
                .map(|s| Velocity::new::<meter_per_second>(f64::from(s))),
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

/// Converts SUMO's `vehicle` element (`schema::VehicleType`, an actual
/// vehicle instance) into [`Vehicle`] — not to be confused with
/// [`VehicleType`] (this crate's mapping of SUMO's `vType`/`vTypeType`, a
/// vehicle *class* definition). The naming clash is SUMO's own.
impl TryFrom<schema::VehicleType> for Vehicle {
    type Error = Error;

    fn try_from(value: schema::VehicleType) -> Result<Self> {
        Ok(Vehicle {
            id: VehicleId(value.id),
            vehicle_type: value.type_.map(VehicleTypeId),
            route: value.route.map(RouteId),
            depart: Depart::try_from(value.depart)?,
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

impl TryFrom<schema::RoutesType> for Routes {
    type Error = Error;

    fn try_from(value: schema::RoutesType) -> Result<Self> {
        let mut routes = Routes::default();

        for item in value.content {
            match item {
                schema::RoutesTypeContent::vType(Some(v_type)) => {
                    routes.vehicle_types.push(VehicleType::try_from(v_type)?);
                }
                schema::RoutesTypeContent::route(Some(route)) => {
                    routes.routes.push(Route::try_from(route)?);
                }
                schema::RoutesTypeContent::vehicle(Some(vehicle)) => {
                    routes.vehicles.push(Vehicle::try_from(vehicle)?);
                }
                // Every other `routesType` element kind (`flow`, `trip`,
                // `person`, `personFlow`, `container`, `containerFlow`,
                // `interval`, `include`, `vTypeDistribution`,
                // `routeDistribution`), and any of the 3 above that showed
                // up as `None` (shouldn't happen per the XSD, but xsd-parser
                // models each choice branch as optional) — not modeled yet,
                // see the `domain` module docs.
                _ => {}
            }
        }

        Ok(routes)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::routes::domain::EdgeRef;

    #[test]
    fn reads_depart_triggers_even_though_the_union_always_offers_them_as_clock_time_text_first() {
        // Same regression shape as the named-color test below, one union
        // level deeper: `sumoTimeType`'s own `SumoTimeType9` text-pattern
        // member is what actually absorbs `"begin"` et al. before
        // `DepartType13` is ever reached.
        let raw = schema::DepartType::sumoTimeType(schema::SumoTimeType::SumoTimeType9(
            "begin".to_string(),
        ));
        assert!(matches!(Depart::try_from(raw).unwrap(), Depart::Begin));
    }

    #[test]
    fn reads_a_named_color_even_though_the_union_always_offers_it_as_color_type_4_first() {
        // Regression test: `schema::ColorType`'s generated union
        // deserializer always resolves to `ColorType4` (an unpatterned
        // `String`, so it "matches" anything) and never actually reaches
        // `ColorType6`, even for a real named color — see the `TryFrom`
        // impl's own doc comment. Exercise the full path a real `.rou.xml`
        // takes, not just the enum conversion directly.
        for raw in ["red", "grey", "gray", "invisible", "random"] {
            let color = Color::try_from(schema::ColorType::ColorType4(raw.to_string())).unwrap();
            assert!(
                matches!(color, Color::Named(_)),
                "{raw:?} should read as a named color, got {color:?}"
            );
        }
    }

    #[test]
    fn parses_numeric_rgb_color_defaulting_alpha() {
        let color = Color::try_from("0.8,0.1,0.1").unwrap();
        assert_eq!(
            color,
            Color::Rgba {
                r: 0.8,
                g: 0.1,
                b: 0.1,
                a: 1.0
            }
        );
    }

    #[test]
    fn parses_numeric_rgba_color() {
        let color = Color::try_from("0.8,0.1,0.1,0.5").unwrap();
        assert_eq!(
            color,
            Color::Rgba {
                r: 0.8,
                g: 0.1,
                b: 0.1,
                a: 0.5
            }
        );
    }

    #[test]
    fn rejects_malformed_color() {
        assert!(Color::try_from("not-a-color").is_err());
        assert!(Color::try_from("1,2").is_err());
        // A `NaN` component would poison whatever the color is blended
        // into, the same way a `NaN` coordinate poisons a position.
        assert!(Color::try_from("NaN,0,0").is_err());
    }

    #[test]
    fn maps_both_spellings_of_grey_to_the_same_named_color() {
        assert_eq!(
            NamedColor::from(schema::ColorType6Type::grey),
            NamedColor::Grey
        );
        assert_eq!(
            NamedColor::from(schema::ColorType6Type::gray),
            NamedColor::Grey
        );
    }

    #[test]
    fn parses_clock_time_without_milliseconds() {
        use uom::si::time::second;
        let time = parse_clock_time("1:02:03").unwrap();
        assert_eq!(time.get::<second>(), 3723.0);
    }

    #[test]
    fn parses_clock_time_with_milliseconds() {
        use uom::si::time::second;
        let time = parse_clock_time("0:00:01:500").unwrap();
        assert_eq!(time.get::<second>(), 1.5);
    }

    #[test]
    fn rejects_malformed_clock_time() {
        assert!(parse_clock_time("not-a-time").is_err());
        assert!(parse_clock_time("1:02").is_err());
        assert!(parse_clock_time("NaN:02:03").is_err());
    }

    #[test]
    fn splits_whitespace_separated_edge_list() {
        assert_eq!(
            split_ids::<EdgeRef>("e0 e1 e2"),
            vec![
                EdgeRef("e0".into()),
                EdgeRef("e1".into()),
                EdgeRef("e2".into())
            ]
        );
    }
}