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    },
25    /// A property value tag is not one this build knows.
26    #[error("unknown property value tag {tag}")]
27    Tag {
28        /// The tag byte.
29        tag: u8,
30    },
31    /// Bytes remain after the record.
32    #[error("{remaining} trailing byte(s) after the record")]
33    Trailing {
34        /// How many bytes remain.
35        remaining: usize,
36    },
37}
38
39/// A stored concept.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Concept {
42    /// The native code, for example the SCTID.
43    pub code: String,
44    /// Whether the concept is active in this version.
45    pub active: bool,
46    /// The version the concept last changed, as the code system writes it.
47    pub effective_time: Option<String>,
48    /// The module or owner concept, when the code system has one.
49    pub module: Option<Ordinal>,
50}
51
52/// A stored designation.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Designation {
55    /// The native identifier of the designation, when the code system has one.
56    pub id: Option<String>,
57    /// The text.
58    pub term: String,
59    /// The BCP 47 language.
60    pub language: String,
61    /// The designation use (a `DESIGNATION_USES` ordinal).
62    pub use_ordinal: u32,
63    /// Whether the designation is active.
64    pub active: bool,
65}
66
67/// One typed property value.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum PropertyValue {
70    /// A reference to another concept of the same version.
71    Concept(Ordinal),
72    /// A code the code system defines.
73    Code(String),
74    /// A string.
75    String(String),
76    /// An integer.
77    Integer(i64),
78    /// A boolean.
79    Boolean(bool),
80    /// A decimal in its lexical form.
81    Decimal(String),
82    /// A date or time in its lexical form.
83    DateTime(String),
84}
85
86impl fmt::Display for PropertyValue {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Concept(o) => write!(f, "{o}"),
90            Self::Code(s) | Self::String(s) | Self::Decimal(s) | Self::DateTime(s) => {
91                f.write_str(s)
92            }
93            Self::Integer(i) => write!(f, "{i}"),
94            Self::Boolean(b) => write!(f, "{b}"),
95        }
96    }
97}
98
99struct Writer(Vec<u8>);
100
101impl Writer {
102    fn u8(&mut self, v: u8) {
103        self.0.push(v);
104    }
105    fn u32(&mut self, v: u32) {
106        self.0.extend_from_slice(&v.to_le_bytes());
107    }
108    fn i64(&mut self, v: i64) {
109        self.0.extend_from_slice(&v.to_le_bytes());
110    }
111    fn str(&mut self, s: &str) {
112        self.u32(u32::try_from(s.len()).unwrap_or(u32::MAX));
113        self.0.extend_from_slice(s.as_bytes());
114    }
115    fn opt_str(&mut self, s: Option<&str>) {
116        match s {
117            Some(s) => {
118                self.u8(1);
119                self.str(s);
120            }
121            None => self.u8(0),
122        }
123    }
124}
125
126struct Reader<'a> {
127    bytes: &'a [u8],
128    at: usize,
129}
130
131impl<'a> Reader<'a> {
132    fn take(&mut self, n: usize) -> Result<&'a [u8], RecordError> {
133        let end = self
134            .at
135            .checked_add(n)
136            .ok_or(RecordError::Truncated { at: self.at })?;
137        let slice = self
138            .bytes
139            .get(self.at..end)
140            .ok_or(RecordError::Truncated { at: self.at })?;
141        self.at = end;
142        Ok(slice)
143    }
144    fn u8(&mut self) -> Result<u8, RecordError> {
145        self.take(1)?
146            .first()
147            .copied()
148            .ok_or(RecordError::Truncated { at: self.at })
149    }
150    fn u32(&mut self) -> Result<u32, RecordError> {
151        let bytes: [u8; 4] = self
152            .take(4)?
153            .try_into()
154            .map_err(|_| RecordError::Truncated { at: self.at })?;
155        Ok(u32::from_le_bytes(bytes))
156    }
157    fn i64(&mut self) -> Result<i64, RecordError> {
158        let bytes: [u8; 8] = self
159            .take(8)?
160            .try_into()
161            .map_err(|_| RecordError::Truncated { at: self.at })?;
162        Ok(i64::from_le_bytes(bytes))
163    }
164    fn str(&mut self) -> Result<String, RecordError> {
165        let len =
166            usize::try_from(self.u32()?).map_err(|_| RecordError::Truncated { at: self.at })?;
167        let at = self.at;
168        let bytes = self.take(len)?;
169        std::str::from_utf8(bytes)
170            .map(str::to_owned)
171            .map_err(|_| RecordError::Utf8 { at })
172    }
173    fn opt_str(&mut self) -> Result<Option<String>, RecordError> {
174        match self.u8()? {
175            0 => Ok(None),
176            _ => self.str().map(Some),
177        }
178    }
179    fn finish(self) -> Result<(), RecordError> {
180        let remaining = self.bytes.len().saturating_sub(self.at);
181        if remaining == 0 {
182            Ok(())
183        } else {
184            Err(RecordError::Trailing { remaining })
185        }
186    }
187}
188
189impl Concept {
190    /// The record's bytes.
191    #[must_use]
192    pub fn encode(&self) -> Vec<u8> {
193        let mut w = Writer(Vec::new());
194        w.str(&self.code);
195        w.u8(u8::from(self.active));
196        w.opt_str(self.effective_time.as_deref());
197        match self.module {
198            Some(m) => {
199                w.u8(1);
200                w.u32(m.index());
201            }
202            None => w.u8(0),
203        }
204        w.0
205    }
206
207    /// Decodes a record.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`RecordError`] for truncated, non-UTF-8, or trailing bytes.
212    pub fn decode(bytes: &[u8]) -> Result<Self, RecordError> {
213        let mut r = Reader { bytes, at: 0 };
214        let code = r.str()?;
215        let active = r.u8()? != 0;
216        let effective_time = r.opt_str()?;
217        let module = match r.u8()? {
218            0 => None,
219            _ => Some(Ordinal::new(r.u32()?)),
220        };
221        r.finish()?;
222        Ok(Self {
223            code,
224            active,
225            effective_time,
226            module,
227        })
228    }
229}
230
231impl Designation {
232    /// The record's bytes.
233    #[must_use]
234    pub fn encode(&self) -> Vec<u8> {
235        let mut w = Writer(Vec::new());
236        w.opt_str(self.id.as_deref());
237        w.str(&self.term);
238        w.str(&self.language);
239        w.u32(self.use_ordinal);
240        w.u8(u8::from(self.active));
241        w.0
242    }
243
244    /// Decodes a record.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`RecordError`] for truncated, non-UTF-8, or trailing bytes.
249    pub fn decode(bytes: &[u8]) -> Result<Self, RecordError> {
250        let mut r = Reader { bytes, at: 0 };
251        let designation = Self {
252            id: r.opt_str()?,
253            term: r.str()?,
254            language: r.str()?,
255            use_ordinal: r.u32()?,
256            active: r.u8()? != 0,
257        };
258        r.finish()?;
259        Ok(designation)
260    }
261}
262
263impl PropertyValue {
264    fn encode_into(&self, w: &mut Writer) {
265        match self {
266            Self::Concept(o) => {
267                w.u8(0);
268                w.u32(o.index());
269            }
270            Self::Code(s) => {
271                w.u8(1);
272                w.str(s);
273            }
274            Self::String(s) => {
275                w.u8(2);
276                w.str(s);
277            }
278            Self::Integer(i) => {
279                w.u8(3);
280                w.i64(*i);
281            }
282            Self::Boolean(b) => {
283                w.u8(4);
284                w.u8(u8::from(*b));
285            }
286            Self::Decimal(s) => {
287                w.u8(5);
288                w.str(s);
289            }
290            Self::DateTime(s) => {
291                w.u8(6);
292                w.str(s);
293            }
294        }
295    }
296
297    fn decode_from(r: &mut Reader<'_>) -> Result<Self, RecordError> {
298        Ok(match r.u8()? {
299            0 => Self::Concept(Ordinal::new(r.u32()?)),
300            1 => Self::Code(r.str()?),
301            2 => Self::String(r.str()?),
302            3 => Self::Integer(r.i64()?),
303            4 => Self::Boolean(r.u8()? != 0),
304            5 => Self::Decimal(r.str()?),
305            6 => Self::DateTime(r.str()?),
306            tag => return Err(RecordError::Tag { tag }),
307        })
308    }
309
310    /// Encodes a list of values as one record.
311    #[must_use]
312    pub fn encode_list(values: &[Self]) -> Vec<u8> {
313        let mut w = Writer(Vec::new());
314        w.u32(u32::try_from(values.len()).unwrap_or(u32::MAX));
315        for value in values {
316            value.encode_into(&mut w);
317        }
318        w.0
319    }
320
321    /// Decodes a list of values.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`RecordError`] for truncated bytes or an unknown tag.
326    pub fn decode_list(bytes: &[u8]) -> Result<Vec<Self>, RecordError> {
327        let mut r = Reader { bytes, at: 0 };
328        let count = r.u32()?;
329        let mut values = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
330        for _ in 0..count {
331            values.push(Self::decode_from(&mut r)?);
332        }
333        r.finish()?;
334        Ok(values)
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::{Concept, Designation, PropertyValue, RecordError};
341    use concept_graph::ordinal::Ordinal;
342
343    #[test]
344    fn records_round_trip() {
345        let concept = Concept {
346            code: "123456789".to_owned(),
347            active: true,
348            effective_time: Some("20260101".to_owned()),
349            module: Some(Ordinal::new(7)),
350        };
351        assert_eq!(Concept::decode(&concept.encode()), Ok(concept));
352        let designation = Designation {
353            id: None,
354            term: "Synthetisch kind".to_owned(),
355            language: "nl".to_owned(),
356            use_ordinal: 2,
357            active: false,
358        };
359        assert_eq!(Designation::decode(&designation.encode()), Ok(designation));
360        let values = vec![
361            PropertyValue::Concept(Ordinal::new(3)),
362            PropertyValue::Code("x".to_owned()),
363            PropertyValue::String("s".to_owned()),
364            PropertyValue::Integer(-42),
365            PropertyValue::Boolean(true),
366            PropertyValue::Decimal("2.50".to_owned()),
367            PropertyValue::DateTime("2026-01-01".to_owned()),
368        ];
369        assert_eq!(
370            PropertyValue::decode_list(&PropertyValue::encode_list(&values)),
371            Ok(values)
372        );
373    }
374
375    #[test]
376    fn damaged_records_are_refused() {
377        let bytes = Concept {
378            code: "1".to_owned(),
379            active: true,
380            effective_time: None,
381            module: None,
382        }
383        .encode();
384        assert!(matches!(
385            Concept::decode(&bytes[..3]),
386            Err(RecordError::Truncated { .. })
387        ));
388        let mut trailing = bytes.clone();
389        trailing.push(0);
390        assert!(matches!(
391            Concept::decode(&trailing),
392            Err(RecordError::Trailing { remaining: 1 })
393        ));
394        assert!(matches!(
395            PropertyValue::decode_list(&[1, 0, 0, 0, 9]),
396            Err(RecordError::Tag { tag: 9 })
397        ));
398        assert!(matches!(
399            Designation::decode(&[0, 1, 0, 0, 0, 0xff]),
400            Err(RecordError::Utf8 { .. })
401        ));
402    }
403}