Skip to main content

concept_store/
record.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! The byte encodings of the stored records.
5//!
6//! Little-endian, length-prefixed strings, a tag byte per property value.
7//! Encoding and decoding are inverse; a decode failure names what was found.
8
9use std::fmt;
10
11use concept_graph::ordinal::Ordinal;
12
13/// A damaged record.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum RecordError {
16    /// The bytes end before the record does.
17    #[error("record truncated at byte {at}")]
18    Truncated {
19        /// The offset where more bytes were expected.
20        at: usize,
21    },
22    /// A string is not UTF-8.
23    #[error("string at byte {at} is not UTF-8")]
24    Utf8 {
25        /// The offset of the string.
26        at: usize,
27        /// The cause.
28        #[source]
29        source: std::str::Utf8Error,
30    },
31    /// A property value tag is not one this build knows.
32    #[error("unknown property value tag {tag}")]
33    Tag {
34        /// The tag byte.
35        tag: u8,
36    },
37    /// Bytes remain after the record.
38    #[error("{remaining} trailing byte(s) after the record")]
39    Trailing {
40        /// How many bytes remain.
41        remaining: usize,
42    },
43}
44
45/// A stored concept.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Concept {
48    /// The native code, for example the SCTID.
49    pub code: String,
50    /// Whether the concept is active in this version.
51    pub active: bool,
52    /// The version the concept last changed, as the code system writes it.
53    pub effective_time: Option<String>,
54    /// The module or owner concept, when the code system has one.
55    pub module: Option<Ordinal>,
56}
57
58/// A stored designation.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Designation {
61    /// The native identifier of the designation, when the code system has one.
62    pub id: Option<String>,
63    /// The text.
64    pub term: String,
65    /// The BCP 47 language.
66    pub language: String,
67    /// The designation use (a `DESIGNATION_USES` ordinal).
68    pub use_ordinal: u32,
69    /// Whether the designation is active.
70    pub active: bool,
71}
72
73/// One typed property value.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum PropertyValue {
76    /// A reference to another concept of the same version.
77    Concept(Ordinal),
78    /// A code the code system defines.
79    Code(String),
80    /// A string.
81    String(String),
82    /// An integer.
83    Integer(i64),
84    /// A boolean.
85    Boolean(bool),
86    /// A decimal in its lexical form.
87    Decimal(String),
88    /// A date or time in its lexical form.
89    DateTime(String),
90}
91
92impl fmt::Display for PropertyValue {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Self::Concept(o) => write!(f, "{o}"),
96            Self::Code(s) | Self::String(s) | Self::Decimal(s) | Self::DateTime(s) => {
97                f.write_str(s)
98            }
99            Self::Integer(i) => write!(f, "{i}"),
100            Self::Boolean(b) => write!(f, "{b}"),
101        }
102    }
103}
104
105struct Writer(Vec<u8>);
106
107impl Writer {
108    fn u8(&mut self, v: u8) {
109        self.0.push(v);
110    }
111    fn u32(&mut self, v: u32) {
112        self.0.extend_from_slice(&v.to_le_bytes());
113    }
114    fn i64(&mut self, v: i64) {
115        self.0.extend_from_slice(&v.to_le_bytes());
116    }
117    fn str(&mut self, s: &str) {
118        self.u32(u32::try_from(s.len()).unwrap_or(u32::MAX));
119        self.0.extend_from_slice(s.as_bytes());
120    }
121    fn opt_str(&mut self, s: Option<&str>) {
122        match s {
123            Some(s) => {
124                self.u8(1);
125                self.str(s);
126            }
127            None => self.u8(0),
128        }
129    }
130}
131
132struct Reader<'a> {
133    bytes: &'a [u8],
134    at: usize,
135}
136
137impl<'a> Reader<'a> {
138    fn take(&mut self, n: usize) -> Result<&'a [u8], RecordError> {
139        let end = self
140            .at
141            .checked_add(n)
142            .ok_or(RecordError::Truncated { at: self.at })?;
143        let slice = self
144            .bytes
145            .get(self.at..end)
146            .ok_or(RecordError::Truncated { at: self.at })?;
147        self.at = end;
148        Ok(slice)
149    }
150    fn u8(&mut self) -> Result<u8, RecordError> {
151        self.take(1)?
152            .first()
153            .copied()
154            .ok_or(RecordError::Truncated { at: self.at })
155    }
156    fn u32(&mut self) -> Result<u32, RecordError> {
157        // The error names the offset; the slice-length error adds nothing to it.
158        let Ok(bytes) = <[u8; 4]>::try_from(self.take(4)?) else {
159            return Err(RecordError::Truncated { at: self.at });
160        };
161        Ok(u32::from_le_bytes(bytes))
162    }
163    fn i64(&mut self) -> Result<i64, RecordError> {
164        let Ok(bytes) = <[u8; 8]>::try_from(self.take(8)?) else {
165            return Err(RecordError::Truncated { at: self.at });
166        };
167        Ok(i64::from_le_bytes(bytes))
168    }
169    fn str(&mut self) -> Result<String, RecordError> {
170        let Ok(len) = usize::try_from(self.u32()?) else {
171            return Err(RecordError::Truncated { at: self.at });
172        };
173        let at = self.at;
174        let bytes = self.take(len)?;
175        std::str::from_utf8(bytes)
176            .map(str::to_owned)
177            .map_err(|source| RecordError::Utf8 { at, source })
178    }
179    fn opt_str(&mut self) -> Result<Option<String>, RecordError> {
180        match self.u8()? {
181            0 => Ok(None),
182            _ => self.str().map(Some),
183        }
184    }
185    fn finish(self) -> Result<(), RecordError> {
186        let remaining = self.bytes.len().saturating_sub(self.at);
187        if remaining == 0 {
188            Ok(())
189        } else {
190            Err(RecordError::Trailing { remaining })
191        }
192    }
193}
194
195impl Concept {
196    /// The record's bytes.
197    #[must_use]
198    pub fn encode(&self) -> Vec<u8> {
199        let mut w = Writer(Vec::new());
200        w.str(&self.code);
201        w.u8(u8::from(self.active));
202        w.opt_str(self.effective_time.as_deref());
203        match self.module {
204            Some(m) => {
205                w.u8(1);
206                w.u32(m.index());
207            }
208            None => w.u8(0),
209        }
210        w.0
211    }
212
213    /// Decodes a record.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`RecordError`] for truncated, non-UTF-8, or trailing bytes.
218    pub fn decode(bytes: &[u8]) -> Result<Self, RecordError> {
219        let mut r = Reader { bytes, at: 0 };
220        let code = r.str()?;
221        let active = r.u8()? != 0;
222        let effective_time = r.opt_str()?;
223        let module = match r.u8()? {
224            0 => None,
225            _ => Some(Ordinal::new(r.u32()?)),
226        };
227        r.finish()?;
228        Ok(Self {
229            code,
230            active,
231            effective_time,
232            module,
233        })
234    }
235
236    /// Decodes the native code of a record and stops there.
237    ///
238    /// The code is the first field, so a caller that wants only the code
239    /// reads one length and one string instead of the whole record.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`RecordError`] when the code is truncated or not UTF-8.
244    pub fn decode_code(bytes: &[u8]) -> Result<String, RecordError> {
245        code(bytes).map(str::to_owned)
246    }
247}
248
249/// The display of one concept per language reference set, as the build chose
250/// it: the term of the preferred designation of the display use.
251///
252/// This is not every preferred designation. A display needs one term and the
253/// language to match it against, so an entry is a reference set, a language,
254/// and a term, and nothing else about the designation is carried. Holding the
255/// whole designation for every use doubled a SNOMED artifact (#322).
256#[derive(Debug)]
257pub struct Displays;
258
259impl Displays {
260    /// The bytes of `entries`, each a reference set, a language, and a term.
261    ///
262    /// Entries are written in the order given, which is the order a reader
263    /// scans them in, so the reference set order the build wrote decides which
264    /// entry a reader meets first.
265    #[must_use]
266    pub fn encode(entries: &[(u32, &str, &str)]) -> Vec<u8> {
267        let mut w = Writer(Vec::new());
268        w.u32(u32::try_from(entries.len()).unwrap_or(u32::MAX));
269        for (refset, language, term) in entries {
270            w.u32(*refset);
271            w.str(language);
272            w.str(term);
273        }
274        w.0
275    }
276
277    /// The language and term `refset` chose, if the concept has one.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`RecordError`] when the entries are truncated or not UTF-8.
282    pub fn find(bytes: &[u8], refset: u32) -> Result<Option<(String, String)>, RecordError> {
283        let mut r = Reader { bytes, at: 0 };
284        let count = r.u32()?;
285        for _ in 0..count {
286            let entry_refset = r.u32()?;
287            let language = r.str()?;
288            let term = r.str()?;
289            if entry_refset == refset {
290                return Ok(Some((language, term)));
291            }
292        }
293        Ok(None)
294    }
295}
296
297/// The designations of one concept, packed in index order.
298///
299/// Each entry carries its designation index, so a gap in the indices the build
300/// wrote cannot shift a later designation into the wrong slot.
301#[derive(Debug)]
302pub struct Designations;
303
304impl Designations {
305    /// The bytes of `entries`, each a designation index and its encoded record.
306    #[must_use]
307    pub fn encode(entries: &[(u32, &[u8])]) -> Vec<u8> {
308        let mut w = Writer(Vec::new());
309        w.u32(u32::try_from(entries.len()).unwrap_or(u32::MAX));
310        for (index, bytes) in entries {
311            w.u32(*index);
312            w.u32(u32::try_from(bytes.len()).unwrap_or(u32::MAX));
313            w.0.extend_from_slice(bytes);
314        }
315        w.0
316    }
317
318    /// Every designation, in the order the build wrote them.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`RecordError`] when the entries are truncated or a designation
323    /// is damaged.
324    pub fn decode(bytes: &[u8]) -> Result<Vec<Designation>, RecordError> {
325        let mut out = Vec::new();
326        Self::walk(bytes, |_, record| {
327            out.push(Designation::decode(record)?);
328            Ok(true)
329        })?;
330        Ok(out)
331    }
332
333    /// The designation stored under `index`, if the concept has one.
334    ///
335    /// # Errors
336    ///
337    /// Returns [`RecordError`] when the entries are truncated or the
338    /// designation is damaged.
339    pub fn at(bytes: &[u8], index: u32) -> Result<Option<Designation>, RecordError> {
340        let mut found = None;
341        Self::walk(bytes, |at, record| {
342            if at != index {
343                return Ok(true);
344            }
345            found = Some(Designation::decode(record)?);
346            Ok(false)
347        })?;
348        Ok(found)
349    }
350
351    /// Calls `visit` with each index and record until it answers `false`.
352    fn walk(
353        bytes: &[u8],
354        mut visit: impl FnMut(u32, &[u8]) -> Result<bool, RecordError>,
355    ) -> Result<(), RecordError> {
356        let mut r = Reader { bytes, at: 0 };
357        let count = r.u32()?;
358        for _ in 0..count {
359            let index = r.u32()?;
360            let Ok(len) = usize::try_from(r.u32()?) else {
361                return Err(RecordError::Truncated { at: r.at });
362            };
363            let record = r.take(len)?;
364            if !visit(index, record)? {
365                return Ok(());
366            }
367        }
368        r.finish()
369    }
370}
371
372/// The properties of one concept, in property key order.
373#[derive(Debug)]
374pub struct Properties;
375
376impl Properties {
377    /// The bytes of `entries`, each a property key and its encoded value list.
378    #[must_use]
379    pub fn encode(entries: &[(u32, &[u8])]) -> Vec<u8> {
380        let mut w = Writer(Vec::new());
381        w.u32(u32::try_from(entries.len()).unwrap_or(u32::MAX));
382        for (key, values) in entries {
383            w.u32(*key);
384            w.u32(u32::try_from(values.len()).unwrap_or(u32::MAX));
385            w.0.extend_from_slice(values);
386        }
387        w.0
388    }
389
390    /// Every property key and its values, in the order the build wrote them.
391    ///
392    /// # Errors
393    ///
394    /// Returns [`RecordError`] when the entries are truncated or a value list
395    /// is damaged.
396    pub fn decode(bytes: &[u8]) -> Result<Vec<(u32, Vec<PropertyValue>)>, RecordError> {
397        let mut r = Reader { bytes, at: 0 };
398        let count = r.u32()?;
399        let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
400        for _ in 0..count {
401            let key = r.u32()?;
402            let Ok(len) = usize::try_from(r.u32()?) else {
403                return Err(RecordError::Truncated { at: r.at });
404            };
405            let values = PropertyValue::decode_list(r.take(len)?)?;
406            out.push((key, values));
407        }
408        r.finish()?;
409        Ok(out)
410    }
411}
412
413/// The acceptability of one concept's designations, per language reference set.
414#[derive(Debug)]
415pub struct Acceptability;
416
417impl Acceptability {
418    /// The bytes of `entries`, each a designation index, a language reference
419    /// set ordinal, and an acceptability ordinal.
420    #[must_use]
421    pub fn encode(entries: &[(u32, u32, u32)]) -> Vec<u8> {
422        let mut w = Writer(Vec::new());
423        w.u32(u32::try_from(entries.len()).unwrap_or(u32::MAX));
424        for (index, refset, acceptability) in entries {
425            w.u32(*index);
426            w.u32(*refset);
427            w.u32(*acceptability);
428        }
429        w.0
430    }
431
432    /// The acceptability of designation `index` in `refset`, if the reference
433    /// set carries the designation.
434    ///
435    /// # Errors
436    ///
437    /// Returns [`RecordError`] when the entries are truncated.
438    pub fn find(bytes: &[u8], index: u32, refset: u32) -> Result<Option<u32>, RecordError> {
439        for (at, in_refset, acceptability) in Self::decode(bytes)? {
440            if at == index && in_refset == refset {
441                return Ok(Some(acceptability));
442            }
443        }
444        Ok(None)
445    }
446
447    /// Every entry, in the order the build wrote them.
448    ///
449    /// # Errors
450    ///
451    /// Returns [`RecordError`] when the entries are truncated.
452    pub fn decode(bytes: &[u8]) -> Result<Vec<(u32, u32, u32)>, RecordError> {
453        let mut r = Reader { bytes, at: 0 };
454        let count = r.u32()?;
455        let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
456        for _ in 0..count {
457            out.push((r.u32()?, r.u32()?, r.u32()?));
458        }
459        r.finish()?;
460        Ok(out)
461    }
462}
463
464/// The native code a concept record starts with, borrowed from `bytes`.
465///
466/// # Errors
467///
468/// Returns [`RecordError`] when the code is truncated or not UTF-8.
469pub fn code(bytes: &[u8]) -> Result<&str, RecordError> {
470    let mut reader = Reader { bytes, at: 0 };
471    let Ok(len) = usize::try_from(reader.u32()?) else {
472        return Err(RecordError::Truncated { at: reader.at });
473    };
474    let at = reader.at;
475    let taken = reader.take(len)?;
476    std::str::from_utf8(taken).map_err(|source| RecordError::Utf8 { at, source })
477}
478
479impl Designation {
480    /// The record's bytes.
481    #[must_use]
482    pub fn encode(&self) -> Vec<u8> {
483        let mut w = Writer(Vec::new());
484        w.opt_str(self.id.as_deref());
485        w.str(&self.term);
486        w.str(&self.language);
487        w.u32(self.use_ordinal);
488        w.u8(u8::from(self.active));
489        w.0
490    }
491
492    /// Decodes a record.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`RecordError`] for truncated, non-UTF-8, or trailing bytes.
497    pub fn decode(bytes: &[u8]) -> Result<Self, RecordError> {
498        let mut r = Reader { bytes, at: 0 };
499        let designation = Self {
500            id: r.opt_str()?,
501            term: r.str()?,
502            language: r.str()?,
503            use_ordinal: r.u32()?,
504            active: r.u8()? != 0,
505        };
506        r.finish()?;
507        Ok(designation)
508    }
509}
510
511impl PropertyValue {
512    fn encode_into(&self, w: &mut Writer) {
513        match self {
514            Self::Concept(o) => {
515                w.u8(0);
516                w.u32(o.index());
517            }
518            Self::Code(s) => {
519                w.u8(1);
520                w.str(s);
521            }
522            Self::String(s) => {
523                w.u8(2);
524                w.str(s);
525            }
526            Self::Integer(i) => {
527                w.u8(3);
528                w.i64(*i);
529            }
530            Self::Boolean(b) => {
531                w.u8(4);
532                w.u8(u8::from(*b));
533            }
534            Self::Decimal(s) => {
535                w.u8(5);
536                w.str(s);
537            }
538            Self::DateTime(s) => {
539                w.u8(6);
540                w.str(s);
541            }
542        }
543    }
544
545    fn decode_from(r: &mut Reader<'_>) -> Result<Self, RecordError> {
546        Ok(match r.u8()? {
547            0 => Self::Concept(Ordinal::new(r.u32()?)),
548            1 => Self::Code(r.str()?),
549            2 => Self::String(r.str()?),
550            3 => Self::Integer(r.i64()?),
551            4 => Self::Boolean(r.u8()? != 0),
552            5 => Self::Decimal(r.str()?),
553            6 => Self::DateTime(r.str()?),
554            tag => return Err(RecordError::Tag { tag }),
555        })
556    }
557
558    /// Encodes a list of values as one record.
559    #[must_use]
560    pub fn encode_list(values: &[Self]) -> Vec<u8> {
561        let mut w = Writer(Vec::new());
562        w.u32(u32::try_from(values.len()).unwrap_or(u32::MAX));
563        for value in values {
564            value.encode_into(&mut w);
565        }
566        w.0
567    }
568
569    /// Decodes a list of values.
570    ///
571    /// # Errors
572    ///
573    /// Returns [`RecordError`] for truncated bytes or an unknown tag.
574    pub fn decode_list(bytes: &[u8]) -> Result<Vec<Self>, RecordError> {
575        let mut r = Reader { bytes, at: 0 };
576        let count = r.u32()?;
577        let mut values = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
578        for _ in 0..count {
579            values.push(Self::decode_from(&mut r)?);
580        }
581        r.finish()?;
582        Ok(values)
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::{Concept, Designation, PropertyValue, RecordError};
589    use concept_graph::ordinal::Ordinal;
590
591    #[test]
592    fn records_round_trip() {
593        let concept = Concept {
594            code: "123456789".to_owned(),
595            active: true,
596            effective_time: Some("20260101".to_owned()),
597            module: Some(Ordinal::new(7)),
598        };
599        assert_eq!(Concept::decode(&concept.encode()), Ok(concept));
600        let designation = Designation {
601            id: None,
602            term: "Synthetisch kind".to_owned(),
603            language: "nl".to_owned(),
604            use_ordinal: 2,
605            active: false,
606        };
607        assert_eq!(Designation::decode(&designation.encode()), Ok(designation));
608        let values = vec![
609            PropertyValue::Concept(Ordinal::new(3)),
610            PropertyValue::Code("x".to_owned()),
611            PropertyValue::String("s".to_owned()),
612            PropertyValue::Integer(-42),
613            PropertyValue::Boolean(true),
614            PropertyValue::Decimal("2.50".to_owned()),
615            PropertyValue::DateTime("2026-01-01".to_owned()),
616        ];
617        assert_eq!(
618            PropertyValue::decode_list(&PropertyValue::encode_list(&values)),
619            Ok(values)
620        );
621    }
622
623    #[test]
624    fn the_per_concept_records_round_trip() {
625        let first = Designation {
626            id: Some("1".to_owned()),
627            term: "Kat".to_owned(),
628            language: "nl".to_owned(),
629            use_ordinal: 1,
630            active: true,
631        };
632        let second = Designation {
633            id: None,
634            term: "Cat".to_owned(),
635            language: "en".to_owned(),
636            use_ordinal: 0,
637            active: false,
638        };
639        let (a, b) = (first.encode(), second.encode());
640        // Index 2 is missing, so a reader that asks by index must not be
641        // handed the record that happens to sit in that position.
642        let packed = super::Designations::encode(&[(0, a.as_slice()), (3, b.as_slice())]);
643        assert_eq!(
644            super::Designations::decode(&packed),
645            Ok(vec![first.clone(), second.clone()])
646        );
647        assert_eq!(super::Designations::at(&packed, 0), Ok(Some(first)));
648        assert_eq!(super::Designations::at(&packed, 3), Ok(Some(second)));
649        assert_eq!(super::Designations::at(&packed, 2), Ok(None));
650
651        let values = PropertyValue::encode_list(&[PropertyValue::Concept(Ordinal::new(4))]);
652        let packed = super::Properties::encode(&[(7, values.as_slice()), (9, &[0, 0, 0, 0])]);
653        assert_eq!(
654            super::Properties::decode(&packed),
655            Ok(vec![
656                (7, vec![PropertyValue::Concept(Ordinal::new(4))]),
657                (9, Vec::new()),
658            ])
659        );
660
661        let packed = super::Acceptability::encode(&[(0, 1, 0), (0, 2, 1)]);
662        assert_eq!(
663            super::Acceptability::decode(&packed),
664            Ok(vec![(0, 1, 0), (0, 2, 1)])
665        );
666        assert_eq!(super::Acceptability::find(&packed, 0, 2), Ok(Some(1)));
667        assert_eq!(super::Acceptability::find(&packed, 1, 2), Ok(None));
668    }
669
670    #[test]
671    fn damaged_per_concept_records_are_refused() {
672        let packed = super::Designations::encode(&[(0, b"x".as_slice())]);
673        assert!(matches!(
674            super::Designations::decode(&packed[..6]),
675            Err(RecordError::Truncated { .. })
676        ));
677        let packed = super::Properties::encode(&[(0, &[1, 0, 0, 0, 9])]);
678        assert!(matches!(
679            super::Properties::decode(&packed),
680            Err(RecordError::Tag { tag: 9 })
681        ));
682        assert!(matches!(
683            super::Acceptability::decode(&[2, 0, 0, 0, 0, 0, 0, 0]),
684            Err(RecordError::Truncated { .. })
685        ));
686    }
687
688    #[test]
689    fn damaged_records_are_refused() {
690        let bytes = Concept {
691            code: "1".to_owned(),
692            active: true,
693            effective_time: None,
694            module: None,
695        }
696        .encode();
697        assert!(matches!(
698            Concept::decode(&bytes[..3]),
699            Err(RecordError::Truncated { .. })
700        ));
701        let mut trailing = bytes.clone();
702        trailing.push(0);
703        assert!(matches!(
704            Concept::decode(&trailing),
705            Err(RecordError::Trailing { remaining: 1 })
706        ));
707        assert!(matches!(
708            PropertyValue::decode_list(&[1, 0, 0, 0, 9]),
709            Err(RecordError::Tag { tag: 9 })
710        ));
711        assert!(matches!(
712            Designation::decode(&[0, 1, 0, 0, 0, 0xff]),
713            Err(RecordError::Utf8 { .. })
714        ));
715    }
716}