Skip to main content

librotenado/
lib.rs

1// SPDX-License-Identifier: MIT
2//! Reference library of librotenado, the open protocol for accounting data.
3//!
4//! Version 0.1 covers the base every other part builds on: amounts as exact counts of a currency's smallest unit,
5//! calendar dates, bookings that must balance, and events – immutable, ordered by a hybrid logical clock, chained per
6//! device and identified by the hash of their canonical encoding – that a set of books is made of. Two sets of events
7//! merge by union; what cannot be merged silently is reported, never guessed. Around them: the harmonised chart of
8//! accounts with translations of national charts, documents with their originals, and the organisations and persons
9//! the books belong to.
10
11/// Deserialises an optional member that, when present, is not `null` – absent is the one form of "none".
12pub(crate) fn present<'de, D: serde::Deserializer<'de>, T: serde::Deserialize<'de>>(
13    deserializer: D,
14) -> Result<Option<T>, D::Error> {
15    T::deserialize(deserializer).map(Some)
16}
17
18/// Deserialises a map that, when present, is not empty – absent is the one form of "none".
19pub(crate) fn non_empty_map<'de, D, K, V>(deserializer: D) -> Result<std::collections::BTreeMap<K, V>, D::Error>
20where
21    D: serde::Deserializer<'de>,
22    K: serde::Deserialize<'de> + Ord,
23    V: serde::Deserialize<'de>,
24{
25    let map = <std::collections::BTreeMap<K, V> as serde::Deserialize>::deserialize(deserializer)?;
26    if map.is_empty() { Err(serde::de::Error::custom("an empty map is left out, not written")) } else { Ok(map) }
27}
28
29/// Deserialises an optional text that, when present, is neither `null` nor empty – absent is the one form of "none".
30pub(crate) fn present_text<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
31    let text = <String as serde::Deserialize>::deserialize(deserializer)?;
32    if text.is_empty() {
33        Err(serde::de::Error::custom("an empty text is left out, not written"))
34    } else {
35        Ok(Some(text))
36    }
37}
38
39/// Whether `s` is a UUID in its one form: lower-case hexadecimal digits in groups of 8-4-4-4-12.
40pub(crate) fn is_uuid(s: &str) -> bool {
41    s.len() == 36
42        && s.bytes().enumerate().all(|(i, b)| match i {
43            8 | 13 | 18 | 23 => b == b'-',
44            _ => b.is_ascii_digit() || (b'a'..=b'f').contains(&b),
45        })
46}
47
48/// The 32 bytes of a SHA-256 hash in its one written form: `sha256:` and 64 lower-case hexadecimal digits.
49pub(crate) fn parse_sha256(s: &str) -> Option<[u8; 32]> {
50    let hex = s.strip_prefix("sha256:")?;
51    if hex.len() != 64 || hex.bytes().any(|b| b.is_ascii_uppercase()) {
52        return None;
53    }
54    let mut bytes = [0u8; 32];
55    hex::decode_to_slice(hex, &mut bytes).ok()?;
56    Some(bytes)
57}
58
59/// Deserialises a list that, when present, is not empty – absent is the one form of "none".
60pub(crate) fn non_empty<'de, D: serde::Deserializer<'de>, T: serde::Deserialize<'de>>(
61    deserializer: D,
62) -> Result<Vec<T>, D::Error> {
63    let items = <Vec<T> as serde::Deserialize>::deserialize(deserializer)?;
64    if items.is_empty() { Err(serde::de::Error::custom("an empty list is left out, not written")) } else { Ok(items) }
65}
66
67/// Deserialises an integer that the canonical encoding can represent (at most 2^53 − 1).
68pub(crate) fn safe_u64<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
69    let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
70    if value > MAX_SAFE_INTEGER {
71        return Err(serde::de::Error::custom(format!("{value} is larger than 2^53 - 1")));
72    }
73    Ok(value)
74}
75
76mod balance;
77mod booking;
78mod canonical;
79mod chart;
80mod clock;
81mod date;
82mod decimal;
83mod document;
84mod event;
85mod money;
86mod names;
87mod object;
88mod party;
89mod report;
90mod tax;
91
92pub use balance::{BalanceError, Balances};
93pub use booking::{AccountId, Booking, BookingError, Posting};
94pub use canonical::{CanonicalError, MAX_SAFE_INTEGER, canonicalize, to_canonical_vec};
95pub use chart::{Account, AccountKind, Chart, ChartError, ChartTranslation, TranslationError};
96pub use clock::{Clock, DeviceId, Hlc};
97pub use date::Date;
98pub use decimal::Decimal;
99pub use document::{ContentHash, Document, DocumentId, DocumentKind, Original};
100pub use event::{AccountProblem, ChainProblem, DocumentProblem, Event, EventHash, EventSet, PartyProblem, Payload};
101pub use money::{Currency, CurrencyDefinition, CurrencyTable, DefinitionError, Money};
102pub use names::Names;
103pub use object::{Object, ObjectError, from_root, to_root};
104pub use party::{Country, Organisation, PartyId, Person, Registration, Role, RoleKind, Shares, TaxId};
105pub use report::{MappingStatus, ReportMapping, TaxRule, VatAccounts};
106pub use tax::{TaxCode, TaxCodeDefinition, TaxDirection, TaxTable};
107
108/// A value in the protocol that could not be read.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum ParseError {
111    /// A decimal number not in its one form, or with more than [`Decimal::MAX_SCALE`] places.
112    #[error("not a decimal number: {0:?}")]
113    Decimal(String),
114    /// A currency code that is neither three upper-case letters (ISO 4217) nor `x-` and a name.
115    #[error("not a currency code: {0:?}")]
116    Currency(String),
117    /// A date that is not a valid `YYYY-MM-DD`.
118    #[error("not a date: {0:?}")]
119    Date(String),
120    /// A device name that is not 1–64 characters of `a-z`, `0-9` and `-`.
121    #[error("not a device id: {0:?}")]
122    Device(String),
123    /// An account identity that is not a UUID in lower case (`8-4-4-4-12` hexadecimal digits).
124    #[error("not an account id: {0:?}")]
125    Account(String),
126    /// A document identity that is not a UUID in lower case.
127    #[error("not a document id: {0:?}")]
128    Document(String),
129    /// A party identity that is not a UUID in lower case.
130    #[error("not a party id: {0:?}")]
131    Party(String),
132    /// A country that is not two upper-case letters (ISO 3166-1 alpha-2).
133    #[error("not a country code: {0:?}")]
134    Country(String),
135    /// A tax code that is not a country in lower case, `-` and a name.
136    #[error("not a tax code: {0:?}")]
137    TaxCode(String),
138    /// A hash that is not `sha256:` and 64 lower-case hexadecimal digits.
139    #[error("not a hash: {0:?}")]
140    Hash(String),
141}