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
234impl Designation {
235    /// The record's bytes.
236    #[must_use]
237    pub fn encode(&self) -> Vec<u8> {
238        let mut w = Writer(Vec::new());
239        w.opt_str(self.id.as_deref());
240        w.str(&self.term);
241        w.str(&self.language);
242        w.u32(self.use_ordinal);
243        w.u8(u8::from(self.active));
244        w.0
245    }
246
247    /// Decodes a record.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`RecordError`] for truncated, non-UTF-8, or trailing bytes.
252    pub fn decode(bytes: &[u8]) -> Result<Self, RecordError> {
253        let mut r = Reader { bytes, at: 0 };
254        let designation = Self {
255            id: r.opt_str()?,
256            term: r.str()?,
257            language: r.str()?,
258            use_ordinal: r.u32()?,
259            active: r.u8()? != 0,
260        };
261        r.finish()?;
262        Ok(designation)
263    }
264}
265
266impl PropertyValue {
267    fn encode_into(&self, w: &mut Writer) {
268        match self {
269            Self::Concept(o) => {
270                w.u8(0);
271                w.u32(o.index());
272            }
273            Self::Code(s) => {
274                w.u8(1);
275                w.str(s);
276            }
277            Self::String(s) => {
278                w.u8(2);
279                w.str(s);
280            }
281            Self::Integer(i) => {
282                w.u8(3);
283                w.i64(*i);
284            }
285            Self::Boolean(b) => {
286                w.u8(4);
287                w.u8(u8::from(*b));
288            }
289            Self::Decimal(s) => {
290                w.u8(5);
291                w.str(s);
292            }
293            Self::DateTime(s) => {
294                w.u8(6);
295                w.str(s);
296            }
297        }
298    }
299
300    fn decode_from(r: &mut Reader<'_>) -> Result<Self, RecordError> {
301        Ok(match r.u8()? {
302            0 => Self::Concept(Ordinal::new(r.u32()?)),
303            1 => Self::Code(r.str()?),
304            2 => Self::String(r.str()?),
305            3 => Self::Integer(r.i64()?),
306            4 => Self::Boolean(r.u8()? != 0),
307            5 => Self::Decimal(r.str()?),
308            6 => Self::DateTime(r.str()?),
309            tag => return Err(RecordError::Tag { tag }),
310        })
311    }
312
313    /// Encodes a list of values as one record.
314    #[must_use]
315    pub fn encode_list(values: &[Self]) -> Vec<u8> {
316        let mut w = Writer(Vec::new());
317        w.u32(u32::try_from(values.len()).unwrap_or(u32::MAX));
318        for value in values {
319            value.encode_into(&mut w);
320        }
321        w.0
322    }
323
324    /// Decodes a list of values.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`RecordError`] for truncated bytes or an unknown tag.
329    pub fn decode_list(bytes: &[u8]) -> Result<Vec<Self>, RecordError> {
330        let mut r = Reader { bytes, at: 0 };
331        let count = r.u32()?;
332        let mut values = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
333        for _ in 0..count {
334            values.push(Self::decode_from(&mut r)?);
335        }
336        r.finish()?;
337        Ok(values)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::{Concept, Designation, PropertyValue, RecordError};
344    use concept_graph::ordinal::Ordinal;
345
346    #[test]
347    fn records_round_trip() {
348        let concept = Concept {
349            code: "123456789".to_owned(),
350            active: true,
351            effective_time: Some("20260101".to_owned()),
352            module: Some(Ordinal::new(7)),
353        };
354        assert_eq!(Concept::decode(&concept.encode()), Ok(concept));
355        let designation = Designation {
356            id: None,
357            term: "Synthetisch kind".to_owned(),
358            language: "nl".to_owned(),
359            use_ordinal: 2,
360            active: false,
361        };
362        assert_eq!(Designation::decode(&designation.encode()), Ok(designation));
363        let values = vec![
364            PropertyValue::Concept(Ordinal::new(3)),
365            PropertyValue::Code("x".to_owned()),
366            PropertyValue::String("s".to_owned()),
367            PropertyValue::Integer(-42),
368            PropertyValue::Boolean(true),
369            PropertyValue::Decimal("2.50".to_owned()),
370            PropertyValue::DateTime("2026-01-01".to_owned()),
371        ];
372        assert_eq!(
373            PropertyValue::decode_list(&PropertyValue::encode_list(&values)),
374            Ok(values)
375        );
376    }
377
378    #[test]
379    fn damaged_records_are_refused() {
380        let bytes = Concept {
381            code: "1".to_owned(),
382            active: true,
383            effective_time: None,
384            module: None,
385        }
386        .encode();
387        assert!(matches!(
388            Concept::decode(&bytes[..3]),
389            Err(RecordError::Truncated { .. })
390        ));
391        let mut trailing = bytes.clone();
392        trailing.push(0);
393        assert!(matches!(
394            Concept::decode(&trailing),
395            Err(RecordError::Trailing { remaining: 1 })
396        ));
397        assert!(matches!(
398            PropertyValue::decode_list(&[1, 0, 0, 0, 9]),
399            Err(RecordError::Tag { tag: 9 })
400        ));
401        assert!(matches!(
402            Designation::decode(&[0, 1, 0, 0, 0, 0xff]),
403            Err(RecordError::Utf8 { .. })
404        ));
405    }
406}