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#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::schema::BoolType;
258
259 #[test]
260 fn rejects_values_that_parse_as_numbers_but_are_not_finite() {
261 assert!(parse_finite("NaN", "test").is_err());
262 assert!(parse_finite("inf", "test").is_err());
263 assert!(parse_finite("-inf", "test").is_err());
264 assert!(parse_finite("not-a-number", "test").is_err());
265 }
266
267 #[test]
268 fn accepts_the_shapes_sumo_actually_writes() {
269 assert_eq!(parse_finite("12.50", "test").unwrap(), 12.5);
270 assert_eq!(parse_finite("-3", "test").unwrap(), -3.0);
271 assert_eq!(parse_finite(" 7.0 ", "test").unwrap(), 7.0);
272 }
273
274 #[test]
275 fn parses_positions_with_and_without_z() {
276 assert_eq!(
277 Point::parse("42,7", "test").unwrap(),
278 Point {
279 x: 42.0,
280 y: 7.0,
281 z: 0.0
282 }
283 );
284 assert_eq!(
285 Point::parse("42,7,1.5", "test").unwrap(),
286 Point {
287 x: 42.0,
288 y: 7.0,
289 z: 1.5
290 }
291 );
292 assert_eq!(
293 Point::parse("-1.00,-1.00", "test").unwrap(),
294 Point {
295 x: -1.0,
296 y: -1.0,
297 z: 0.0
298 }
299 );
300 }
301
302 #[test]
303 fn rejects_positions_of_the_wrong_shape_or_with_poisoned_coordinates() {
304 assert!(Point::parse("42", "test").is_err());
305 assert!(Point::parse("42,7,1,9", "test").is_err());
306 assert!(Point::parse("NaN,7", "test").is_err());
307 assert!(Point::parse("42,inf", "test").is_err());
308 }
309
310 #[test]
311 fn splits_on_any_run_of_whitespace() {
312 #[derive(Debug, PartialEq)]
313 struct Id(String);
314 impl From<String> for Id {
315 fn from(s: String) -> Self {
316 Id(s)
317 }
318 }
319
320 let ids: Vec<Id> = split_ids("a b\tc");
321 assert_eq!(ids, vec![Id("a".into()), Id("b".into()), Id("c".into())]);
322 assert!(split_ids::<Id>("").is_empty());
323 assert!(split_ids_opt::<Id>(None).is_empty());
324 }
325
326 #[test]
327 fn distinguishes_case_sensitive_bool_spellings() {
328 assert!(parse_bool(BoolType::True).unwrap());
329 assert!(parse_bool(BoolType::true_).unwrap());
330 assert!(!parse_bool(BoolType::False).unwrap());
331 assert!(!parse_bool(BoolType::false_).unwrap());
332 }
333
334 #[test]
335 fn rejects_bool_without_binary_meaning() {
336 assert!(parse_bool(BoolType::x).is_err());
337 assert!(parse_bool(BoolType::Dash).is_err());
338 assert_eq!(parse_bool_opt(None).unwrap(), None);
339 }
340
341 #[cfg(feature = "write")]
342 mod write_tests {
343 use super::*;
344
345 #[test]
346 fn formats_a_point_omitting_z_only_when_it_is_zero() {
347 assert_eq!(
348 Point {
349 x: 1.5,
350 y: -2.0,
351 z: 0.0
352 }
353 .format(),
354 "1.5,-2"
355 );
356 assert_eq!(
357 Point {
358 x: 1.5,
359 y: -2.0,
360 z: 3.0
361 }
362 .format(),
363 "1.5,-2,3"
364 );
365 }
366
367 #[test]
368 fn every_parsed_position_formats_back_to_an_equal_point() {
369 for raw in ["0,0", "42,7,1.5", "-1.00,-1.00"] {
370 let point = Point::parse(raw, "test").unwrap();
371 assert_eq!(Point::parse(&point.format(), "test").unwrap(), point);
372 }
373 }
374
375 #[test]
376 fn joins_and_splits_are_inverse_on_a_nonempty_list() {
377 let ids = ["a", "b", "c"];
378 assert_eq!(join_ids(&ids), "a b c");
379 assert_eq!(split_ids::<String>(&join_ids(&ids)), vec!["a", "b", "c"]);
380 }
381
382 #[test]
383 fn join_ids_opt_omits_the_attribute_for_an_empty_list() {
384 assert_eq!(join_ids_opt::<String>(&[]), None);
385 assert_eq!(join_ids_opt(&["a".to_string()]), Some("a".to_string()));
386 }
387
388 #[test]
389 fn formats_the_canonical_bool_spelling() {
390 // `BoolType` isn't `PartialEq` (it's generated, see `lib.rs`'s
391 // `mod schema` docs), so round-trip through `parse_bool` instead
392 // of comparing variants directly.
393 assert!(parse_bool(format_bool(true)).unwrap());
394 assert!(!parse_bool(format_bool(false)).unwrap());
395 assert!(format_bool_opt(None).is_none());
396 }
397 }
398}