Skip to main content

concept_store/
record.rs

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