LCOV - code coverage report
Current view: top level - antares-model/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 254 254
Test Date: 2026-11-02 13:21:27 Functions: 100.0 % 45 45

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! NGSI-LD data model (ETSI CIM 009 V1.9.1).
       3              : //!
       4              : //! Shapes and invariants only: no I/O, no clocks, no config.
       5              : //!
       6              : #![doc = include_str!("../README.md")]
       7              : // every surface calls into this crate on a request path, where a
       8              : // panic is a remote DoS. Outside tests it may not unwrap, expect, panic,
       9              : // index out of bounds or overflow.
      10              : #![cfg_attr(
      11              :     not(test),
      12              :     deny(
      13              :         clippy::unwrap_used,
      14              :         clippy::expect_used,
      15              :         clippy::panic,
      16              :         clippy::indexing_slicing,
      17              :         clippy::arithmetic_side_effects
      18              :     )
      19              : )]
      20              : #![deny(missing_docs)]
      21              : 
      22              : pub mod error;
      23              : pub mod id;
      24              : pub mod operations;
      25              : 
      26              : pub use error::{NgsiError, ProblemDetails};
      27              : pub use id::{EntityId, TenantId};
      28              : 
      29              : /// API root path (CIM 009 clause 6.2).
      30              : pub const API_ROOT: &str = "/ngsi-ld/v1";
      31              : 
      32              : /// Egress key order for a served payload: every object serializes `id`
      33              : /// then `type` first, recursively (an attribute object leads with
      34              : /// `"type": "Property"`, a GeoJSON Feature with `id`/`type` — the order the
      35              : /// spec's own examples print). Cosmetic only: RFC 8259 objects are unordered
      36              : /// and CIM 009 4.5.1 mandates presence, not position. Applied ONLY at egress
      37              : /// (responses and notifications) — internal serialization (storage, temporal
      38              : /// diff) stays byte-stable alphabetical and must not use this.
      39              : pub struct SpecOrder<'a>(pub &'a serde_json::Value);
      40              : 
      41              : impl serde::Serialize for SpecOrder<'_> {
      42       295886 :     fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
      43              :         use serde::ser::SerializeMap;
      44              :         use serde_json::Value;
      45       295886 :         match self.0 {
      46        73074 :             Value::Object(m) => {
      47        73074 :                 let mut map = s.serialize_map(Some(m.len()))?;
      48       146148 :                 for k in ["id", "type"] {
      49       146148 :                     if let Some(v) = m.get(k) {
      50        97620 :                         map.serialize_entry(k, &SpecOrder(v))?;
      51        48528 :                     }
      52              :                 }
      53       244538 :                 for (k, v) in m {
      54       244538 :                     if k != "id" && k != "type" {
      55       146918 :                         map.serialize_entry(k, &SpecOrder(v))?;
      56        97620 :                     }
      57              :                 }
      58        73074 :                 map.end()
      59              :             }
      60        18563 :             Value::Array(a) => s.collect_seq(a.iter().map(SpecOrder)),
      61       204249 :             other => other.serialize(s),
      62              :         }
      63       295886 :     }
      64              : }
      65              : 
      66              : /// Serialize a response or notification payload in egress key order
      67              : /// (serializing a `Value` cannot fail).
      68         8878 : pub fn ordered_vec(v: &serde_json::Value) -> Vec<u8> {
      69         8878 :     serde_json::to_vec(&SpecOrder(v)).unwrap_or_default()
      70         8878 : }
      71              : 
      72              : /// TS 104 175 Table 5.2.6.6.5-1: the RegistrationInfo members that name
      73              : /// the Attributes a registration scopes. `attributeNames` replaces the
      74              : /// deprecated pair, which is "only used if attributeNames is not present".
      75              : pub const REGISTRATION_ATTRIBUTE_MEMBERS: [&str; 3] =
      76              :     ["attributeNames", "propertyNames", "relationshipNames"];
      77              : 
      78              : /// The Attribute names one RegistrationInfo scopes (TS 104 175 9.4.2):
      79              : /// `attributeNames` when present, else `propertyNames` and
      80              : /// `relationshipNames` together; `None` when it names none, so the
      81              : /// registration scopes every Attribute of its entities. The one reading
      82              : /// every matcher shares: a matcher that reads only the old pair treats an
      83              : /// `attributeNames`-only registration as unscoped and forwards it every
      84              : /// query.
      85         7800 : pub fn registration_attribute_names(info: &serde_json::Value) -> Option<Vec<&str>> {
      86         5376 :     fn strings(list: &[serde_json::Value]) -> Vec<&str> {
      87         5376 :         list.iter().filter_map(serde_json::Value::as_str).collect()
      88         5376 :     }
      89        23364 :     let names = |key: &str| info.get(key).and_then(serde_json::Value::as_array);
      90         7800 :     if let Some(attributes) = names("attributeNames") {
      91           18 :         return Some(strings(attributes));
      92         7782 :     }
      93         7782 :     let (properties, relationships) = (names("propertyNames"), names("relationshipNames"));
      94         7782 :     if properties.is_none() && relationships.is_none() {
      95         2482 :         return None;
      96         5300 :     }
      97              :     Some(
      98         5300 :         properties
      99         5300 :             .into_iter()
     100         5300 :             .chain(relationships)
     101         5358 :             .flat_map(|list| strings(list))
     102         5300 :             .collect(),
     103              :     )
     104         7800 : }
     105              : 
     106              : /// 5.2.4 Entity, Table 5.2.4-1, with the common members of Table 5.2.2-1:
     107              : /// the members of an Entity document that are not Attributes. Every other
     108              : /// member is a Property or a Relationship (`location` and the two other
     109              : /// default GeoProperties included), so this list is what every layer that
     110              : /// has to tell an attribute from an entity member reads — the query
     111              : /// projection, the notification diff, the temporal split, the outbox event.
     112              : /// A layer with its own copy is a layer that will disagree with the others
     113              : /// about what an attribute is.
     114              : ///
     115              : /// `@context` is in the list because a stored or rendered document carries
     116              : /// it and it is not an Attribute either. It is the one member Table 5.2.4-1
     117              : /// does not name, which is why [`is_meta`] — the Entity's OWN members, the
     118              : /// question 5.2.4 asks — leaves it out.
     119              : pub const ENTITY_META_KEYS: &[&str] = &[
     120              :     "id",
     121              :     "type",
     122              :     "scope",
     123              :     "createdAt",
     124              :     "modifiedAt",
     125              :     "deletedAt",
     126              :     "expiresAt",
     127              :     "@context",
     128              : ];
     129              : 
     130              : /// The members of an Entity that are not Attributes — `id`, `type`,
     131              : /// `scope`, `expiresAt`, `createdAt`, `modifiedAt`, `deletedAt`. See
     132              : /// [`ENTITY_META_KEYS`] for the document-level list this narrows.
     133       351290 : pub fn is_meta(k: &str) -> bool {
     134       351290 :     k != "@context" && ENTITY_META_KEYS.contains(&k)
     135       351290 : }
     136              : 
     137              : /// Canonical lexicographic comparison key for a 4.6.3 DateTime: the trailing
     138              : /// `Z` dropped and the optional seconds fraction (`.` or the request-side `,`
     139              : /// separator) zero-padded to six digits, so string order equals temporal
     140              : /// order across spellings of the same instant. Non-DateTime input is
     141              : /// returned as-is (callers validated at write/parse time).
     142       334863 : pub fn dt_key(s: &str) -> String {
     143       334863 :     let Some(body) = s.strip_suffix('Z') else {
     144          204 :         return s.to_owned();
     145              :     };
     146       334659 :     if !body.is_char_boundary(19) {
     147            6 :         return s.to_owned();
     148       334653 :     }
     149       334653 :     let (base, frac) = body.split_at(19);
     150       334653 :     let digits = frac
     151       334653 :         .strip_prefix('.')
     152       334653 :         .or_else(|| frac.strip_prefix(','))
     153       334653 :         .unwrap_or("");
     154       334653 :     format!("{base}.{digits:0<6}")
     155       334863 : }
     156              : 
     157              : /// One ISO 8601 duration — `P[nY][nM][nW][nD][T[nH][nM][nS]]` — read into
     158              : /// its components. Three NGSI-LD members carry this syntax and weigh it
     159              : /// differently: a Context Source registration's refresh rate (5.2.9) and an
     160              : /// EntityMap's `entityMapLifetime` (Table 6.4.3.2-1) want a span in seconds,
     161              : /// where a month is a nominal thirty days, while a temporal aggregation
     162              : /// period (4.5.19) keeps months as calendar months because that is what its
     163              : /// buckets are cut on. The scan is one function; the weighing belongs to
     164              : /// the caller.
     165              : #[derive(Debug, Clone, Copy, Default, PartialEq)]
     166              : pub struct IsoDuration {
     167              :     /// `nY`.
     168              :     pub years: f64,
     169              :     /// `nM` before the `T` — calendar months.
     170              :     pub months: f64,
     171              :     /// `nW`.
     172              :     pub weeks: f64,
     173              :     /// `nD`.
     174              :     pub days: f64,
     175              :     /// `nH`.
     176              :     pub hours: f64,
     177              :     /// `nM` after the `T`.
     178              :     pub minutes: f64,
     179              :     /// `nS`.
     180              :     pub seconds: f64,
     181              :     /// Every component present is a plain digit run within `i64`: no
     182              :     /// fraction, no magnitude a whole-second span could not hold.
     183              :     pub whole: bool,
     184              :     /// No component at all — a bare `P`.
     185              :     pub empty: bool,
     186              : }
     187              : 
     188              : /// Read the syntax, in the designator order ISO 8601 fixes. `None` for
     189              : /// anything that is not it: a missing `P`, a component with no digit, a
     190              : /// designator out of order, repeated or in the wrong half, a number that
     191              : /// does not parse, digits with no designator to weigh them, or a `T` with
     192              : /// no time component after it.
     193          548 : pub fn parse_iso_duration(s: &str) -> Option<IsoDuration> {
     194              :     /// One half of the duration — the date designators or the time ones.
     195              :     /// `out` takes the values in `units` order; the answer is whether the
     196              :     /// half carried anything.
     197          726 :     fn scan(part: &str, units: &[char], out: &mut [f64], whole: &mut bool) -> Option<bool> {
     198          726 :         let mut p = part;
     199              :         // each designator is read at most once and in order: the search for
     200              :         // the next one starts after the last one matched
     201          726 :         let mut next = 0usize;
     202          726 :         let mut any = false;
     203         1138 :         while !p.is_empty() {
     204         2402 :             let i = p.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))?;
     205          528 :             let (num, rest) = p.split_at(i);
     206          528 :             let unit = rest.chars().next()?;
     207          528 :             let (slot, _) = units
     208          528 :                 .iter()
     209          528 :                 .enumerate()
     210          528 :                 .skip(next)
     211         1242 :                 .find(|(_, u)| **u == unit)?;
     212          434 :             next = slot.saturating_add(1);
     213          444 :             if !num.bytes().any(|b| b.is_ascii_digit()) {
     214           18 :                 return None;
     215          416 :             }
     216          416 :             *whole &= num.parse::<i64>().is_ok();
     217              :             // 4.6.3 leaves the fraction separator open, here as everywhere
     218          416 :             let value: f64 = num.replace(',', ".").parse().ok()?;
     219          414 :             if !value.is_finite() {
     220            2 :                 return None;
     221          412 :             }
     222          412 :             *out.get_mut(slot)? = value;
     223          412 :             any = true;
     224          412 :             p = &rest[unit.len_utf8()..];
     225              :         }
     226          590 :         Some(any)
     227          726 :     }
     228              : 
     229          548 :     let rest = s.strip_prefix('P')?;
     230          456 :     let (date, time) = match rest.split_once('T') {
     231          270 :         Some((d, t)) => (d, Some(t)),
     232          186 :         None => (rest, None),
     233              :     };
     234          456 :     let mut v = [0f64; 7];
     235          456 :     let mut whole = true;
     236          456 :     let date_any = scan(date, &['Y', 'M', 'W', 'D'], &mut v[..4], &mut whole)?;
     237          356 :     let time_any = match time {
     238           86 :         None => false,
     239          270 :         Some(t) => {
     240              :             // a `T` with nothing to designate is not a duration
     241          270 :             if !scan(t, &['H', 'M', 'S'], &mut v[4..], &mut whole)? {
     242           20 :                 return None;
     243          214 :             }
     244          214 :             true
     245              :         }
     246              :     };
     247              :     Some(IsoDuration {
     248          300 :         years: v[0],
     249          300 :         months: v[1],
     250          300 :         weeks: v[2],
     251          300 :         days: v[3],
     252          300 :         hours: v[4],
     253          300 :         minutes: v[5],
     254          300 :         seconds: v[6],
     255          300 :         whole,
     256          300 :         empty: !date_any && !time_any,
     257              :     })
     258          548 : }
     259              : 
     260              : /// Attribute names in paths must be valid terms/IRIs (4.6.2) — 400 otherwise.
     261         1261 : pub fn check_attr_name(attr: &str) -> Result<(), NgsiError> {
     262              :     // 4.6.2 supported names: no '@' (keyword territory), no parens/quotes/etc.
     263         1261 :     let ok = !attr.is_empty()
     264         1254 :         && attr
     265         1254 :             .chars()
     266         7903 :             .all(|c| c.is_ascii_alphanumeric() || "_:.#/%-+".contains(c))
     267         1227 :         && !has_dot_segment(attr);
     268         1261 :     if ok {
     269         1124 :         Ok(())
     270              :     } else {
     271          137 :         Err(NgsiError::BadRequestData(format!(
     272          137 :             "invalid attribute name {attr:?}"
     273          137 :         )))
     274              :     }
     275         1261 : }
     276              : 
     277              : /// A 4.6.2 name begins with a letter, so no valid Attribute name is a relative
     278              : /// path dot-segment (RFC 3986 clause 5.2.4). The name is interpolated into the
     279              : /// request URLs of forwarded operations, where a `.`/`..` segment addresses a
     280              : /// different resource of the registration endpoint — `/entities/{id}/attrs/..`
     281              : /// is that endpoint's Entity resource, and a URL parser resolves the segment
     282              : /// before the request leaves this process. Percent triplets are folded once
     283              : /// first, because the endpoint decodes the path it is given.
     284              : ///
     285              : /// The name a client sends is checked by [`check_attr_name`], but that is not
     286              : /// the only form that reaches a path: 4.3.6.6 compacts the name again with a
     287              : /// registered `@context`, which is client-supplied and may bind any term. The
     288              : /// compacted form is held to this same rule before it is written into a
     289              : /// forwarded URL.
     290         1241 : pub fn has_dot_segment(attr: &str) -> bool {
     291         1241 :     attr.to_ascii_lowercase()
     292         1241 :         .replace("%2e", ".")
     293         1241 :         .replace("%2f", "/")
     294         1241 :         .split('/')
     295         1345 :         .any(|seg| seg == "." || seg == "..")
     296         1241 : }
     297              : 
     298              : #[cfg(test)]
     299              : mod tests {
     300              :     use super::dt_key;
     301              : 
     302              :     #[test]
     303            2 :     fn attribute_names_come_first_and_the_old_pair_only_without_them() {
     304              :         use super::registration_attribute_names as names;
     305              :         use serde_json::json;
     306            2 :         let both = json!({"attributeNames": ["speed"], "propertyNames": ["brand"],
     307            2 :                           "relationshipNames": ["owner"]});
     308            2 :         assert_eq!(names(&both), Some(vec!["speed"]));
     309            2 :         let old = json!({"propertyNames": ["brand"], "relationshipNames": ["owner"]});
     310            2 :         assert_eq!(names(&old), Some(vec!["brand", "owner"]));
     311            2 :         assert_eq!(
     312            2 :             names(&json!({"relationshipNames": ["owner"]})),
     313            2 :             Some(vec!["owner"])
     314              :         );
     315            2 :         assert_eq!(names(&json!({"entities": []})), None);
     316            2 :         assert_eq!(names(&json!({"attributeNames": []})), Some(vec![]));
     317            2 :     }
     318              :     #[test]
     319            2 :     fn meta_members_are_the_non_attribute_members_of_an_entity() {
     320           14 :         for k in [
     321            2 :             "id",
     322            2 :             "type",
     323            2 :             "scope",
     324            2 :             "createdAt",
     325            2 :             "modifiedAt",
     326            2 :             "deletedAt",
     327            2 :             "expiresAt",
     328            2 :         ] {
     329           14 :             assert!(super::is_meta(k), "{k}");
     330              :         }
     331           20 :         for k in [
     332            2 :             "",
     333            2 :             "v",
     334            2 :             "Type",
     335            2 :             "location",
     336            2 :             "observationSpace",
     337            2 :             "operationSpace",
     338            2 :             "speed",
     339            2 :             "@context",
     340            2 :             "observedAt",
     341            2 :             "datasetId",
     342            2 :         ] {
     343           20 :             assert!(!super::is_meta(k), "{k}");
     344              :         }
     345            2 :     }
     346              : 
     347              :     /// 4.6.3 DateTime: only a DateTime has a canonical key — anything else
     348              :     /// is returned unchanged, including a multi-byte string that ends in
     349              :     /// `Z` and is long enough to reach the seconds position in bytes.
     350              :     #[test]
     351            2 :     fn non_datetime_input_is_returned_unchanged() {
     352           10 :         for s in ["", "Z", "not-a-date", "ααααααααααZ", "urn:ngsi-ld:nullZ"] {
     353           10 :             assert_eq!(dt_key(s), s, "{s:?}");
     354              :         }
     355              :         // a real DateTime still normalizes to its comparison key
     356            2 :         assert_eq!(dt_key("2026-05-01T00:00:00Z"), "2026-05-01T00:00:00.000000");
     357            2 :         assert_eq!(
     358            2 :             dt_key("2026-05-01T00:00:00,5Z"),
     359              :             "2026-05-01T00:00:00.500000"
     360              :         );
     361            2 :     }
     362              : 
     363              :     /// One scan serves three weighings, so the syntax it accepts is the
     364              :     /// syntax all three accept: designators in ISO order, each at most once
     365              :     /// and in its own half, every component a number with a digit.
     366              :     #[test]
     367            2 :     fn a_duration_is_read_into_its_components() {
     368            2 :         let d = super::parse_iso_duration("P3Y6M4WT12H30M5.5S").expect("a duration");
     369            2 :         assert_eq!((d.years, d.months, d.weeks, d.days), (3.0, 6.0, 4.0, 0.0));
     370            2 :         assert_eq!((d.hours, d.minutes, d.seconds), (12.0, 30.0, 5.5));
     371            2 :         assert!(!d.whole, "a fractional component is not whole");
     372            2 :         assert!(!d.empty);
     373              :         // 4.6.3 leaves the fraction separator open
     374            2 :         assert_eq!(
     375            2 :             super::parse_iso_duration("PT0,5S").map(|d| d.seconds),
     376              :             Some(0.5)
     377              :         );
     378              :         // a bare P carries nothing to weigh, and is the only accepted shape
     379              :         // that carries nothing
     380            2 :         let bare = super::parse_iso_duration("P").expect("a bare P scans");
     381            2 :         assert!(bare.empty);
     382            2 :         assert!(bare.whole);
     383           48 :         for bad in [
     384            2 :             "",
     385            2 :             "PT",
     386            2 :             "P1DT", // a T with no time component after it
     387            2 :             "1Y",
     388            2 :             "P1",
     389            2 :             "PT1H1", // digits with no designator
     390            2 :             "P1X",
     391            2 :             "p1d",
     392            2 :             "P-1D",
     393            2 :             "P+1D",
     394            2 :             "P 1D",
     395            2 :             "PT1H ",
     396            2 :             " PT1H",
     397            2 :             "1PD",
     398            2 :             "P1H",
     399            2 :             "PT1D", // a designator in the wrong half
     400            2 :             "P1D2M",
     401            2 :             "PT1S1S", // out of order, and repeated
     402            2 :             "P,D",
     403            2 :             "P.D",
     404            2 :             "P..D",
     405            2 :             "P1.2.3D", // not a number
     406            2 :             "P\u{661}D",
     407            2 :             "P1D\u{0}",
     408            2 :         ] {
     409           48 :             assert_eq!(super::parse_iso_duration(bad), None, "{bad:?}");
     410              :         }
     411            2 :     }
     412              : 
     413              :     /// `whole` is what an EntityMap lifetime asks: the component is a plain
     414              :     /// digit run an `i64` of seconds can still hold.
     415              :     #[test]
     416            2 :     fn a_magnitude_past_i64_is_not_whole() {
     417            6 :         for s in ["PT99999999999999999999S", "PT1.5S", "PT1,5S"] {
     418            6 :             let d = super::parse_iso_duration(s).expect("it scans");
     419            6 :             assert!(!d.whole, "{s:?}");
     420              :         }
     421            6 :         for s in ["PT9223372036854775807S", "P0D", "PT0S"] {
     422            6 :             assert!(
     423            6 :                 super::parse_iso_duration(s).expect("it scans").whole,
     424              :                 "{s:?}"
     425              :             );
     426              :         }
     427            2 :     }
     428              : 
     429              :     /// The two views of the same list stay one list: `is_meta` answers
     430              :     /// Table 5.2.4-1's question — the Entity's OWN members — and
     431              :     /// `ENTITY_META_KEYS` answers the document's, which is the same set plus
     432              :     /// the `@context` a stored or rendered document carries.
     433              :     #[test]
     434            2 :     fn the_document_list_and_the_entity_list_agree() {
     435           16 :         for k in super::ENTITY_META_KEYS {
     436           16 :             assert_eq!(
     437           16 :                 super::is_meta(k),
     438           16 :                 *k != "@context",
     439              :                 "{k} is in the document list"
     440              :             );
     441              :         }
     442            2 :         assert_eq!(
     443            2 :             super::ENTITY_META_KEYS.len(),
     444              :             8,
     445              :             "seven Entity members plus @context"
     446              :         );
     447            8 :         for k in [
     448            2 :             "location",
     449            2 :             "speed",
     450            2 :             "https://uri.etsi.org/ngsi-ld/default-context/x",
     451            2 :             "",
     452            2 :         ] {
     453            8 :             assert!(!super::is_meta(k), "{k:?} is an Attribute, not a member");
     454            8 :             assert!(!super::ENTITY_META_KEYS.contains(&k), "{k:?}");
     455              :         }
     456            2 :     }
     457              : 
     458              :     /// Egress order: `id` then `type` lead every object, at every depth,
     459              :     /// arrays included; scalars pass through untouched.
     460              :     #[test]
     461            2 :     fn spec_order_leads_with_id_and_type_at_every_depth() {
     462              :         use serde_json::json;
     463            2 :         let v =
     464            2 :             json!({"z": 1, "type": "T", "a": [{"value": 2, "type": "Property"}], "id": "urn:x"});
     465            2 :         let out = String::from_utf8(super::ordered_vec(&v)).expect("utf-8");
     466            2 :         assert_eq!(
     467              :             out,
     468              :             r#"{"id":"urn:x","type":"T","a":[{"type":"Property","value":2}],"z":1}"#
     469              :         );
     470            2 :         assert_eq!(super::ordered_vec(&json!(null)), b"null");
     471            2 :         assert_eq!(super::ordered_vec(&json!({})), b"{}");
     472            2 :     }
     473              : 
     474              :     /// 4.6.2: a name is non-empty, from the allowed characters, and never a
     475              :     /// dot-segment however it is percent-spelled.
     476              :     #[test]
     477            2 :     fn attribute_names_and_dot_segments() {
     478              :         use super::{check_attr_name, has_dot_segment};
     479           10 :         for ok in ["speed", "ngsi-ld:x", "https://e.org/a#b", "a%20b", "a.b"] {
     480           10 :             assert!(check_attr_name(ok).is_ok(), "{ok:?}");
     481              :         }
     482           18 :         for bad in [
     483            2 :             "", "@id", "a b", "a(b)", "..", "a/./b", "%2E%2e", "a%2F..", "ü",
     484            2 :         ] {
     485           18 :             assert!(check_attr_name(bad).is_err(), "{bad:?}");
     486              :         }
     487            2 :         assert!(has_dot_segment("x/%2e"));
     488            2 :         assert!(!has_dot_segment("..a"));
     489            2 :     }
     490              : 
     491              :     /// A magnitude that overflows f64 is not a duration.
     492              :     #[test]
     493            2 :     fn a_duration_too_large_for_f64_is_refused() {
     494            2 :         let huge = format!("P{}Y", "9".repeat(400));
     495            2 :         assert!(super::parse_iso_duration(&huge).is_none());
     496            2 :         assert!(super::parse_iso_duration("P1Y").is_some());
     497            2 :     }
     498              : }
        

Generated by: LCOV version 2.0-1