Skip to main content

openbim_dt/
value.rs

1//! ISO 23387 lexical value contracts.
2
3use std::{error::Error, fmt, str::FromStr};
4
5/// A value rejected by an ISO 23387 lexical contract.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ValueError {
8    kind: ValueErrorKind,
9    value: String,
10}
11
12impl ValueError {
13    fn new(kind: ValueErrorKind, value: impl Into<String>) -> Self {
14        Self {
15            kind,
16            value: value.into(),
17        }
18    }
19
20    /// The failed lexical contract.
21    #[must_use]
22    pub const fn kind(&self) -> ValueErrorKind {
23        self.kind
24    }
25}
26
27impl fmt::Display for ValueError {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(formatter, "invalid {:?} value {:?}", self.kind, self.value)
30    }
31}
32
33impl Error for ValueError {}
34
35/// Lexical contracts checked by [`ValueError`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum ValueErrorKind {
38    /// ISO 23387 `GuidType`.
39    Guid,
40    /// XML Schema `language`.
41    Language,
42    /// ISO 23387 `RationalType`.
43    Rational,
44    /// XML Schema `decimal`.
45    Decimal,
46    /// XML Schema `positiveInteger`.
47    PositiveInteger,
48    /// A semantically identified reference with neither GUID nor URI.
49    EmptyReference,
50    /// XML Schema `dateTime`.
51    CreationDate,
52    /// XML Schema `anyURI`.
53    Uri,
54}
55
56/// An ISO 23387 GUID, preserving its validated source spelling.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct Guid(String);
59
60impl Guid {
61    /// Returns the original validated lexical value.
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl FromStr for Guid {
69    type Err = ValueError;
70
71    fn from_str(value: &str) -> Result<Self, Self::Err> {
72        const HYPHENS: [usize; 4] = [8, 13, 18, 23];
73        let valid = value.len() == 36
74            && value.bytes().enumerate().all(|(index, byte)| {
75                if HYPHENS.contains(&index) {
76                    byte == b'-'
77                } else {
78                    byte.is_ascii_hexdigit()
79                }
80            });
81        valid
82            .then(|| Self(value.to_owned()))
83            .ok_or_else(|| ValueError::new(ValueErrorKind::Guid, value))
84    }
85}
86
87impl fmt::Display for Guid {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter.write_str(&self.0)
90    }
91}
92
93/// XML Schema `language` used by DT and importing standards.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct Language(String);
96
97impl Language {
98    #[must_use]
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104impl FromStr for Language {
105    type Err = ValueError;
106
107    fn from_str(value: &str) -> Result<Self, Self::Err> {
108        let value = collapse_whitespace(value);
109        is_language(&value)
110            .then(|| Self(value.clone()))
111            .ok_or_else(|| ValueError::new(ValueErrorKind::Language, value))
112    }
113}
114
115/// XML Schema `dateTime` value after whitespace collapsing and lexical validation.
116#[derive(Debug, Clone, PartialEq, Eq, Hash)]
117pub struct DateTime(String);
118
119impl DateTime {
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124}
125
126impl FromStr for DateTime {
127    type Err = ValueError;
128
129    fn from_str(value: &str) -> Result<Self, Self::Err> {
130        let value = collapse_whitespace(value);
131        is_xs_datetime(&value)
132            .then(|| Self(value.clone()))
133            .ok_or_else(|| ValueError::new(ValueErrorKind::CreationDate, value))
134    }
135}
136
137/// XML Schema 1.0 `anyURI` after whitespace collapsing.
138///
139/// Its lexical space is broader than an ASCII URI-reference: XML Schema's
140/// escaping procedure admits Unicode and spaces that become percent-encoded in
141/// the corresponding URI. The stored value retains that pre-escaped spelling.
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143pub struct AnyUri(String);
144
145impl AnyUri {
146    #[must_use]
147    pub fn as_str(&self) -> &str {
148        &self.0
149    }
150}
151
152impl FromStr for AnyUri {
153    type Err = ValueError;
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        let value = collapse_whitespace(value);
157        value
158            .chars()
159            .all(is_xml_10_character)
160            .then(|| Self(value.clone()))
161            .ok_or_else(|| ValueError::new(ValueErrorKind::Uri, value))
162    }
163}
164
165/// ISO 23387 multilingual text.
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub struct MultiLanguageText {
168    language: Language,
169    text: String,
170}
171
172impl MultiLanguageText {
173    /// Creates text after validating the XML Schema `language` lexeme.
174    pub fn new(language: impl Into<String>, text: impl Into<String>) -> Result<Self, ValueError> {
175        let language = language.into().parse()?;
176        Ok(Self {
177            language,
178            text: text.into(),
179        })
180    }
181
182    /// Language tag exactly as supplied.
183    #[must_use]
184    pub fn language(&self) -> &str {
185        self.language.as_str()
186    }
187
188    /// Text value exactly as supplied.
189    #[must_use]
190    pub fn text(&self) -> &str {
191        &self.text
192    }
193}
194
195/// An ISO 23387 reference by GUID, URI, or both.
196#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub struct Reference {
198    guid: Option<Guid>,
199    uri: Option<AnyUri>,
200}
201
202impl Reference {
203    /// Creates the exact XSD contract. Annex E permits both attributes to be absent.
204    #[must_use]
205    pub const fn new(guid: Option<Guid>, uri: Option<AnyUri>) -> Self {
206        Self { guid, uri }
207    }
208
209    /// Creates a semantically identified reference, rejecting the XSD-valid empty state.
210    pub fn identified(guid: Option<Guid>, uri: Option<AnyUri>) -> Result<Self, ValueError> {
211        if guid.is_none() && uri.is_none() {
212            return Err(ValueError::new(ValueErrorKind::EmptyReference, ""));
213        }
214        Ok(Self::new(guid, uri))
215    }
216
217    #[must_use]
218    pub const fn is_empty(&self) -> bool {
219        self.guid.is_none() && self.uri.is_none()
220    }
221
222    /// Referenced GUID, when present.
223    #[must_use]
224    pub const fn guid(&self) -> Option<&Guid> {
225        self.guid.as_ref()
226    }
227
228    /// Referenced URI, when present.
229    #[must_use]
230    pub fn uri(&self) -> Option<&str> {
231        self.uri.as_ref().map(AnyUri::as_str)
232    }
233}
234
235/// Owned core of ISO 23387 `ConceptType` for reuse by dependent standards.
236///
237/// Format codecs retain the complete XML tree separately; this value is the
238/// stable, application-facing subset shared by DT and standards such as LOIN.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct Concept {
241    guid: Guid,
242    date_of_creation: DateTime,
243    names: Vec<MultiLanguageText>,
244    definition: MultiLanguageText,
245    references: Vec<Reference>,
246}
247
248impl Concept {
249    /// Creates an Annex E-valid required `ConceptType` core.
250    #[must_use]
251    pub fn new(
252        guid: Guid,
253        date_of_creation: DateTime,
254        first_name: MultiLanguageText,
255        definition: MultiLanguageText,
256    ) -> Self {
257        Self {
258            guid,
259            date_of_creation,
260            names: vec![first_name],
261            definition,
262            references: Vec::new(),
263        }
264    }
265
266    #[must_use]
267    pub const fn guid(&self) -> &Guid {
268        &self.guid
269    }
270
271    #[must_use]
272    pub fn date_of_creation(&self) -> &str {
273        self.date_of_creation.as_str()
274    }
275
276    #[must_use]
277    pub fn names(&self) -> &[MultiLanguageText] {
278        &self.names
279    }
280
281    #[must_use]
282    pub const fn definition(&self) -> &MultiLanguageText {
283        &self.definition
284    }
285
286    #[must_use]
287    pub fn references(&self) -> &[Reference] {
288        &self.references
289    }
290
291    pub fn add_name(&mut self, name: MultiLanguageText) {
292        self.names.push(name);
293    }
294
295    pub fn set_definition(&mut self, definition: MultiLanguageText) {
296        self.definition = definition;
297    }
298
299    pub fn add_reference(&mut self, reference: Reference) {
300        self.references.push(reference);
301    }
302}
303
304/// ISO 23387 property data-type names with forward-compatible retention.
305#[derive(Debug, Clone, PartialEq, Eq, Hash)]
306pub enum DataTypeName {
307    Boolean,
308    Integer,
309    Rational,
310    Real,
311    Complex,
312    String,
313    DateTime,
314    /// A future or extension value retained verbatim.
315    Other(String),
316}
317
318impl DataTypeName {
319    /// Wire spelling.
320    #[must_use]
321    pub fn as_str(&self) -> &str {
322        match self {
323            Self::Boolean => "BOOLEAN",
324            Self::Integer => "INTEGER",
325            Self::Rational => "RATIONAL",
326            Self::Real => "REAL",
327            Self::Complex => "COMPLEX",
328            Self::String => "STRING",
329            Self::DateTime => "DATETIME",
330            Self::Other(value) => value,
331        }
332    }
333}
334
335impl From<&str> for DataTypeName {
336    fn from(value: &str) -> Self {
337        match value {
338            "BOOLEAN" => Self::Boolean,
339            "INTEGER" => Self::Integer,
340            "RATIONAL" => Self::Rational,
341            "REAL" => Self::Real,
342            "COMPLEX" => Self::Complex,
343            "STRING" => Self::String,
344            "DATETIME" => Self::DateTime,
345            other => Self::Other(other.to_owned()),
346        }
347    }
348}
349
350/// Unit scale with forward-compatible retention.
351#[derive(Debug, Clone, PartialEq, Eq, Hash)]
352pub enum Scale {
353    Linear,
354    Logarithmic,
355    Other(String),
356}
357
358impl From<&str> for Scale {
359    fn from(value: &str) -> Self {
360        match value {
361            "LINEAR" => Self::Linear,
362            "LOGARITHMIC" => Self::Logarithmic,
363            other => Self::Other(other.to_owned()),
364        }
365    }
366}
367
368/// Unit logarithm base with forward-compatible retention.
369#[derive(Debug, Clone, PartialEq, Eq, Hash)]
370pub enum Base {
371    One,
372    Two,
373    E,
374    Pi,
375    Ten,
376    Other(String),
377}
378
379impl From<&str> for Base {
380    fn from(value: &str) -> Self {
381        match value {
382            "ONE" => Self::One,
383            "TWO" => Self::Two,
384            "E" => Self::E,
385            "PI" => Self::Pi,
386            "TEN" => Self::Ten,
387            other => Self::Other(other.to_owned()),
388        }
389    }
390}
391
392/// XML Schema decimal preserving its whitespace-collapsed validated lexeme.
393#[derive(Debug, Clone, PartialEq, Eq, Hash)]
394pub struct Decimal(String);
395
396impl Decimal {
397    #[must_use]
398    pub fn as_str(&self) -> &str {
399        &self.0
400    }
401}
402
403impl FromStr for Decimal {
404    type Err = ValueError;
405
406    fn from_str(value: &str) -> Result<Self, Self::Err> {
407        let value = collapse_whitespace(value);
408        let unsigned = value.strip_prefix(['+', '-']).unwrap_or(&value);
409        let mut parts = unsigned.split('.');
410        let integer = parts.next().unwrap_or_default();
411        let fraction = parts.next();
412        let valid_integer = integer.bytes().all(|byte| byte.is_ascii_digit());
413        let valid_fraction =
414            fraction.is_none_or(|part| part.bytes().all(|byte| byte.is_ascii_digit()));
415        let has_digit = !integer.is_empty() || fraction.is_some_and(|part| !part.is_empty());
416        let valid = valid_integer && valid_fraction && has_digit && parts.next().is_none();
417        valid
418            .then(|| Self(value.clone()))
419            .ok_or_else(|| ValueError::new(ValueErrorKind::Decimal, value))
420    }
421}
422
423/// XML Schema `positiveInteger`, preserved after whitespace collapse.
424#[derive(Debug, Clone, PartialEq, Eq, Hash)]
425pub struct PositiveInteger(String);
426
427impl PositiveInteger {
428    #[must_use]
429    pub fn as_str(&self) -> &str {
430        &self.0
431    }
432}
433
434impl FromStr for PositiveInteger {
435    type Err = ValueError;
436
437    fn from_str(value: &str) -> Result<Self, Self::Err> {
438        let value = collapse_whitespace(value);
439        let digits = value.strip_prefix('+').unwrap_or(&value);
440        let valid = !digits.is_empty()
441            && digits.bytes().all(|byte| byte.is_ascii_digit())
442            && digits.bytes().any(|byte| byte != b'0');
443        valid
444            .then(|| Self(value.clone()))
445            .ok_or_else(|| ValueError::new(ValueErrorKind::PositiveInteger, value))
446    }
447}
448
449/// ISO 23387 rational value preserving the validated source lexeme.
450#[derive(Debug, Clone, PartialEq, Eq, Hash)]
451pub struct Rational(String);
452
453impl Rational {
454    #[must_use]
455    pub fn as_str(&self) -> &str {
456        &self.0
457    }
458}
459
460impl FromStr for Rational {
461    type Err = ValueError;
462
463    fn from_str(value: &str) -> Result<Self, Self::Err> {
464        let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
465        let mut parts = unsigned.split('/');
466        let numerator = parts.next().unwrap_or_default();
467        let denominator = parts.next();
468        let valid_numerator =
469            !numerator.is_empty() && numerator.bytes().all(|b| b.is_ascii_digit());
470        let valid_denominator = denominator.is_none_or(|part| {
471            part.bytes()
472                .next()
473                .is_some_and(|first| matches!(first, b'1'..=b'9'))
474                && part.bytes().all(|b| b.is_ascii_digit())
475        });
476        let valid = valid_numerator && valid_denominator && parts.next().is_none();
477        valid
478            .then(|| Self(value.to_owned()))
479            .ok_or_else(|| ValueError::new(ValueErrorKind::Rational, value))
480    }
481}
482
483fn collapse_whitespace(value: &str) -> String {
484    let mut output = String::with_capacity(value.len());
485    let mut pending_space = false;
486    for character in value.chars() {
487        if matches!(character, ' ' | '\t' | '\r' | '\n') {
488            pending_space = !output.is_empty();
489        } else {
490            if pending_space {
491                output.push(' ');
492                pending_space = false;
493            }
494            output.push(character);
495        }
496    }
497    output
498}
499
500fn is_xml_10_character(value: char) -> bool {
501    matches!(value, '\u{9}' | '\u{A}' | '\u{D}')
502        || ('\u{20}'..='\u{D7FF}').contains(&value)
503        || ('\u{E000}'..='\u{FFFD}').contains(&value)
504        || ('\u{10000}'..='\u{10FFFF}').contains(&value)
505}
506
507fn is_language(value: &str) -> bool {
508    let mut parts = value.split('-');
509    let Some(first) = parts.next() else {
510        return false;
511    };
512    let first_valid =
513        (1..=8).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_alphabetic());
514    first_valid
515        && parts.all(|part| {
516            (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())
517        })
518}
519
520fn is_xs_datetime(value: &str) -> bool {
521    let Some((date, time_and_zone)) = value.split_once('T') else {
522        return false;
523    };
524    if time_and_zone.contains('T') || !valid_xs_date(date) {
525        return false;
526    }
527    let (time, zone) = split_timezone(time_and_zone);
528    valid_xs_time(time) && zone.is_none_or(valid_timezone)
529}
530
531fn valid_xs_date(value: &str) -> bool {
532    let unsigned = value.strip_prefix('-').unwrap_or(value);
533    let mut parts = unsigned.split('-');
534    let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
535        return false;
536    };
537    if parts.next().is_some()
538        || year.len() < 4
539        || (year.len() > 4 && year.starts_with('0'))
540        || !year.bytes().all(|b| b.is_ascii_digit())
541        || year.bytes().all(|b| b == b'0')
542        || month.len() != 2
543        || day.len() != 2
544    {
545        return false;
546    }
547    let (Ok(month), Ok(day)) = (month.parse::<u8>(), day.parse::<u8>()) else {
548        return false;
549    };
550    let year_mod_400 = year.bytes().fold(0_u16, |value, digit| {
551        (value * 10 + u16::from(digit - b'0')) % 400
552    });
553    let leap = year_mod_400 % 4 == 0 && (year_mod_400 % 100 != 0 || year_mod_400 == 0);
554    let max_day = match month {
555        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
556        4 | 6 | 9 | 11 => 30,
557        2 if leap => 29,
558        2 => 28,
559        _ => return false,
560    };
561    (1..=max_day).contains(&day)
562}
563
564fn valid_xs_time(value: &str) -> bool {
565    let mut parts = value.split(':');
566    let (Some(hour), Some(minute), Some(second)) = (parts.next(), parts.next(), parts.next())
567    else {
568        return false;
569    };
570    if parts.next().is_some() || hour.len() != 2 || minute.len() != 2 {
571        return false;
572    }
573    let (Ok(hour), Ok(minute)) = (hour.parse::<u8>(), minute.parse::<u8>()) else {
574        return false;
575    };
576    let mut second_parts = second.split('.');
577    let whole = second_parts.next().unwrap_or_default();
578    let fraction = second_parts.next();
579    let valid_second = whole.len() == 2
580        && whole.bytes().all(|b| b.is_ascii_digit())
581        && whole.parse::<u8>().is_ok_and(|v| v <= 59)
582        && fraction.is_none_or(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
583        && second_parts.next().is_none();
584    valid_second
585        && minute <= 59
586        && (hour <= 23
587            || (hour == 24
588                && minute == 0
589                && whole == "00"
590                && fraction.is_none_or(|value| value.bytes().all(|byte| byte == b'0'))))
591}
592
593fn split_timezone(value: &str) -> (&str, Option<&str>) {
594    if let Some(time) = value.strip_suffix('Z') {
595        return (time, Some("Z"));
596    }
597    if value.len() >= 6 {
598        let boundary = value.len() - 6;
599        if matches!(value.as_bytes()[boundary], b'+' | b'-') {
600            return (&value[..boundary], Some(&value[boundary..]));
601        }
602    }
603    (value, None)
604}
605
606fn valid_timezone(value: &str) -> bool {
607    if value == "Z" {
608        return true;
609    }
610    let bytes = value.as_bytes();
611    if bytes.len() != 6 || !matches!(bytes[0], b'+' | b'-') || bytes[3] != b':' {
612        return false;
613    }
614    let (Ok(hour), Ok(minute)) = (value[1..3].parse::<u8>(), value[4..6].parse::<u8>()) else {
615        return false;
616    };
617    hour <= 14 && minute <= 59 && (hour != 14 || minute == 0)
618}