Skip to main content

pactole_core/models/
names.rs

1use crate::errors::ModelError;
2use std::borrow::Borrow;
3use std::collections::BTreeMap;
4use std::fmt;
5
6/// Metadata attached to a directive or posting.
7pub type Metadata = BTreeMap<MetadataKey, String>;
8
9/// A metadata key: only lowercase letters, `_` or `-` (see the `key` rule
10/// in `grammar.js`).
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct MetadataKey(String);
13
14impl MetadataKey {
15    /// Build a new metadata key, validating it against the grammar's `key`
16    /// rule: `/[a-z_\-]+/`.
17    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
18        let value = value.into();
19        if is_valid_metadata_key(&value) {
20            Ok(Self(value))
21        } else {
22            Err(ModelError::InvalidMetadataKey(value))
23        }
24    }
25
26    /// Returns the validated key as a string slice.
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30}
31
32impl Borrow<str> for MetadataKey {
33    fn borrow(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for MetadataKey {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44/// An account name: colon-separated segments, the first one starting with
45/// an uppercase letter, each made of letters, digits, `_` or `-` (see the
46/// `account` rule in `grammar.js`).
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct AccountName(String);
49
50impl AccountName {
51    /// Build a new account name, validating it against the grammar's
52    /// `account` rule: `/[A-Z][A-Za-z0-9_\-]*(?::[A-Za-z0-9_\-]+)*/`.
53    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
54        let value = value.into();
55        if is_valid_account_name(&value) {
56            Ok(Self(value))
57        } else {
58            Err(ModelError::InvalidAccountName(value))
59        }
60    }
61
62    /// Returns the validated account name as a string slice.
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl fmt::Display for AccountName {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(&self.0)
71    }
72}
73
74/// A commodity name: a Beancount-like currency code of 2 to 24 characters,
75/// starting with an uppercase letter, ending with an uppercase letter or
76/// digit, and made of uppercase letters, digits, `'`, `.`, `_` or `-` in
77/// between (see the `commodity_name` rule in `grammar.js`).
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79pub struct CommodityName(String);
80
81impl CommodityName {
82    /// Build a new commodity name, validating it against the grammar's
83    /// `commodity_name` rule: `/[A-Z][A-Z0-9'._\-]{0,22}[A-Z0-9]/`.
84    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
85        let value = value.into();
86        if is_valid_commodity_name(&value) {
87            Ok(Self(value))
88        } else {
89            Err(ModelError::InvalidCommodityName(value))
90        }
91    }
92
93    /// Returns the validated commodity name as a string slice.
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98
99impl fmt::Display for CommodityName {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str(&self.0)
102    }
103}
104
105fn is_valid_metadata_key(value: &str) -> bool {
106    !value.is_empty()
107        && value
108            .chars()
109            .all(|c| c.is_ascii_lowercase() || c == '_' || c == '-')
110}
111
112fn is_valid_account_name(value: &str) -> bool {
113    let mut segments = value.split(':');
114
115    let Some(first) = segments.next() else {
116        return false;
117    };
118    if !is_valid_account_first_segment(first) {
119        return false;
120    }
121
122    segments.all(is_valid_account_other_segment)
123}
124
125fn is_valid_account_first_segment(segment: &str) -> bool {
126    let mut chars = segment.chars();
127    match chars.next() {
128        Some(c) if c.is_ascii_uppercase() => {}
129        _ => return false,
130    }
131    chars.all(is_account_char)
132}
133
134fn is_valid_account_other_segment(segment: &str) -> bool {
135    !segment.is_empty() && segment.chars().all(is_account_char)
136}
137
138fn is_account_char(c: char) -> bool {
139    c.is_ascii_alphanumeric() || c == '_' || c == '-'
140}
141
142fn is_valid_commodity_name(value: &str) -> bool {
143    let chars: Vec<char> = value.chars().collect();
144    let len = chars.len();
145
146    // Beancount-like currency code: 2 to 24 characters, starting with an
147    // uppercase letter, ending with an uppercase letter or digit, and made
148    // of uppercase letters, digits, `'`, `.`, `_` or `-` in between.
149    if !(2..=24).contains(&len) {
150        return false;
151    }
152    if !chars[0].is_ascii_uppercase() {
153        return false;
154    }
155    let last = chars[len - 1];
156    if !(last.is_ascii_uppercase() || last.is_ascii_digit()) {
157        return false;
158    }
159    chars[1..len - 1].iter().all(|&c| {
160        c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '\'' | '.' | '_' | '-')
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn account_name_accepts_valid_values() {
170        assert!(AccountName::new("Actifs").is_ok());
171        assert!(AccountName::new("Actifs:Compte-Joint").is_ok());
172        assert!(AccountName::new("Actifs:Compte_Joint:sous2").is_ok());
173    }
174
175    #[test]
176    fn account_name_rejects_invalid_values() {
177        assert!(AccountName::new("").is_err());
178        assert!(AccountName::new("actifs").is_err());
179        assert!(AccountName::new("Actifs:").is_err());
180        assert!(AccountName::new(":Actifs").is_err());
181        assert!(AccountName::new("Actifs::Compte").is_err());
182        assert!(AccountName::new("Actifs Compte").is_err());
183    }
184
185    #[test]
186    fn commodity_name_accepts_valid_values() {
187        assert!(CommodityName::new("EUR").is_ok());
188        assert!(CommodityName::new("BTC.SAT-2").is_ok());
189        assert!(CommodityName::new("BRK'A").is_ok());
190        assert!(CommodityName::new("AB").is_ok());
191    }
192
193    #[test]
194    fn commodity_name_rejects_invalid_values() {
195        assert!(CommodityName::new("").is_err());
196        assert!(CommodityName::new("A").is_err()); // too short (min 2 chars)
197        assert!(CommodityName::new("eur").is_err());
198        assert!(CommodityName::new("1EUR").is_err());
199        assert!(CommodityName::new("EU R").is_err());
200        assert!(CommodityName::new("EUR-").is_err()); // must end with letter/digit
201        assert!(CommodityName::new(format!("A{}", "B".repeat(24))).is_err()); // too long (> 24 chars)
202    }
203
204    #[test]
205    fn metadata_key_accepts_valid_values() {
206        assert!(MetadataKey::new("note").is_ok());
207        assert!(MetadataKey::new("opened_on").is_ok());
208        assert!(MetadataKey::new("some-key").is_ok());
209    }
210
211    #[test]
212    fn metadata_key_rejects_invalid_values() {
213        assert!(MetadataKey::new("").is_err());
214        assert!(MetadataKey::new("Note").is_err());
215        assert!(MetadataKey::new("note:").is_err());
216        assert!(MetadataKey::new("note key").is_err());
217    }
218}