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}