pactole_core/models/
names.rs1use crate::errors::ModelError;
2use std::borrow::Borrow;
3use std::collections::BTreeMap;
4use std::fmt;
5
6pub type Metadata = BTreeMap<MetadataKey, String>;
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct MetadataKey(String);
13
14impl MetadataKey {
15 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct AccountName(String);
49
50impl AccountName {
51 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79pub struct CommodityName(String);
80
81impl CommodityName {
82 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 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 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()); 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()); assert!(CommodityName::new(format!("A{}", "B".repeat(24))).is_err()); }
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}