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#[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}