1use crate::NaiveDate;
19use rust_decimal::Decimal;
20use rustc_hash::FxHashMap;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24use crate::intern::InternedStr;
25#[cfg(feature = "rkyv")]
26use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate, AsOptionInternedStr, AsVecInternedStr};
27use crate::{Amount, CostSpec, IncompleteAmount};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(
32 feature = "rkyv",
33 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
34)]
35pub enum MetaValue {
36 String(String),
38 Account(String),
40 Currency(String),
42 Tag(String),
44 Link(String),
46 Date(#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))] NaiveDate),
48 Number(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
50 Bool(bool),
52 Amount(Amount),
54 None,
56}
57
58impl fmt::Display for MetaValue {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::String(s) => write!(f, "\"{s}\""),
62 Self::Account(a) => write!(f, "{a}"),
63 Self::Currency(c) => write!(f, "{c}"),
64 Self::Tag(t) => write!(f, "#{t}"),
65 Self::Link(l) => write!(f, "^{l}"),
66 Self::Date(d) => write!(f, "{d}"),
67 Self::Number(n) => write!(f, "{n}"),
68 Self::Bool(b) => write!(f, "{b}"),
69 Self::Amount(a) => write!(f, "{a}"),
70 Self::None => write!(f, "None"),
71 }
72 }
73}
74
75pub type Metadata = FxHashMap<String, MetaValue>;
77
78#[must_use = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
91pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
92 use rust_decimal::prelude::ToPrimitive;
93 let MetaValue::Number(n) = value else {
94 return Err(format!(
95 "expected a non-negative integer, got {} value",
96 meta_value_kind(value)
97 ));
98 };
99 if n.is_sign_negative() {
100 return Err(format!("expected a non-negative integer, got {n}"));
101 }
102 if !n.fract().is_zero() {
103 return Err(format!("expected an integer, got {n}"));
104 }
105 n.to_u32().ok_or_else(|| {
106 format!(
107 "value {n} exceeds the maximum supported precision ({})",
108 u32::MAX
109 )
110 })
111}
112
113const fn meta_value_kind(v: &MetaValue) -> &'static str {
114 match v {
115 MetaValue::String(_) => "string",
116 MetaValue::Account(_) => "account",
117 MetaValue::Currency(_) => "currency",
118 MetaValue::Tag(_) => "tag",
119 MetaValue::Link(_) => "link",
120 MetaValue::Date(_) => "date",
121 MetaValue::Number(_) => "number",
122 MetaValue::Bool(_) => "bool",
123 MetaValue::Amount(_) => "amount",
124 MetaValue::None => "none",
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(
139 feature = "rkyv",
140 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
141)]
142pub struct Posting {
143 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
145 pub account: InternedStr,
146 pub units: Option<IncompleteAmount>,
148 pub cost: Option<CostSpec>,
150 pub price: Option<PriceAnnotation>,
152 pub flag: Option<char>,
154 pub meta: Metadata,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub comments: Vec<String>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub trailing_comments: Vec<String>,
162}
163
164impl Posting {
165 #[must_use]
167 pub fn new(account: impl Into<InternedStr>, units: Amount) -> Self {
168 Self {
169 account: account.into(),
170 units: Some(IncompleteAmount::Complete(units)),
171 cost: None,
172 price: None,
173 flag: None,
174 meta: Metadata::default(),
175 comments: Vec::new(),
176 trailing_comments: Vec::new(),
177 }
178 }
179
180 #[must_use]
182 pub fn with_incomplete(account: impl Into<InternedStr>, units: IncompleteAmount) -> Self {
183 Self {
184 account: account.into(),
185 units: Some(units),
186 cost: None,
187 price: None,
188 flag: None,
189 meta: Metadata::default(),
190 comments: Vec::new(),
191 trailing_comments: Vec::new(),
192 }
193 }
194
195 #[must_use]
197 pub fn auto(account: impl Into<InternedStr>) -> Self {
198 Self {
199 account: account.into(),
200 units: None,
201 cost: None,
202 price: None,
203 flag: None,
204 meta: Metadata::default(),
205 comments: Vec::new(),
206 trailing_comments: Vec::new(),
207 }
208 }
209
210 #[must_use]
212 pub fn amount(&self) -> Option<&Amount> {
213 self.units.as_ref().and_then(|u| u.as_amount())
214 }
215
216 #[must_use]
218 pub fn with_cost(mut self, cost: CostSpec) -> Self {
219 self.cost = Some(cost);
220 self
221 }
222
223 #[must_use]
225 pub fn with_price(mut self, price: PriceAnnotation) -> Self {
226 self.price = Some(price);
227 self
228 }
229
230 #[must_use]
232 pub const fn with_flag(mut self, flag: char) -> Self {
233 self.flag = Some(flag);
234 self
235 }
236
237 #[must_use]
239 pub const fn has_units(&self) -> bool {
240 self.units.is_some()
241 }
242}
243
244impl fmt::Display for Posting {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 write!(f, " ")?;
247 if let Some(flag) = self.flag {
248 write!(f, "{flag} ")?;
249 }
250 write!(f, "{}", self.account)?;
251 if let Some(units) = &self.units {
252 write!(f, " {units}")?;
253 }
254 if let Some(cost) = &self.cost {
255 write!(f, " {cost}")?;
256 }
257 if let Some(price) = &self.price {
258 write!(f, " {price}")?;
259 }
260 for (key, value) in &self.meta {
262 write!(f, "\n {key}: {value}")?;
263 }
264 Ok(())
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(
274 feature = "rkyv",
275 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
276)]
277pub enum PriceAnnotation {
278 Unit(Amount),
280 Total(Amount),
282 UnitIncomplete(IncompleteAmount),
284 TotalIncomplete(IncompleteAmount),
286 UnitEmpty,
288 TotalEmpty,
290}
291
292impl PriceAnnotation {
293 #[must_use]
295 pub const fn amount(&self) -> Option<&Amount> {
296 match self {
297 Self::Unit(a) | Self::Total(a) => Some(a),
298 Self::UnitIncomplete(ia) | Self::TotalIncomplete(ia) => ia.as_amount(),
299 Self::UnitEmpty | Self::TotalEmpty => None,
300 }
301 }
302
303 #[must_use]
305 pub const fn is_unit(&self) -> bool {
306 matches!(
307 self,
308 Self::Unit(_) | Self::UnitIncomplete(_) | Self::UnitEmpty
309 )
310 }
311}
312
313impl fmt::Display for PriceAnnotation {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 match self {
316 Self::Unit(a) => write!(f, "@ {a}"),
317 Self::Total(a) => write!(f, "@@ {a}"),
318 Self::UnitIncomplete(ia) => write!(f, "@ {ia}"),
319 Self::TotalIncomplete(ia) => write!(f, "@@ {ia}"),
320 Self::UnitEmpty => write!(f, "@"),
321 Self::TotalEmpty => write!(f, "@@"),
322 }
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
331pub enum DirectivePriority {
332 Open = 0,
334 Commodity = 1,
336 Pad = 2,
338 Balance = 3,
340 Transaction = 4,
342 Note = 5,
344 Document = 6,
346 Event = 7,
348 Query = 8,
350 Price = 9,
352 Close = 10,
354 Custom = 11,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(
361 feature = "rkyv",
362 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
363)]
364pub enum Directive {
365 Transaction(Transaction),
367 Balance(Balance),
369 Open(Open),
371 Close(Close),
373 Commodity(Commodity),
375 Pad(Pad),
377 Event(Event),
379 Query(Query),
381 Note(Note),
383 Document(Document),
385 Price(Price),
387 Custom(Custom),
389}
390
391impl Directive {
392 #[must_use]
394 pub const fn date(&self) -> NaiveDate {
395 match self {
396 Self::Transaction(t) => t.date,
397 Self::Balance(b) => b.date,
398 Self::Open(o) => o.date,
399 Self::Close(c) => c.date,
400 Self::Commodity(c) => c.date,
401 Self::Pad(p) => p.date,
402 Self::Event(e) => e.date,
403 Self::Query(q) => q.date,
404 Self::Note(n) => n.date,
405 Self::Document(d) => d.date,
406 Self::Price(p) => p.date,
407 Self::Custom(c) => c.date,
408 }
409 }
410
411 #[must_use]
413 pub const fn meta(&self) -> &Metadata {
414 match self {
415 Self::Transaction(t) => &t.meta,
416 Self::Balance(b) => &b.meta,
417 Self::Open(o) => &o.meta,
418 Self::Close(c) => &c.meta,
419 Self::Commodity(c) => &c.meta,
420 Self::Pad(p) => &p.meta,
421 Self::Event(e) => &e.meta,
422 Self::Query(q) => &q.meta,
423 Self::Note(n) => &n.meta,
424 Self::Document(d) => &d.meta,
425 Self::Price(p) => &p.meta,
426 Self::Custom(c) => &c.meta,
427 }
428 }
429
430 #[must_use]
432 pub const fn is_transaction(&self) -> bool {
433 matches!(self, Self::Transaction(_))
434 }
435
436 #[must_use]
438 pub const fn as_transaction(&self) -> Option<&Transaction> {
439 match self {
440 Self::Transaction(t) => Some(t),
441 _ => None,
442 }
443 }
444
445 #[must_use]
447 pub const fn type_name(&self) -> &'static str {
448 match self {
449 Self::Transaction(_) => "transaction",
450 Self::Balance(_) => "balance",
451 Self::Open(_) => "open",
452 Self::Close(_) => "close",
453 Self::Commodity(_) => "commodity",
454 Self::Pad(_) => "pad",
455 Self::Event(_) => "event",
456 Self::Query(_) => "query",
457 Self::Note(_) => "note",
458 Self::Document(_) => "document",
459 Self::Price(_) => "price",
460 Self::Custom(_) => "custom",
461 }
462 }
463
464 #[must_use]
468 pub const fn priority(&self) -> DirectivePriority {
469 match self {
470 Self::Open(_) => DirectivePriority::Open,
471 Self::Commodity(_) => DirectivePriority::Commodity,
472 Self::Pad(_) => DirectivePriority::Pad,
473 Self::Balance(_) => DirectivePriority::Balance,
474 Self::Transaction(_) => DirectivePriority::Transaction,
475 Self::Note(_) => DirectivePriority::Note,
476 Self::Document(_) => DirectivePriority::Document,
477 Self::Event(_) => DirectivePriority::Event,
478 Self::Query(_) => DirectivePriority::Query,
479 Self::Price(_) => DirectivePriority::Price,
480 Self::Close(_) => DirectivePriority::Close,
481 Self::Custom(_) => DirectivePriority::Custom,
482 }
483 }
484
485 #[must_use]
492 pub fn has_cost_reduction(&self) -> bool {
493 if let Self::Transaction(txn) = self {
494 txn.postings.iter().any(|p| {
495 p.cost.is_some()
496 && p.units
497 .as_ref()
498 .and_then(IncompleteAmount::number)
499 .is_some_and(|n| n.is_sign_negative())
500 })
501 } else {
502 false
503 }
504 }
505}
506
507pub fn sort_directives(directives: &mut [Directive]) {
517 directives.sort_by_cached_key(|d| (d.date(), d.priority(), d.has_cost_reduction()));
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525#[cfg_attr(
526 feature = "rkyv",
527 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
528)]
529pub struct Transaction {
530 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
532 pub date: NaiveDate,
533 pub flag: char,
535 #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
537 pub payee: Option<InternedStr>,
538 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
540 pub narration: InternedStr,
541 #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
543 pub tags: Vec<InternedStr>,
544 #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
546 pub links: Vec<InternedStr>,
547 pub meta: Metadata,
549 pub postings: Vec<Posting>,
551 #[serde(default, skip_serializing_if = "Vec::is_empty")]
553 pub trailing_comments: Vec<String>,
554}
555
556impl Transaction {
557 #[must_use]
559 pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
560 Self {
561 date,
562 flag: '*',
563 payee: None,
564 narration: narration.into(),
565 tags: Vec::new(),
566 links: Vec::new(),
567 meta: Metadata::default(),
568 postings: Vec::new(),
569 trailing_comments: Vec::new(),
570 }
571 }
572
573 #[must_use]
575 pub const fn with_flag(mut self, flag: char) -> Self {
576 self.flag = flag;
577 self
578 }
579
580 #[must_use]
582 pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
583 self.payee = Some(payee.into());
584 self
585 }
586
587 #[must_use]
589 pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
590 self.tags.push(tag.into());
591 self
592 }
593
594 #[must_use]
596 pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
597 self.links.push(link.into());
598 self
599 }
600
601 #[must_use]
603 pub fn with_posting(mut self, posting: Posting) -> Self {
604 self.postings.push(posting);
605 self
606 }
607
608 #[must_use]
610 pub const fn is_complete(&self) -> bool {
611 self.flag == '*'
612 }
613
614 #[must_use]
616 pub const fn is_incomplete(&self) -> bool {
617 self.flag == '!'
618 }
619
620 #[must_use]
623 pub const fn is_pending(&self) -> bool {
624 self.flag == '!'
625 }
626
627 #[must_use]
629 pub const fn is_pad_generated(&self) -> bool {
630 self.flag == 'P'
631 }
632
633 #[must_use]
635 pub const fn is_summarization(&self) -> bool {
636 self.flag == 'S'
637 }
638
639 #[must_use]
641 pub const fn is_transfer(&self) -> bool {
642 self.flag == 'T'
643 }
644
645 #[must_use]
647 pub const fn is_conversion(&self) -> bool {
648 self.flag == 'C'
649 }
650
651 #[must_use]
653 pub const fn is_unrealized(&self) -> bool {
654 self.flag == 'U'
655 }
656
657 #[must_use]
659 pub const fn is_return(&self) -> bool {
660 self.flag == 'R'
661 }
662
663 #[must_use]
665 pub const fn is_merge(&self) -> bool {
666 self.flag == 'M'
667 }
668
669 #[must_use]
671 pub const fn is_bookmarked(&self) -> bool {
672 self.flag == '#'
673 }
674
675 #[must_use]
677 pub const fn needs_investigation(&self) -> bool {
678 self.flag == '?'
679 }
680
681 #[must_use]
683 pub const fn is_valid_flag(flag: char) -> bool {
684 matches!(
685 flag,
686 '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
687 )
688 }
689}
690
691impl fmt::Display for Transaction {
692 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693 write!(f, "{} {} ", self.date, self.flag)?;
694 if let Some(payee) = &self.payee {
695 write!(f, "\"{payee}\" ")?;
696 }
697 write!(f, "\"{}\"", self.narration)?;
698 for tag in &self.tags {
699 write!(f, " #{tag}")?;
700 }
701 for link in &self.links {
702 write!(f, " ^{link}")?;
703 }
704 for (key, value) in &self.meta {
706 write!(f, "\n {key}: {value}")?;
707 }
708 for posting in &self.postings {
709 write!(f, "\n{posting}")?;
710 }
711 Ok(())
712 }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719#[cfg_attr(
720 feature = "rkyv",
721 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
722)]
723pub struct Balance {
724 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
726 pub date: NaiveDate,
727 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
729 pub account: InternedStr,
730 pub amount: Amount,
732 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
734 pub tolerance: Option<Decimal>,
735 pub meta: Metadata,
737}
738
739impl Balance {
740 #[must_use]
742 pub fn new(date: NaiveDate, account: impl Into<InternedStr>, amount: Amount) -> Self {
743 Self {
744 date,
745 account: account.into(),
746 amount,
747 tolerance: None,
748 meta: Metadata::default(),
749 }
750 }
751
752 #[must_use]
754 pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
755 self.tolerance = Some(tolerance);
756 self
757 }
758
759 #[must_use]
761 pub fn with_meta(mut self, meta: Metadata) -> Self {
762 self.meta = meta;
763 self
764 }
765}
766
767impl fmt::Display for Balance {
768 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
769 write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
770 if let Some(tol) = self.tolerance {
771 write!(f, " ~ {tol}")?;
772 }
773 Ok(())
774 }
775}
776
777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[cfg_attr(
782 feature = "rkyv",
783 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
784)]
785pub struct Open {
786 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
788 pub date: NaiveDate,
789 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
791 pub account: InternedStr,
792 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
794 pub currencies: Vec<InternedStr>,
795 pub booking: Option<String>,
797 pub meta: Metadata,
799}
800
801impl Open {
802 #[must_use]
804 pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
805 Self {
806 date,
807 account: account.into(),
808 currencies: Vec::new(),
809 booking: None,
810 meta: Metadata::default(),
811 }
812 }
813
814 #[must_use]
816 pub fn with_currencies(mut self, currencies: Vec<InternedStr>) -> Self {
817 self.currencies = currencies;
818 self
819 }
820
821 #[must_use]
823 pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
824 self.booking = Some(booking.into());
825 self
826 }
827
828 #[must_use]
830 pub fn with_meta(mut self, meta: Metadata) -> Self {
831 self.meta = meta;
832 self
833 }
834}
835
836impl fmt::Display for Open {
837 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
838 write!(f, "{} open {}", self.date, self.account)?;
839 if !self.currencies.is_empty() {
840 let currencies: Vec<&str> = self.currencies.iter().map(InternedStr::as_str).collect();
841 write!(f, " {}", currencies.join(","))?;
842 }
843 if let Some(booking) = &self.booking {
844 write!(f, " \"{booking}\"")?;
845 }
846 Ok(())
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
854#[cfg_attr(
855 feature = "rkyv",
856 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
857)]
858pub struct Close {
859 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
861 pub date: NaiveDate,
862 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
864 pub account: InternedStr,
865 pub meta: Metadata,
867}
868
869impl Close {
870 #[must_use]
872 pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
873 Self {
874 date,
875 account: account.into(),
876 meta: Metadata::default(),
877 }
878 }
879
880 #[must_use]
882 pub fn with_meta(mut self, meta: Metadata) -> Self {
883 self.meta = meta;
884 self
885 }
886}
887
888impl fmt::Display for Close {
889 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
890 write!(f, "{} close {}", self.date, self.account)
891 }
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898#[cfg_attr(
899 feature = "rkyv",
900 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
901)]
902pub struct Commodity {
903 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
905 pub date: NaiveDate,
906 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
908 pub currency: InternedStr,
909 pub meta: Metadata,
911}
912
913impl Commodity {
914 #[must_use]
916 pub fn new(date: NaiveDate, currency: impl Into<InternedStr>) -> Self {
917 Self {
918 date,
919 currency: currency.into(),
920 meta: Metadata::default(),
921 }
922 }
923
924 #[must_use]
926 pub fn with_meta(mut self, meta: Metadata) -> Self {
927 self.meta = meta;
928 self
929 }
930}
931
932impl fmt::Display for Commodity {
933 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
934 write!(f, "{} commodity {}", self.date, self.currency)
935 }
936}
937
938#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
943#[cfg_attr(
944 feature = "rkyv",
945 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
946)]
947pub struct Pad {
948 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
950 pub date: NaiveDate,
951 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
953 pub account: InternedStr,
954 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
956 pub source_account: InternedStr,
957 pub meta: Metadata,
959}
960
961impl Pad {
962 #[must_use]
964 pub fn new(
965 date: NaiveDate,
966 account: impl Into<InternedStr>,
967 source_account: impl Into<InternedStr>,
968 ) -> Self {
969 Self {
970 date,
971 account: account.into(),
972 source_account: source_account.into(),
973 meta: Metadata::default(),
974 }
975 }
976
977 #[must_use]
979 pub fn with_meta(mut self, meta: Metadata) -> Self {
980 self.meta = meta;
981 self
982 }
983}
984
985impl fmt::Display for Pad {
986 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
987 write!(
988 f,
989 "{} pad {} {}",
990 self.date, self.account, self.source_account
991 )
992 }
993}
994
995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(
1000 feature = "rkyv",
1001 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1002)]
1003pub struct Event {
1004 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1006 pub date: NaiveDate,
1007 pub event_type: String,
1009 pub value: String,
1011 pub meta: Metadata,
1013}
1014
1015impl Event {
1016 #[must_use]
1018 pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1019 Self {
1020 date,
1021 event_type: event_type.into(),
1022 value: value.into(),
1023 meta: Metadata::default(),
1024 }
1025 }
1026
1027 #[must_use]
1029 pub fn with_meta(mut self, meta: Metadata) -> Self {
1030 self.meta = meta;
1031 self
1032 }
1033}
1034
1035impl fmt::Display for Event {
1036 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1037 write!(
1038 f,
1039 "{} event \"{}\" \"{}\"",
1040 self.date, self.event_type, self.value
1041 )
1042 }
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049#[cfg_attr(
1050 feature = "rkyv",
1051 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1052)]
1053pub struct Query {
1054 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1056 pub date: NaiveDate,
1057 pub name: String,
1059 pub query: String,
1061 pub meta: Metadata,
1063}
1064
1065impl Query {
1066 #[must_use]
1068 pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1069 Self {
1070 date,
1071 name: name.into(),
1072 query: query.into(),
1073 meta: Metadata::default(),
1074 }
1075 }
1076
1077 #[must_use]
1079 pub fn with_meta(mut self, meta: Metadata) -> Self {
1080 self.meta = meta;
1081 self
1082 }
1083}
1084
1085impl fmt::Display for Query {
1086 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1087 write!(
1088 f,
1089 "{} query \"{}\" \"{}\"",
1090 self.date, self.name, self.query
1091 )
1092 }
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1099#[cfg_attr(
1100 feature = "rkyv",
1101 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1102)]
1103pub struct Note {
1104 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1106 pub date: NaiveDate,
1107 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1109 pub account: InternedStr,
1110 pub comment: String,
1112 pub meta: Metadata,
1114}
1115
1116impl Note {
1117 #[must_use]
1119 pub fn new(
1120 date: NaiveDate,
1121 account: impl Into<InternedStr>,
1122 comment: impl Into<String>,
1123 ) -> Self {
1124 Self {
1125 date,
1126 account: account.into(),
1127 comment: comment.into(),
1128 meta: Metadata::default(),
1129 }
1130 }
1131
1132 #[must_use]
1134 pub fn with_meta(mut self, meta: Metadata) -> Self {
1135 self.meta = meta;
1136 self
1137 }
1138}
1139
1140impl fmt::Display for Note {
1141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1142 write!(
1143 f,
1144 "{} note {} \"{}\"",
1145 self.date, self.account, self.comment
1146 )
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1154#[cfg_attr(
1155 feature = "rkyv",
1156 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1157)]
1158pub struct Document {
1159 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1161 pub date: NaiveDate,
1162 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1164 pub account: InternedStr,
1165 pub path: String,
1167 #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
1169 pub tags: Vec<InternedStr>,
1170 #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
1172 pub links: Vec<InternedStr>,
1173 pub meta: Metadata,
1175}
1176
1177impl Document {
1178 #[must_use]
1180 pub fn new(date: NaiveDate, account: impl Into<InternedStr>, path: impl Into<String>) -> Self {
1181 Self {
1182 date,
1183 account: account.into(),
1184 path: path.into(),
1185 tags: Vec::new(),
1186 links: Vec::new(),
1187 meta: Metadata::default(),
1188 }
1189 }
1190
1191 #[must_use]
1193 pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
1194 self.tags.push(tag.into());
1195 self
1196 }
1197
1198 #[must_use]
1200 pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
1201 self.links.push(link.into());
1202 self
1203 }
1204
1205 #[must_use]
1207 pub fn with_meta(mut self, meta: Metadata) -> Self {
1208 self.meta = meta;
1209 self
1210 }
1211}
1212
1213impl fmt::Display for Document {
1214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1215 write!(
1216 f,
1217 "{} document {} \"{}\"",
1218 self.date, self.account, self.path
1219 )
1220 }
1221}
1222
1223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1227#[cfg_attr(
1228 feature = "rkyv",
1229 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1230)]
1231pub struct Price {
1232 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1234 pub date: NaiveDate,
1235 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1237 pub currency: InternedStr,
1238 pub amount: Amount,
1240 pub meta: Metadata,
1242}
1243
1244impl Price {
1245 #[must_use]
1247 pub fn new(date: NaiveDate, currency: impl Into<InternedStr>, amount: Amount) -> Self {
1248 Self {
1249 date,
1250 currency: currency.into(),
1251 amount,
1252 meta: Metadata::default(),
1253 }
1254 }
1255
1256 #[must_use]
1258 pub fn with_meta(mut self, meta: Metadata) -> Self {
1259 self.meta = meta;
1260 self
1261 }
1262}
1263
1264impl fmt::Display for Price {
1265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1266 write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1267 }
1268}
1269
1270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1274#[cfg_attr(
1275 feature = "rkyv",
1276 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1277)]
1278pub struct Custom {
1279 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1281 pub date: NaiveDate,
1282 pub custom_type: String,
1284 pub values: Vec<MetaValue>,
1286 pub meta: Metadata,
1288}
1289
1290impl Custom {
1291 #[must_use]
1293 pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1294 Self {
1295 date,
1296 custom_type: custom_type.into(),
1297 values: Vec::new(),
1298 meta: Metadata::default(),
1299 }
1300 }
1301
1302 #[must_use]
1304 pub fn with_value(mut self, value: MetaValue) -> Self {
1305 self.values.push(value);
1306 self
1307 }
1308
1309 #[must_use]
1311 pub fn with_meta(mut self, meta: Metadata) -> Self {
1312 self.meta = meta;
1313 self
1314 }
1315}
1316
1317impl fmt::Display for Custom {
1318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1319 write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
1320 for value in &self.values {
1321 write!(f, " {value}")?;
1322 }
1323 Ok(())
1324 }
1325}
1326
1327impl fmt::Display for Directive {
1328 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1329 match self {
1330 Self::Transaction(t) => write!(f, "{t}"),
1331 Self::Balance(b) => write!(f, "{b}"),
1332 Self::Open(o) => write!(f, "{o}"),
1333 Self::Close(c) => write!(f, "{c}"),
1334 Self::Commodity(c) => write!(f, "{c}"),
1335 Self::Pad(p) => write!(f, "{p}"),
1336 Self::Event(e) => write!(f, "{e}"),
1337 Self::Query(q) => write!(f, "{q}"),
1338 Self::Note(n) => write!(f, "{n}"),
1339 Self::Document(d) => write!(f, "{d}"),
1340 Self::Price(p) => write!(f, "{p}"),
1341 Self::Custom(c) => write!(f, "{c}"),
1342 }
1343 }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::*;
1349 use rust_decimal_macros::dec;
1350
1351 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1352 crate::naive_date(year, month, day).unwrap()
1353 }
1354
1355 #[test]
1356 fn test_transaction() {
1357 let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1358 .with_payee("Whole Foods")
1359 .with_flag('*')
1360 .with_tag("food")
1361 .with_posting(Posting::new(
1362 "Expenses:Food",
1363 Amount::new(dec!(50.00), "USD"),
1364 ))
1365 .with_posting(Posting::auto("Assets:Checking"));
1366
1367 assert_eq!(txn.flag, '*');
1368 assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1369 assert_eq!(txn.postings.len(), 2);
1370 assert!(txn.is_complete());
1371 }
1372
1373 #[test]
1374 fn test_balance() {
1375 let bal = Balance::new(
1376 date(2024, 1, 1),
1377 "Assets:Checking",
1378 Amount::new(dec!(1000.00), "USD"),
1379 );
1380
1381 assert_eq!(bal.account, "Assets:Checking");
1382 assert_eq!(bal.amount.number, dec!(1000.00));
1383 }
1384
1385 #[test]
1386 fn test_open() {
1387 let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1388 .with_currencies(vec!["USD".into()])
1389 .with_booking("FIFO");
1390
1391 assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1392 assert_eq!(open.booking, Some("FIFO".to_string()));
1393 }
1394
1395 #[test]
1396 fn test_directive_date() {
1397 let txn = Transaction::new(date(2024, 1, 15), "Test");
1398 let dir = Directive::Transaction(txn);
1399
1400 assert_eq!(dir.date(), date(2024, 1, 15));
1401 assert!(dir.is_transaction());
1402 assert_eq!(dir.type_name(), "transaction");
1403 }
1404
1405 #[test]
1406 fn test_posting_display() {
1407 let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1408 let s = format!("{posting}");
1409 assert!(s.contains("Assets:Checking"));
1410 assert!(s.contains("100.00 USD"));
1411 }
1412
1413 #[test]
1414 fn test_transaction_display() {
1415 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1416 .with_payee("Test Payee")
1417 .with_posting(Posting::new(
1418 "Expenses:Test",
1419 Amount::new(dec!(50.00), "USD"),
1420 ))
1421 .with_posting(Posting::auto("Assets:Cash"));
1422
1423 let s = format!("{txn}");
1424 assert!(s.contains("2024-01-15"));
1425 assert!(s.contains("Test Payee"));
1426 assert!(s.contains("Test transaction"));
1427 }
1428
1429 #[test]
1430 fn test_directive_priority() {
1431 assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1433 assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1434 assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1435 assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1436 assert!(DirectivePriority::Price < DirectivePriority::Close);
1437 }
1438
1439 #[test]
1440 fn test_sort_directives_by_date() {
1441 let mut directives = vec![
1442 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1443 Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1444 Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1445 ];
1446
1447 sort_directives(&mut directives);
1448
1449 assert_eq!(directives[0].date(), date(2024, 1, 1));
1450 assert_eq!(directives[1].date(), date(2024, 1, 10));
1451 assert_eq!(directives[2].date(), date(2024, 1, 15));
1452 }
1453
1454 #[test]
1455 fn test_sort_directives_by_type_same_date() {
1456 let mut directives = vec![
1458 Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1459 Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1460 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1461 Directive::Balance(Balance::new(
1462 date(2024, 1, 1),
1463 "Assets:Bank",
1464 Amount::new(dec!(0), "USD"),
1465 )),
1466 ];
1467
1468 sort_directives(&mut directives);
1469
1470 assert_eq!(directives[0].type_name(), "open");
1471 assert_eq!(directives[1].type_name(), "balance");
1472 assert_eq!(directives[2].type_name(), "transaction");
1473 assert_eq!(directives[3].type_name(), "close");
1474 }
1475
1476 #[test]
1477 fn test_sort_directives_pad_before_balance() {
1478 let mut directives = vec![
1480 Directive::Balance(Balance::new(
1481 date(2024, 1, 1),
1482 "Assets:Bank",
1483 Amount::new(dec!(1000), "USD"),
1484 )),
1485 Directive::Pad(Pad::new(
1486 date(2024, 1, 1),
1487 "Assets:Bank",
1488 "Equity:Opening-Balances",
1489 )),
1490 ];
1491
1492 sort_directives(&mut directives);
1493
1494 assert_eq!(directives[0].type_name(), "pad");
1495 assert_eq!(directives[1].type_name(), "balance");
1496 }
1497
1498 #[test]
1499 fn test_sort_augmentations_before_reductions_same_date() {
1500 let reduction = Directive::Transaction(
1504 Transaction::new(date(2024, 9, 1), "Transfer Received")
1505 .with_posting(
1506 Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1507 CostSpec::empty()
1508 .with_number_per(dec!(0.90))
1509 .with_currency("EUR"),
1510 ),
1511 )
1512 .with_posting(
1513 Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1514 CostSpec::empty()
1515 .with_number_per(dec!(0.90))
1516 .with_currency("EUR"),
1517 ),
1518 ),
1519 );
1520
1521 let augmentation = Directive::Transaction(
1522 Transaction::new(date(2024, 9, 1), "Transfer Sent")
1523 .with_posting(Posting::new(
1524 "Assets:AccountA",
1525 Amount::new(dec!(-10.00), "EUR"),
1526 ))
1527 .with_posting(
1528 Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1529 CostSpec::empty()
1530 .with_number_per(dec!(0.90))
1531 .with_currency("EUR"),
1532 ),
1533 ),
1534 );
1535
1536 let mut directives = vec![reduction, augmentation];
1538 sort_directives(&mut directives);
1539
1540 assert!(
1542 !directives[0].has_cost_reduction(),
1543 "first directive should be augmentation"
1544 );
1545 assert!(
1546 directives[1].has_cost_reduction(),
1547 "second directive should be reduction"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_has_cost_reduction() {
1553 let reduction = Directive::Transaction(
1555 Transaction::new(date(2024, 1, 1), "Sell")
1556 .with_posting(
1557 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
1558 CostSpec::empty()
1559 .with_number_per(dec!(150))
1560 .with_currency("USD"),
1561 ),
1562 )
1563 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD"))),
1564 );
1565 assert!(reduction.has_cost_reduction());
1566
1567 let augmentation = Directive::Transaction(
1569 Transaction::new(date(2024, 1, 1), "Buy")
1570 .with_posting(
1571 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1572 CostSpec::empty()
1573 .with_number_per(dec!(150))
1574 .with_currency("USD"),
1575 ),
1576 )
1577 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1578 );
1579 assert!(!augmentation.has_cost_reduction());
1580
1581 let simple = Directive::Transaction(
1583 Transaction::new(date(2024, 1, 1), "Payment")
1584 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
1585 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD"))),
1586 );
1587 assert!(!simple.has_cost_reduction());
1588 }
1589
1590 #[test]
1591 fn test_transaction_flags() {
1592 let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1593
1594 assert!(make_txn('*').is_complete());
1596 assert!(make_txn('!').is_incomplete());
1597 assert!(make_txn('!').is_pending());
1598
1599 assert!(make_txn('P').is_pad_generated());
1601 assert!(make_txn('S').is_summarization());
1602 assert!(make_txn('T').is_transfer());
1603 assert!(make_txn('C').is_conversion());
1604 assert!(make_txn('U').is_unrealized());
1605 assert!(make_txn('R').is_return());
1606 assert!(make_txn('M').is_merge());
1607 assert!(make_txn('#').is_bookmarked());
1608 assert!(make_txn('?').needs_investigation());
1609
1610 assert!(!make_txn('*').is_pending());
1612 assert!(!make_txn('!').is_complete());
1613 assert!(!make_txn('*').is_pad_generated());
1614 }
1615
1616 #[test]
1617 fn test_is_valid_flag() {
1618 for flag in [
1620 '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1621 ] {
1622 assert!(
1623 Transaction::is_valid_flag(flag),
1624 "Flag '{flag}' should be valid"
1625 );
1626 }
1627
1628 for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1630 assert!(
1631 !Transaction::is_valid_flag(flag),
1632 "Flag '{flag}' should be invalid"
1633 );
1634 }
1635 }
1636
1637 #[test]
1638 fn test_transaction_display_includes_metadata() {
1639 let mut meta = Metadata::default();
1640 meta.insert(
1641 "document".to_string(),
1642 MetaValue::String("myfile.pdf".to_string()),
1643 );
1644
1645 let txn = Transaction {
1646 date: date(2026, 2, 23),
1647 flag: '*',
1648 payee: None,
1649 narration: "Example".into(),
1650 tags: vec![],
1651 links: vec![],
1652 meta,
1653 postings: vec![
1654 Posting::new("Assets:Bank", Amount::new(dec!(-2), "USD")),
1655 Posting::auto("Expenses:Example"),
1656 ],
1657 trailing_comments: Vec::new(),
1658 };
1659
1660 let output = txn.to_string();
1661 assert!(
1662 output.contains("document: \"myfile.pdf\""),
1663 "Transaction Display should include metadata: {output}"
1664 );
1665 assert!(
1666 output.contains("Assets:Bank"),
1667 "Transaction Display should include postings: {output}"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_posting_display_includes_metadata() {
1673 let mut meta = Metadata::default();
1674 meta.insert(
1675 "category".to_string(),
1676 MetaValue::String("groceries".to_string()),
1677 );
1678
1679 let posting = Posting {
1680 account: "Expenses:Food".into(),
1681 units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1682 cost: None,
1683 price: None,
1684 flag: None,
1685 meta,
1686 comments: Vec::new(),
1687 trailing_comments: Vec::new(),
1688 };
1689
1690 let output = posting.to_string();
1691 assert!(
1692 output.contains("category: \"groceries\""),
1693 "Posting Display should include metadata: {output}"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_directive_display() {
1699 let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1701 let dir = Directive::Transaction(txn.clone());
1702
1703 assert_eq!(format!("{dir}"), format!("{txn}"));
1705
1706 let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1708 let dir_open = Directive::Open(open.clone());
1709 assert_eq!(format!("{dir_open}"), format!("{open}"));
1710
1711 let balance = Balance::new(
1712 date(2024, 1, 1),
1713 "Assets:Bank",
1714 Amount::new(dec!(100), "USD"),
1715 );
1716 let dir_balance = Directive::Balance(balance.clone());
1717 assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1718 }
1719
1720 #[test]
1723 fn parse_precision_meta_accepts_non_negative_integers() {
1724 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1725 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1726 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1727 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1731 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1732 }
1733
1734 #[test]
1735 fn parse_precision_meta_rejects_negatives() {
1736 let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
1737 assert!(err.contains("non-negative"), "got: {err}");
1738 }
1739
1740 #[test]
1741 fn parse_precision_meta_rejects_fractional() {
1742 let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
1743 assert!(err.contains("integer"), "got: {err}");
1744 }
1745
1746 #[test]
1747 fn parse_precision_meta_rejects_overflow() {
1748 let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
1750 assert!(err.contains("exceeds"), "got: {err}");
1751 }
1752
1753 #[test]
1754 fn parse_precision_meta_rejects_non_number_variants() {
1755 use crate::Amount;
1760 use rust_decimal_macros::dec;
1761 let cases = [
1762 (MetaValue::String("2".into()), "string"),
1763 (MetaValue::Account("Assets:Cash".into()), "account"),
1764 (MetaValue::Currency("USD".into()), "currency"),
1765 (MetaValue::Tag("foo".into()), "tag"),
1766 (MetaValue::Link("bar".into()), "link"),
1767 (MetaValue::Date(date(2024, 1, 1)), "date"),
1768 (MetaValue::Bool(true), "bool"),
1769 (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
1770 (MetaValue::None, "none"),
1771 ];
1772 for (case, kind) in cases {
1773 let err = match parse_precision_meta(&case) {
1774 Ok(_) => panic!("should have rejected {case:?}"),
1775 Err(e) => e,
1776 };
1777 assert!(
1778 err.contains(kind),
1779 "error for {case:?} should mention kind {kind:?}, got: {err}"
1780 );
1781 }
1782 }
1783}