Skip to main content

sumo_types/
sumo.rs

1//! Primitives shared by every format's `schema_mapper`.
2//!
3//! What lives here is knowledge about *SUMO*, not about any one of its file
4//! formats: how SUMO spells a boolean, how it encodes a number, how it
5//! writes a list of ids. Each format's mapper is otherwise its own code —
6//! the bulk of a mapper is "take these attributes, build this struct", and
7//! that part is genuinely different per format.
8//!
9//! The reason these need a home of their own rather than living in
10//! whichever mapper got them first: the format features are independent, so
11//! `additional` can't call into `net`. Before this module both of them
12//! carried their own byte-identical copy of [`parse_finite`] and of the
13//! boolean table, free to drift apart the first time one got a fix.
14
15use crate::{Error, Result};
16
17/// Parses one of SUMO's text-encoded numbers, rejecting `NaN` and the
18/// infinities.
19///
20/// SUMO declares `floatType` in `types/base.xsd` as an `xsd:string`
21/// restricted by a pattern rather than as an `xsd:float`, so xsd-parser
22/// hands these over as text for the mappers to parse. The pattern excludes
23/// `NaN` and `inf`, but xsd-parser enforces no `xsd:pattern`, and `f64`'s
24/// `FromStr` happily accepts both — so without this guard a `NaN` would
25/// flow into a position or a length and poison every computation
26/// downstream, which is exactly what the typed layer exists to prevent.
27///
28/// `context` names the value in the error message (`"junction x
29/// coordinate"`, ...).
30pub(crate) fn parse_finite(raw: &str, context: &'static str) -> Result<f64> {
31    let value: f64 = raw.trim().parse().map_err(|_| Error::InvalidNumber {
32        context,
33        value: raw.to_owned(),
34    })?;
35
36    if !value.is_finite() {
37        return Err(Error::NonFiniteNumber {
38            context,
39            value: raw.to_owned(),
40        });
41    }
42
43    Ok(value)
44}
45
46/// The inverse of [`parse_finite`]: formats a number the way SUMO's own
47/// `floatType` pattern accepts.
48///
49/// No `NaN`/infinity guard on this side — every domain value reachable from
50/// a parsed document already passed [`parse_finite`] on the way in, and a
51/// hand-built domain value with a non-finite field is a caller bug this
52/// crate has no way to see, the same way it doesn't re-validate any other
53/// domain invariant at write time.
54///
55/// `f64`'s own [`Display`](std::fmt::Display) never falls back to
56/// scientific notation the way `{:e}` would, so this is a thin, named
57/// wrapper rather than a real conversion — its purpose is the same one
58/// [`parse_finite`] serves on the read side: one place both directions of
59/// "how SUMO spells a number" live, instead of a bare `.to_string()` at
60/// every call site.
61#[cfg(feature = "write")]
62pub(crate) fn format_finite(value: f64) -> String {
63    value.to_string()
64}
65
66/// A point in the network's coordinate system (meters), with an optional
67/// `z` represented here as `0.0` by default.
68///
69/// [`Default`] is the origin.
70///
71/// Shared by every format rather than defined once per format, unlike the
72/// id newtypes next to it. Those are per-format on purpose — an
73/// `additional`'s `LaneRef` and a `net`'s `LaneId` carry the same string but
74/// mean "a lane in the file I came with" and "a lane I define", and keeping
75/// them un-mixable is worth the duplication. A point has no such reading:
76/// it is a pair of meters in one coordinate system, and `net`'s and
77/// `additional`'s were byte-identical structs that only existed separately
78/// because `additional` cannot name a type from `net`, which may not be
79/// enabled. This module *is* always compiled when any format is (see
80/// `lib.rs`), so it can hold the one definition both re-export — and with
81/// it, in `Self::parse`, the single copy of how SUMO spells a position.
82/// (Plain code span, not a link: `parse` is `pub(crate)`, so an intra-doc
83/// link to it fails `rustdoc::private_intra_doc_links` under `-D warnings`.)
84#[derive(Debug, Clone, Copy, Default, PartialEq)]
85pub struct Point {
86    pub x: f64,
87    pub y: f64,
88    pub z: f64,
89}
90
91impl Point {
92    /// Parses SUMO's `positionType` encoding: `"x,y"` or `"x,y,z"`, with `z`
93    /// reading as `0.0` when omitted.
94    ///
95    /// Takes the `context` its errors should name (`"SUMO position"`,
96    /// `"detector icon position"`, ...), which is why this is an inherent
97    /// constructor and [`TryFrom<&str>`] below is a thin wrapper over it
98    /// rather than the other way round: the same encoding spells several
99    /// different attributes, and "invalid position" without saying *which*
100    /// is the kind of error that sends someone reading a 200 MB `.net.xml`
101    /// by hand. Crate-internal for that reason — the context argument is an
102    /// error-message detail, not something a consumer should have to pass.
103    pub(crate) fn parse(raw: &str, context: &'static str) -> Result<Self> {
104        let mut parts = raw.split(',');
105        let mut next_coordinate = || -> Result<f64> {
106            let part = parts.next().ok_or(Error::IncompletePosition {
107                context,
108                value: raw.to_owned(),
109            })?;
110            parse_finite(part, context)
111        };
112
113        let x = next_coordinate()?;
114        let y = next_coordinate()?;
115        let z = match parts.next() {
116            Some(z) => parse_finite(z, context)?,
117            None => 0.0,
118        };
119
120        if parts.next().is_some() {
121            return Err(Error::TooManyCoordinates {
122                context,
123                value: raw.to_owned(),
124            });
125        }
126
127        Ok(Point { x, y, z })
128    }
129
130    /// Formats this point back into SUMO's `positionType` encoding — the
131    /// inverse of [`Self::parse`].
132    ///
133    /// Omits `z` when it is exactly `0.0`. [`Self::parse`] reads a
134    /// 2-coordinate position and a 3-coordinate one with `z="0"` into the
135    /// identical value, so there is no original spelling recorded to
136    /// reproduce either way — and writing `z` on every point would make a
137    /// flat, 2-D network (most of them) noisier than what `netconvert`
138    /// itself emits for the same data.
139    #[cfg(feature = "write")]
140    pub(crate) fn format(&self) -> String {
141        if self.z == 0.0 {
142            format!("{},{}", format_finite(self.x), format_finite(self.y))
143        } else {
144            format!(
145                "{},{},{}",
146                format_finite(self.x),
147                format_finite(self.y),
148                format_finite(self.z)
149            )
150        }
151    }
152}
153
154/// `Point::parse` under the generic name, for the common case where "SUMO
155/// position" is context enough. Lives here beside the type rather than in a
156/// format's `schema_mapper` so there is one impl of it, not one per format
157/// competing for the same `impl TryFrom<&str> for Point`.
158impl TryFrom<&str> for Point {
159    type Error = Error;
160
161    fn try_from(raw: &str) -> Result<Self> {
162        Point::parse(raw, "SUMO position")
163    }
164}
165
166/// Splits one of SUMO's whitespace-separated id lists (`incLanes`,
167/// `intLanes`, `edges`, `lanes`, ...) into the newtype the caller expects.
168///
169/// Generic over that newtype rather than fixed to one, because each format
170/// has its own — `net`'s `LaneId`, `routes`' `EdgeRef`, `additional`'s
171/// `LaneRef` — and they are deliberately unrelated types.
172pub(crate) fn split_ids<T: From<String>>(raw: &str) -> Vec<T> {
173    raw.split_whitespace()
174        .map(|id| T::from(id.to_owned()))
175        .collect()
176}
177
178/// [`split_ids`] for an attribute SUMO may omit entirely; absent reads as
179/// the empty list.
180pub(crate) fn split_ids_opt<T: From<String>>(raw: Option<&str>) -> Vec<T> {
181    raw.map(split_ids).unwrap_or_default()
182}
183
184/// The inverse of [`split_ids`]: joins a list of ids into SUMO's
185/// whitespace-separated attribute value. Never `None` — for the *required*
186/// list attributes [`split_ids`] reads (`incLanes`, `intLanes`, ...), an
187/// empty list is a legal value (a dead-end junction's `incLanes=""`), not a
188/// missing one, so the caller writes the (possibly empty) string as-is.
189///
190/// Generic over `T: AsRef<str>` rather than fixed to one id newtype, same
191/// reasoning as [`split_ids`] being generic over `T: From<String>`.
192#[cfg(feature = "write")]
193pub(crate) fn join_ids<T: AsRef<str>>(ids: &[T]) -> String {
194    ids.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" ")
195}
196
197/// [`join_ids`] for an attribute SUMO may omit entirely: `None` for an empty
198/// list, so the caller can leave the attribute out rather than writing
199/// `attr=""` — the inverse of how [`split_ids_opt`] reads a missing
200/// attribute back as an empty list. (A non-empty `raw` that splits to
201/// nothing can't happen: [`split_ids`] only ever produces `Vec::new()` from
202/// an all-whitespace string, and no writer using this helper would build one
203/// of those from anything but an already-empty list.)
204#[cfg(feature = "write")]
205pub(crate) fn join_ids_opt<T: AsRef<str>>(ids: &[T]) -> Option<String> {
206    (!ids.is_empty()).then(|| join_ids(ids))
207}
208
209/// Converts SUMO's permissive `boolType` spelling into a real `bool`.
210///
211/// SUMO accepts ten spellings across five meanings, in both cases, plus two
212/// values that aren't booleans at all: `"x"` and `"-"` are the
213/// "unspecified" marker its configuration options use, and have no binary
214/// equivalent, so they are an error rather than a silent `false`.
215pub(crate) fn parse_bool(value: crate::schema::BoolType) -> Result<bool> {
216    use crate::schema::BoolType as S;
217
218    match value {
219        S::true_ | S::True | S::yes | S::on | S::_1 => Ok(true),
220        S::false_ | S::False | S::no | S::off | S::_0 => Ok(false),
221        S::x | S::Dash => Err(Error::AmbiguousBool),
222    }
223}
224
225/// [`parse_bool`] for an attribute SUMO may omit. Absent stays absent
226/// rather than being resolved to a default here: what the default *is*
227/// depends on the attribute, so that call belongs to each format's mapper.
228pub(crate) fn parse_bool_opt(value: Option<crate::schema::BoolType>) -> Result<Option<bool>> {
229    value.map(parse_bool).transpose()
230}
231
232/// The inverse of [`parse_bool`]: always the canonical `"true"`/`"false"`
233/// spelling. [`parse_bool`] accepts SUMO's nine other spellings (`"True"`,
234/// `"yes"`, `"1"`, ...) because a reader has to cope with whatever a
235/// document contains, but nothing needs a *writer* to reproduce whichever
236/// one happened to be on the way in — a `bool` doesn't remember its
237/// spelling, so there is nothing to round-trip here the way [`Point::format`]
238/// round-trips a position's coordinates.
239#[cfg(feature = "write")]
240pub(crate) fn format_bool(value: bool) -> crate::schema::BoolType {
241    if value {
242        crate::schema::BoolType::true_
243    } else {
244        crate::schema::BoolType::false_
245    }
246}
247
248/// [`format_bool`] for an attribute this crate models as `Option<bool>`.
249#[cfg(feature = "write")]
250pub(crate) fn format_bool_opt(value: Option<bool>) -> Option<crate::schema::BoolType> {
251    value.map(format_bool)
252}
253
254/// SUMO's own named vehicle/traffic-participant classes — the tokens a
255/// `.net.xml` lane's `allow`/`disallow` attribute lists, one per
256/// space-separated word (see `net::domain::Lane::allow`/`disallow`), and
257/// also the single token a `.rou.xml` `vType`'s `vClass` attribute carries
258/// (see `routes::domain::VehicleType::vehicle_class`). Not generated from
259/// the XSD like layer 1: neither attribute is more than plain `xs:string`
260/// there, SUMO doesn't constrain either to an enumeration at the schema
261/// level. Hand-written instead, from SUMO's own vClass reference
262/// (<https://sumo.dlr.de/docs/Definition_of_Vehicles,_Vehicle_Types,_and_Routes.html#abstract_vehicle_class>)
263/// cross-checked against every distinct token actually observed across this
264/// crate's own fixtures.
265///
266/// Lives here, shared by every format, rather than in `net::domain` alone
267/// (where it originated): `routes` doesn't depend on `net` (see that
268/// module's own docs), so a `routes`-only build couldn't otherwise name it
269/// for `vehicle_class`. Re-exported as `net::domain::VClass` so `net`'s own
270/// public path is unchanged.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272pub enum VClass {
273    Aircraft,
274    Army,
275    Authority,
276    Bicycle,
277    Bus,
278    CableCar,
279    Coach,
280    Container,
281    Custom1,
282    Custom2,
283    Delivery,
284    Drone,
285    Emergency,
286    Evehicle,
287    Hov,
288    Ignoring,
289    Moped,
290    Motorcycle,
291    Passenger,
292    Pedestrian,
293    Private,
294    Rail,
295    RailElectric,
296    RailFast,
297    RailUrban,
298    Scooter,
299    Ship,
300    Subway,
301    Taxi,
302    Trailer,
303    Tram,
304    Truck,
305    Vip,
306    Wheelchair,
307}
308
309/// An `allow`/`disallow`/`vClass` token that isn't one of [`VClass`]'s own
310/// known variants — SUMO does add new vClasses across releases, and a
311/// hand-edited or newer-than-this-crate SUMO file is untrusted input either
312/// way. Carries the token back so a caller can report it, but
313/// [`VClass::permitted_on`] itself treats an unparseable token as simply
314/// "not this class" rather than failing outright — a class this crate
315/// doesn't know can't be the one a caller is asking about.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct UnknownVClass(pub String);
318
319impl std::str::FromStr for VClass {
320    type Err = UnknownVClass;
321
322    fn from_str(s: &str) -> Result<Self, Self::Err> {
323        Ok(match s {
324            "aircraft" => Self::Aircraft,
325            "army" => Self::Army,
326            "authority" => Self::Authority,
327            "bicycle" => Self::Bicycle,
328            "bus" => Self::Bus,
329            "cable_car" => Self::CableCar,
330            "coach" => Self::Coach,
331            "container" => Self::Container,
332            "custom1" => Self::Custom1,
333            "custom2" => Self::Custom2,
334            "delivery" => Self::Delivery,
335            "drone" => Self::Drone,
336            "emergency" => Self::Emergency,
337            "evehicle" => Self::Evehicle,
338            "hov" => Self::Hov,
339            "ignoring" => Self::Ignoring,
340            "moped" => Self::Moped,
341            "motorcycle" => Self::Motorcycle,
342            "passenger" => Self::Passenger,
343            "pedestrian" => Self::Pedestrian,
344            "private" => Self::Private,
345            "rail" => Self::Rail,
346            "rail_electric" => Self::RailElectric,
347            "rail_fast" => Self::RailFast,
348            "rail_urban" => Self::RailUrban,
349            "scooter" => Self::Scooter,
350            "ship" => Self::Ship,
351            "subway" => Self::Subway,
352            "taxi" => Self::Taxi,
353            "trailer" => Self::Trailer,
354            "tram" => Self::Tram,
355            "truck" => Self::Truck,
356            "vip" => Self::Vip,
357            "wheelchair" => Self::Wheelchair,
358            other => return Err(UnknownVClass(other.to_string())),
359        })
360    }
361}
362
363impl VClass {
364    /// Whether `self` may use a lane whose own `.net.xml` attributes are
365    /// `allow`/`disallow` (each already split on whitespace, one token per
366    /// class — matching `Lane::allow`/`disallow` exactly, so this is
367    /// usually called as `class.permitted_on(&lane.allow, &lane.disallow)`,
368    /// or via the `Lane::permits` shorthand).
369    ///
370    /// SUMO's own rule: a non-empty `allow` is *exhaustive* — only the
371    /// classes it names may use the lane, and `disallow` is never consulted
372    /// (SUMO's own netconvert/netedit keep the two mutually exclusive on a
373    /// single lane in the first place). An empty `allow` means every class
374    /// *except* the ones `disallow` names may — including a class this
375    /// crate doesn't know about, since the lane never said no to it.
376    ///
377    /// `"all"` is SUMO's own keyword for its entire vClass set, valid in
378    /// either attribute, rather than one specific class among them — real
379    /// data uses `disallow="all"` for a lane no vehicle class may enter at
380    /// all (e.g. a pure geometry/divider lane) — so it's checked ahead of
381    /// parsing each token as a [`VClass`], the same way a real `netconvert`
382    /// reader would have to.
383    #[must_use]
384    pub fn permitted_on(self, allow: &[String], disallow: &[String]) -> bool {
385        let names_self = |list: &[String]| {
386            list.iter()
387                .any(|token| token == "all" || token.parse::<VClass>() == Ok(self))
388        };
389        if allow.is_empty() {
390            !names_self(disallow)
391        } else {
392            names_self(allow)
393        }
394    }
395
396    /// The exact spelling [`std::str::FromStr::from_str`] reads back into
397    /// this variant. `Custom1`/`Custom2` round-trip losslessly; every other
398    /// variant does too, `FromStr` having exactly one spelling per variant
399    /// (the `impl` above has no `|`-combined arm on this direction, only
400    /// `grey`/`gray` do that and only on read — nothing here writes SUMO's
401    /// deprecated `gray` spelling back out).
402    #[must_use]
403    pub fn as_str(self) -> &'static str {
404        match self {
405            Self::Aircraft => "aircraft",
406            Self::Army => "army",
407            Self::Authority => "authority",
408            Self::Bicycle => "bicycle",
409            Self::Bus => "bus",
410            Self::CableCar => "cable_car",
411            Self::Coach => "coach",
412            Self::Container => "container",
413            Self::Custom1 => "custom1",
414            Self::Custom2 => "custom2",
415            Self::Delivery => "delivery",
416            Self::Drone => "drone",
417            Self::Emergency => "emergency",
418            Self::Evehicle => "evehicle",
419            Self::Hov => "hov",
420            Self::Ignoring => "ignoring",
421            Self::Moped => "moped",
422            Self::Motorcycle => "motorcycle",
423            Self::Passenger => "passenger",
424            Self::Pedestrian => "pedestrian",
425            Self::Private => "private",
426            Self::Rail => "rail",
427            Self::RailElectric => "rail_electric",
428            Self::RailFast => "rail_fast",
429            Self::RailUrban => "rail_urban",
430            Self::Scooter => "scooter",
431            Self::Ship => "ship",
432            Self::Subway => "subway",
433            Self::Taxi => "taxi",
434            Self::Trailer => "trailer",
435            Self::Tram => "tram",
436            Self::Truck => "truck",
437            Self::Vip => "vip",
438            Self::Wheelchair => "wheelchair",
439        }
440    }
441}
442
443impl std::fmt::Display for VClass {
444    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445        f.write_str(self.as_str())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::schema::BoolType;
453
454    #[test]
455    fn rejects_values_that_parse_as_numbers_but_are_not_finite() {
456        assert!(parse_finite("NaN", "test").is_err());
457        assert!(parse_finite("inf", "test").is_err());
458        assert!(parse_finite("-inf", "test").is_err());
459        assert!(parse_finite("not-a-number", "test").is_err());
460    }
461
462    #[test]
463    fn accepts_the_shapes_sumo_actually_writes() {
464        assert_eq!(parse_finite("12.50", "test").unwrap(), 12.5);
465        assert_eq!(parse_finite("-3", "test").unwrap(), -3.0);
466        assert_eq!(parse_finite("  7.0  ", "test").unwrap(), 7.0);
467    }
468
469    #[test]
470    fn parses_positions_with_and_without_z() {
471        assert_eq!(
472            Point::parse("42,7", "test").unwrap(),
473            Point {
474                x: 42.0,
475                y: 7.0,
476                z: 0.0
477            }
478        );
479        assert_eq!(
480            Point::parse("42,7,1.5", "test").unwrap(),
481            Point {
482                x: 42.0,
483                y: 7.0,
484                z: 1.5
485            }
486        );
487        assert_eq!(
488            Point::parse("-1.00,-1.00", "test").unwrap(),
489            Point {
490                x: -1.0,
491                y: -1.0,
492                z: 0.0
493            }
494        );
495    }
496
497    #[test]
498    fn rejects_positions_of_the_wrong_shape_or_with_poisoned_coordinates() {
499        assert!(Point::parse("42", "test").is_err());
500        assert!(Point::parse("42,7,1,9", "test").is_err());
501        assert!(Point::parse("NaN,7", "test").is_err());
502        assert!(Point::parse("42,inf", "test").is_err());
503    }
504
505    #[test]
506    fn splits_on_any_run_of_whitespace() {
507        #[derive(Debug, PartialEq)]
508        struct Id(String);
509        impl From<String> for Id {
510            fn from(s: String) -> Self {
511                Id(s)
512            }
513        }
514
515        let ids: Vec<Id> = split_ids("a  b\tc");
516        assert_eq!(ids, vec![Id("a".into()), Id("b".into()), Id("c".into())]);
517        assert!(split_ids::<Id>("").is_empty());
518        assert!(split_ids_opt::<Id>(None).is_empty());
519    }
520
521    #[test]
522    fn distinguishes_case_sensitive_bool_spellings() {
523        assert!(parse_bool(BoolType::True).unwrap());
524        assert!(parse_bool(BoolType::true_).unwrap());
525        assert!(!parse_bool(BoolType::False).unwrap());
526        assert!(!parse_bool(BoolType::false_).unwrap());
527    }
528
529    #[test]
530    fn rejects_bool_without_binary_meaning() {
531        assert!(parse_bool(BoolType::x).is_err());
532        assert!(parse_bool(BoolType::Dash).is_err());
533        assert_eq!(parse_bool_opt(None).unwrap(), None);
534    }
535
536    #[cfg(feature = "write")]
537    mod write_tests {
538        use super::*;
539
540        #[test]
541        fn formats_a_point_omitting_z_only_when_it_is_zero() {
542            assert_eq!(
543                Point {
544                    x: 1.5,
545                    y: -2.0,
546                    z: 0.0
547                }
548                .format(),
549                "1.5,-2"
550            );
551            assert_eq!(
552                Point {
553                    x: 1.5,
554                    y: -2.0,
555                    z: 3.0
556                }
557                .format(),
558                "1.5,-2,3"
559            );
560        }
561
562        #[test]
563        fn every_parsed_position_formats_back_to_an_equal_point() {
564            for raw in ["0,0", "42,7,1.5", "-1.00,-1.00"] {
565                let point = Point::parse(raw, "test").unwrap();
566                assert_eq!(Point::parse(&point.format(), "test").unwrap(), point);
567            }
568        }
569
570        #[test]
571        fn joins_and_splits_are_inverse_on_a_nonempty_list() {
572            let ids = ["a", "b", "c"];
573            assert_eq!(join_ids(&ids), "a b c");
574            assert_eq!(split_ids::<String>(&join_ids(&ids)), vec!["a", "b", "c"]);
575        }
576
577        #[test]
578        fn join_ids_opt_omits_the_attribute_for_an_empty_list() {
579            assert_eq!(join_ids_opt::<String>(&[]), None);
580            assert_eq!(join_ids_opt(&["a".to_string()]), Some("a".to_string()));
581        }
582
583        #[test]
584        fn formats_the_canonical_bool_spelling() {
585            // `BoolType` isn't `PartialEq` (it's generated, see `lib.rs`'s
586            // `mod schema` docs), so round-trip through `parse_bool` instead
587            // of comparing variants directly.
588            assert!(parse_bool(format_bool(true)).unwrap());
589            assert!(!parse_bool(format_bool(false)).unwrap());
590            assert!(format_bool_opt(None).is_none());
591        }
592    }
593}