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};
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(crate::Account),
40 Currency(crate::Currency),
42 Tag(crate::Tag),
44 Link(crate::Link),
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 pub account: crate::Account,
145 pub units: Option<IncompleteAmount>,
147 pub cost: Option<CostSpec>,
149 pub price: Option<PriceAnnotation>,
151 pub flag: Option<char>,
153 pub meta: Metadata,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub comments: Vec<String>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub trailing_comments: Vec<String>,
161}
162
163impl Posting {
164 #[must_use]
166 pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
167 Self {
168 account: account.into(),
169 units: Some(IncompleteAmount::Complete(units)),
170 cost: None,
171 price: None,
172 flag: None,
173 meta: Metadata::default(),
174 comments: Vec::new(),
175 trailing_comments: Vec::new(),
176 }
177 }
178
179 #[must_use]
181 pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
182 Self {
183 account: account.into(),
184 units: Some(units),
185 cost: None,
186 price: None,
187 flag: None,
188 meta: Metadata::default(),
189 comments: Vec::new(),
190 trailing_comments: Vec::new(),
191 }
192 }
193
194 #[must_use]
196 pub fn auto(account: impl Into<crate::Account>) -> Self {
197 Self {
198 account: account.into(),
199 units: None,
200 cost: None,
201 price: None,
202 flag: None,
203 meta: Metadata::default(),
204 comments: Vec::new(),
205 trailing_comments: Vec::new(),
206 }
207 }
208
209 #[must_use]
211 pub fn amount(&self) -> Option<&Amount> {
212 self.units.as_ref().and_then(|u| u.as_amount())
213 }
214
215 #[must_use]
232 pub fn with_cost(mut self, cost: CostSpec) -> Self {
233 self.cost = Some(cost);
234 self
235 }
236
237 #[must_use]
241 pub fn with_price(mut self, price: PriceAnnotation) -> Self {
242 self.price = Some(price);
243 self
244 }
245
246 #[must_use]
250 pub const fn with_flag(mut self, flag: char) -> Self {
251 self.flag = Some(flag);
252 self
253 }
254
255 #[must_use]
257 pub const fn has_units(&self) -> bool {
258 self.units.is_some()
259 }
260}
261
262impl fmt::Display for Posting {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 write!(f, " ")?;
265 if let Some(flag) = self.flag {
266 write!(f, "{flag} ")?;
267 }
268 write!(f, "{}", self.account)?;
269 if let Some(units) = &self.units {
270 write!(f, " {units}")?;
271 }
272 if let Some(cost) = &self.cost {
273 write!(f, " {cost}")?;
274 }
275 if let Some(price) = &self.price {
276 write!(f, " {price}")?;
277 }
278 for (key, value) in &self.meta {
280 write!(f, "\n {key}: {value}")?;
281 }
282 Ok(())
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(
289 feature = "rkyv",
290 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
291)]
292pub enum PriceKind {
293 Unit,
295 Total,
297}
298
299impl fmt::Display for PriceKind {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.write_str(match self {
302 Self::Unit => "@",
303 Self::Total => "@@",
304 })
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[cfg_attr(
322 feature = "rkyv",
323 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
324)]
325pub struct PriceAnnotation {
326 pub kind: PriceKind,
328 pub amount: Option<IncompleteAmount>,
333}
334
335impl PriceAnnotation {
336 #[must_use]
338 pub const fn unit(amount: Amount) -> Self {
339 Self {
340 kind: PriceKind::Unit,
341 amount: Some(IncompleteAmount::Complete(amount)),
342 }
343 }
344
345 #[must_use]
347 pub const fn total(amount: Amount) -> Self {
348 Self {
349 kind: PriceKind::Total,
350 amount: Some(IncompleteAmount::Complete(amount)),
351 }
352 }
353
354 #[must_use]
356 pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
357 Self {
358 kind: PriceKind::Unit,
359 amount: Some(amount),
360 }
361 }
362
363 #[must_use]
365 pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
366 Self {
367 kind: PriceKind::Total,
368 amount: Some(amount),
369 }
370 }
371
372 #[must_use]
374 pub const fn unit_empty() -> Self {
375 Self {
376 kind: PriceKind::Unit,
377 amount: None,
378 }
379 }
380
381 #[must_use]
383 pub const fn total_empty() -> Self {
384 Self {
385 kind: PriceKind::Total,
386 amount: None,
387 }
388 }
389
390 #[must_use]
392 pub fn amount(&self) -> Option<&Amount> {
393 self.amount.as_ref().and_then(IncompleteAmount::as_amount)
394 }
395
396 #[must_use]
398 pub const fn is_unit(&self) -> bool {
399 matches!(self.kind, PriceKind::Unit)
400 }
401}
402
403impl fmt::Display for PriceAnnotation {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 match &self.amount {
406 Some(amt) => write!(f, "{} {amt}", self.kind),
407 None => write!(f, "{}", self.kind),
408 }
409 }
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
417pub enum DirectivePriority {
418 Open = 0,
420 Commodity = 1,
422 Pad = 2,
424 Balance = 3,
426 Transaction = 4,
428 Note = 5,
430 Document = 6,
432 Event = 7,
434 Query = 8,
436 Price = 9,
438 Close = 10,
440 Custom = 11,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[cfg_attr(
447 feature = "rkyv",
448 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
449)]
450pub enum Directive {
451 Transaction(Transaction),
453 Balance(Balance),
455 Open(Open),
457 Close(Close),
459 Commodity(Commodity),
461 Pad(Pad),
463 Event(Event),
465 Query(Query),
467 Note(Note),
469 Document(Document),
471 Price(Price),
473 Custom(Custom),
475}
476
477impl Directive {
478 #[must_use]
480 pub const fn date(&self) -> NaiveDate {
481 match self {
482 Self::Transaction(t) => t.date,
483 Self::Balance(b) => b.date,
484 Self::Open(o) => o.date,
485 Self::Close(c) => c.date,
486 Self::Commodity(c) => c.date,
487 Self::Pad(p) => p.date,
488 Self::Event(e) => e.date,
489 Self::Query(q) => q.date,
490 Self::Note(n) => n.date,
491 Self::Document(d) => d.date,
492 Self::Price(p) => p.date,
493 Self::Custom(c) => c.date,
494 }
495 }
496
497 #[must_use]
499 pub const fn meta(&self) -> &Metadata {
500 match self {
501 Self::Transaction(t) => &t.meta,
502 Self::Balance(b) => &b.meta,
503 Self::Open(o) => &o.meta,
504 Self::Close(c) => &c.meta,
505 Self::Commodity(c) => &c.meta,
506 Self::Pad(p) => &p.meta,
507 Self::Event(e) => &e.meta,
508 Self::Query(q) => &q.meta,
509 Self::Note(n) => &n.meta,
510 Self::Document(d) => &d.meta,
511 Self::Price(p) => &p.meta,
512 Self::Custom(c) => &c.meta,
513 }
514 }
515
516 #[must_use]
518 pub const fn is_transaction(&self) -> bool {
519 matches!(self, Self::Transaction(_))
520 }
521
522 #[must_use]
524 pub const fn as_transaction(&self) -> Option<&Transaction> {
525 match self {
526 Self::Transaction(t) => Some(t),
527 _ => None,
528 }
529 }
530
531 #[must_use]
533 pub const fn type_name(&self) -> &'static str {
534 match self {
535 Self::Transaction(_) => "transaction",
536 Self::Balance(_) => "balance",
537 Self::Open(_) => "open",
538 Self::Close(_) => "close",
539 Self::Commodity(_) => "commodity",
540 Self::Pad(_) => "pad",
541 Self::Event(_) => "event",
542 Self::Query(_) => "query",
543 Self::Note(_) => "note",
544 Self::Document(_) => "document",
545 Self::Price(_) => "price",
546 Self::Custom(_) => "custom",
547 }
548 }
549
550 #[must_use]
554 pub const fn priority(&self) -> DirectivePriority {
555 match self {
556 Self::Open(_) => DirectivePriority::Open,
557 Self::Commodity(_) => DirectivePriority::Commodity,
558 Self::Pad(_) => DirectivePriority::Pad,
559 Self::Balance(_) => DirectivePriority::Balance,
560 Self::Transaction(_) => DirectivePriority::Transaction,
561 Self::Note(_) => DirectivePriority::Note,
562 Self::Document(_) => DirectivePriority::Document,
563 Self::Event(_) => DirectivePriority::Event,
564 Self::Query(_) => DirectivePriority::Query,
565 Self::Price(_) => DirectivePriority::Price,
566 Self::Close(_) => DirectivePriority::Close,
567 Self::Custom(_) => DirectivePriority::Custom,
568 }
569 }
570
571 #[must_use]
578 pub fn has_cost_reduction(&self) -> bool {
579 if let Self::Transaction(txn) = self {
580 txn.postings.iter().any(|p| {
581 p.cost.is_some()
582 && p.units
583 .as_ref()
584 .and_then(IncompleteAmount::number)
585 .is_some_and(|n| n.is_sign_negative())
586 })
587 } else {
588 false
589 }
590 }
591}
592
593pub fn sort_directives(directives: &mut [Directive]) {
603 directives.sort_by_cached_key(|d| (d.date(), d.priority(), d.has_cost_reduction()));
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611#[cfg_attr(
612 feature = "rkyv",
613 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
614)]
615pub struct Transaction {
616 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
618 pub date: NaiveDate,
619 pub flag: char,
621 #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
623 pub payee: Option<InternedStr>,
624 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
626 pub narration: InternedStr,
627 pub tags: Vec<crate::Tag>,
629 pub links: Vec<crate::Link>,
631 pub meta: Metadata,
633 pub postings: Vec<crate::Spanned<Posting>>,
640 #[serde(default, skip_serializing_if = "Vec::is_empty")]
642 pub trailing_comments: Vec<String>,
643}
644
645impl Transaction {
646 #[must_use]
648 pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
649 Self {
650 date,
651 flag: '*',
652 payee: None,
653 narration: narration.into(),
654 tags: Vec::new(),
655 links: Vec::new(),
656 meta: Metadata::default(),
657 postings: Vec::new(),
658 trailing_comments: Vec::new(),
659 }
660 }
661
662 #[must_use]
664 pub const fn with_flag(mut self, flag: char) -> Self {
665 self.flag = flag;
666 self
667 }
668
669 #[must_use]
671 pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
672 self.payee = Some(payee.into());
673 self
674 }
675
676 #[must_use]
678 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
679 self.tags.push(tag.into());
680 self
681 }
682
683 #[must_use]
685 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
686 self.links.push(link.into());
687 self
688 }
689
690 #[must_use]
695 pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
696 self.postings.push(posting);
697 self
698 }
699
700 #[must_use]
708 pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
709 self.postings.push(crate::Spanned::synthesized(posting));
710 self
711 }
712
713 #[must_use]
715 pub const fn is_complete(&self) -> bool {
716 self.flag == '*'
717 }
718
719 #[must_use]
721 pub const fn is_incomplete(&self) -> bool {
722 self.flag == '!'
723 }
724
725 #[must_use]
728 pub const fn is_pending(&self) -> bool {
729 self.flag == '!'
730 }
731
732 #[must_use]
734 pub const fn is_summarization(&self) -> bool {
735 self.flag == 'S'
736 }
737
738 #[must_use]
740 pub const fn is_transfer(&self) -> bool {
741 self.flag == 'T'
742 }
743
744 #[must_use]
746 pub const fn is_conversion(&self) -> bool {
747 self.flag == 'C'
748 }
749
750 #[must_use]
752 pub const fn is_unrealized(&self) -> bool {
753 self.flag == 'U'
754 }
755
756 #[must_use]
758 pub const fn is_return(&self) -> bool {
759 self.flag == 'R'
760 }
761
762 #[must_use]
764 pub const fn is_merge(&self) -> bool {
765 self.flag == 'M'
766 }
767
768 #[must_use]
770 pub const fn is_bookmarked(&self) -> bool {
771 self.flag == '#'
772 }
773
774 #[must_use]
776 pub const fn needs_investigation(&self) -> bool {
777 self.flag == '?'
778 }
779
780 #[must_use]
782 pub const fn is_valid_flag(flag: char) -> bool {
783 matches!(
784 flag,
785 '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
786 )
787 }
788}
789
790impl fmt::Display for Transaction {
791 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
792 write!(f, "{} {} ", self.date, self.flag)?;
793 if let Some(payee) = &self.payee {
794 write!(f, "\"{payee}\" ")?;
795 }
796 write!(f, "\"{}\"", self.narration)?;
797 for tag in &self.tags {
798 write!(f, " #{tag}")?;
799 }
800 for link in &self.links {
801 write!(f, " ^{link}")?;
802 }
803 for (key, value) in &self.meta {
805 write!(f, "\n {key}: {value}")?;
806 }
807 for posting in &self.postings {
808 write!(f, "\n{posting}")?;
809 }
810 Ok(())
811 }
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[cfg_attr(
819 feature = "rkyv",
820 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
821)]
822pub struct Balance {
823 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
825 pub date: NaiveDate,
826 pub account: crate::Account,
828 pub amount: Amount,
830 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
832 pub tolerance: Option<Decimal>,
833 pub meta: Metadata,
835}
836
837impl Balance {
838 #[must_use]
840 pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
841 Self {
842 date,
843 account: account.into(),
844 amount,
845 tolerance: None,
846 meta: Metadata::default(),
847 }
848 }
849
850 #[must_use]
852 pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
853 self.tolerance = Some(tolerance);
854 self
855 }
856
857 #[must_use]
859 pub fn with_meta(mut self, meta: Metadata) -> Self {
860 self.meta = meta;
861 self
862 }
863}
864
865impl fmt::Display for Balance {
866 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
867 write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
868 if let Some(tol) = self.tolerance {
869 write!(f, " ~ {tol}")?;
870 }
871 Ok(())
872 }
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
879#[cfg_attr(
880 feature = "rkyv",
881 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
882)]
883pub struct Open {
884 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
886 pub date: NaiveDate,
887 pub account: crate::Account,
889 pub currencies: Vec<crate::Currency>,
891 pub booking: Option<String>,
893 pub meta: Metadata,
895}
896
897impl Open {
898 #[must_use]
900 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
901 Self {
902 date,
903 account: account.into(),
904 currencies: Vec::new(),
905 booking: None,
906 meta: Metadata::default(),
907 }
908 }
909
910 #[must_use]
912 pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
913 self.currencies = currencies;
914 self
915 }
916
917 #[must_use]
919 pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
920 self.booking = Some(booking.into());
921 self
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 Open {
933 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
934 write!(f, "{} open {}", self.date, self.account)?;
935 if !self.currencies.is_empty() {
936 let currencies: Vec<&str> = self
937 .currencies
938 .iter()
939 .map(crate::Currency::as_str)
940 .collect();
941 write!(f, " {}", currencies.join(","))?;
942 }
943 if let Some(booking) = &self.booking {
944 write!(f, " \"{booking}\"")?;
945 }
946 Ok(())
947 }
948}
949
950#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
954#[cfg_attr(
955 feature = "rkyv",
956 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
957)]
958pub struct Close {
959 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
961 pub date: NaiveDate,
962 pub account: crate::Account,
964 pub meta: Metadata,
966}
967
968impl Close {
969 #[must_use]
971 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
972 Self {
973 date,
974 account: account.into(),
975 meta: Metadata::default(),
976 }
977 }
978
979 #[must_use]
981 pub fn with_meta(mut self, meta: Metadata) -> Self {
982 self.meta = meta;
983 self
984 }
985}
986
987impl fmt::Display for Close {
988 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
989 write!(f, "{} close {}", self.date, self.account)
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997#[cfg_attr(
998 feature = "rkyv",
999 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1000)]
1001pub struct Commodity {
1002 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1004 pub date: NaiveDate,
1005 pub currency: crate::Currency,
1007 pub meta: Metadata,
1009}
1010
1011impl Commodity {
1012 #[must_use]
1014 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1015 Self {
1016 date,
1017 currency: currency.into(),
1018 meta: Metadata::default(),
1019 }
1020 }
1021
1022 #[must_use]
1024 pub fn with_meta(mut self, meta: Metadata) -> Self {
1025 self.meta = meta;
1026 self
1027 }
1028}
1029
1030impl fmt::Display for Commodity {
1031 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1032 write!(f, "{} commodity {}", self.date, self.currency)
1033 }
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1041#[cfg_attr(
1042 feature = "rkyv",
1043 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1044)]
1045pub struct Pad {
1046 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1048 pub date: NaiveDate,
1049 pub account: crate::Account,
1051 pub source_account: crate::Account,
1053 pub meta: Metadata,
1055}
1056
1057impl Pad {
1058 #[must_use]
1060 pub fn new(
1061 date: NaiveDate,
1062 account: impl Into<crate::Account>,
1063 source_account: impl Into<crate::Account>,
1064 ) -> Self {
1065 Self {
1066 date,
1067 account: account.into(),
1068 source_account: source_account.into(),
1069 meta: Metadata::default(),
1070 }
1071 }
1072
1073 #[must_use]
1075 pub fn with_meta(mut self, meta: Metadata) -> Self {
1076 self.meta = meta;
1077 self
1078 }
1079}
1080
1081impl fmt::Display for Pad {
1082 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1083 write!(
1084 f,
1085 "{} pad {} {}",
1086 self.date, self.account, self.source_account
1087 )
1088 }
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095#[cfg_attr(
1096 feature = "rkyv",
1097 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1098)]
1099pub struct Event {
1100 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1102 pub date: NaiveDate,
1103 pub event_type: String,
1105 pub value: String,
1107 pub meta: Metadata,
1109}
1110
1111impl Event {
1112 #[must_use]
1114 pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1115 Self {
1116 date,
1117 event_type: event_type.into(),
1118 value: value.into(),
1119 meta: Metadata::default(),
1120 }
1121 }
1122
1123 #[must_use]
1125 pub fn with_meta(mut self, meta: Metadata) -> Self {
1126 self.meta = meta;
1127 self
1128 }
1129}
1130
1131impl fmt::Display for Event {
1132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1133 write!(
1134 f,
1135 "{} event \"{}\" \"{}\"",
1136 self.date, self.event_type, self.value
1137 )
1138 }
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1145#[cfg_attr(
1146 feature = "rkyv",
1147 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1148)]
1149pub struct Query {
1150 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1152 pub date: NaiveDate,
1153 pub name: String,
1155 pub query: String,
1157 pub meta: Metadata,
1159}
1160
1161impl Query {
1162 #[must_use]
1164 pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1165 Self {
1166 date,
1167 name: name.into(),
1168 query: query.into(),
1169 meta: Metadata::default(),
1170 }
1171 }
1172
1173 #[must_use]
1175 pub fn with_meta(mut self, meta: Metadata) -> Self {
1176 self.meta = meta;
1177 self
1178 }
1179}
1180
1181impl fmt::Display for Query {
1182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1183 write!(
1184 f,
1185 "{} query \"{}\" \"{}\"",
1186 self.date, self.name, self.query
1187 )
1188 }
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1195#[cfg_attr(
1196 feature = "rkyv",
1197 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1198)]
1199pub struct Note {
1200 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1202 pub date: NaiveDate,
1203 pub account: crate::Account,
1205 pub comment: String,
1207 pub meta: Metadata,
1209}
1210
1211impl Note {
1212 #[must_use]
1214 pub fn new(
1215 date: NaiveDate,
1216 account: impl Into<crate::Account>,
1217 comment: impl Into<String>,
1218 ) -> Self {
1219 Self {
1220 date,
1221 account: account.into(),
1222 comment: comment.into(),
1223 meta: Metadata::default(),
1224 }
1225 }
1226
1227 #[must_use]
1229 pub fn with_meta(mut self, meta: Metadata) -> Self {
1230 self.meta = meta;
1231 self
1232 }
1233}
1234
1235impl fmt::Display for Note {
1236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1237 write!(
1238 f,
1239 "{} note {} \"{}\"",
1240 self.date, self.account, self.comment
1241 )
1242 }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249#[cfg_attr(
1250 feature = "rkyv",
1251 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1252)]
1253pub struct Document {
1254 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1256 pub date: NaiveDate,
1257 pub account: crate::Account,
1259 pub path: String,
1261 pub tags: Vec<crate::Tag>,
1263 pub links: Vec<crate::Link>,
1265 pub meta: Metadata,
1267}
1268
1269impl Document {
1270 #[must_use]
1272 pub fn new(
1273 date: NaiveDate,
1274 account: impl Into<crate::Account>,
1275 path: impl Into<String>,
1276 ) -> Self {
1277 Self {
1278 date,
1279 account: account.into(),
1280 path: path.into(),
1281 tags: Vec::new(),
1282 links: Vec::new(),
1283 meta: Metadata::default(),
1284 }
1285 }
1286
1287 #[must_use]
1289 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1290 self.tags.push(tag.into());
1291 self
1292 }
1293
1294 #[must_use]
1296 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1297 self.links.push(link.into());
1298 self
1299 }
1300
1301 #[must_use]
1303 pub fn with_meta(mut self, meta: Metadata) -> Self {
1304 self.meta = meta;
1305 self
1306 }
1307}
1308
1309impl fmt::Display for Document {
1310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1311 write!(
1312 f,
1313 "{} document {} \"{}\"",
1314 self.date, self.account, self.path
1315 )
1316 }
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1323#[cfg_attr(
1324 feature = "rkyv",
1325 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1326)]
1327pub struct Price {
1328 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1330 pub date: NaiveDate,
1331 pub currency: crate::Currency,
1333 pub amount: Amount,
1335 pub meta: Metadata,
1337}
1338
1339impl Price {
1340 #[must_use]
1342 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1343 Self {
1344 date,
1345 currency: currency.into(),
1346 amount,
1347 meta: Metadata::default(),
1348 }
1349 }
1350
1351 #[must_use]
1353 pub fn with_meta(mut self, meta: Metadata) -> Self {
1354 self.meta = meta;
1355 self
1356 }
1357}
1358
1359impl fmt::Display for Price {
1360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1361 write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1362 }
1363}
1364
1365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1369#[cfg_attr(
1370 feature = "rkyv",
1371 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1372)]
1373pub struct Custom {
1374 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1376 pub date: NaiveDate,
1377 pub custom_type: String,
1379 pub values: Vec<MetaValue>,
1381 pub meta: Metadata,
1383}
1384
1385impl Custom {
1386 #[must_use]
1388 pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1389 Self {
1390 date,
1391 custom_type: custom_type.into(),
1392 values: Vec::new(),
1393 meta: Metadata::default(),
1394 }
1395 }
1396
1397 #[must_use]
1399 pub fn with_value(mut self, value: MetaValue) -> Self {
1400 self.values.push(value);
1401 self
1402 }
1403
1404 #[must_use]
1406 pub fn with_meta(mut self, meta: Metadata) -> Self {
1407 self.meta = meta;
1408 self
1409 }
1410}
1411
1412impl fmt::Display for Custom {
1413 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1414 write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
1415 for value in &self.values {
1416 write!(f, " {value}")?;
1417 }
1418 Ok(())
1419 }
1420}
1421
1422impl fmt::Display for Directive {
1423 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1424 match self {
1425 Self::Transaction(t) => write!(f, "{t}"),
1426 Self::Balance(b) => write!(f, "{b}"),
1427 Self::Open(o) => write!(f, "{o}"),
1428 Self::Close(c) => write!(f, "{c}"),
1429 Self::Commodity(c) => write!(f, "{c}"),
1430 Self::Pad(p) => write!(f, "{p}"),
1431 Self::Event(e) => write!(f, "{e}"),
1432 Self::Query(q) => write!(f, "{q}"),
1433 Self::Note(n) => write!(f, "{n}"),
1434 Self::Document(d) => write!(f, "{d}"),
1435 Self::Price(p) => write!(f, "{p}"),
1436 Self::Custom(c) => write!(f, "{c}"),
1437 }
1438 }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444 use rust_decimal_macros::dec;
1445
1446 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1447 crate::naive_date(year, month, day).unwrap()
1448 }
1449
1450 #[test]
1451 fn test_transaction() {
1452 let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1453 .with_payee("Whole Foods")
1454 .with_flag('*')
1455 .with_tag("food")
1456 .with_synthesized_posting(Posting::new(
1457 "Expenses:Food",
1458 Amount::new(dec!(50.00), "USD"),
1459 ))
1460 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1461
1462 assert_eq!(txn.flag, '*');
1463 assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1464 assert_eq!(txn.postings.len(), 2);
1465 assert!(txn.is_complete());
1466 }
1467
1468 #[test]
1469 fn test_balance() {
1470 let bal = Balance::new(
1471 date(2024, 1, 1),
1472 "Assets:Checking",
1473 Amount::new(dec!(1000.00), "USD"),
1474 );
1475
1476 assert_eq!(bal.account, "Assets:Checking");
1477 assert_eq!(bal.amount.number, dec!(1000.00));
1478 }
1479
1480 #[test]
1481 fn test_open() {
1482 let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1483 .with_currencies(vec!["USD".into()])
1484 .with_booking("FIFO");
1485
1486 assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1487 assert_eq!(open.booking, Some("FIFO".to_string()));
1488 }
1489
1490 #[test]
1491 fn test_directive_date() {
1492 let txn = Transaction::new(date(2024, 1, 15), "Test");
1493 let dir = Directive::Transaction(txn);
1494
1495 assert_eq!(dir.date(), date(2024, 1, 15));
1496 assert!(dir.is_transaction());
1497 assert_eq!(dir.type_name(), "transaction");
1498 }
1499
1500 #[test]
1501 fn test_posting_display() {
1502 let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1503 let s = format!("{posting}");
1504 assert!(s.contains("Assets:Checking"));
1505 assert!(s.contains("100.00 USD"));
1506 }
1507
1508 #[test]
1509 fn test_transaction_display() {
1510 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1511 .with_payee("Test Payee")
1512 .with_synthesized_posting(Posting::new(
1513 "Expenses:Test",
1514 Amount::new(dec!(50.00), "USD"),
1515 ))
1516 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1517
1518 let s = format!("{txn}");
1519 assert!(s.contains("2024-01-15"));
1520 assert!(s.contains("Test Payee"));
1521 assert!(s.contains("Test transaction"));
1522 }
1523
1524 #[test]
1525 fn test_directive_priority() {
1526 assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1528 assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1529 assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1530 assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1531 assert!(DirectivePriority::Price < DirectivePriority::Close);
1532 }
1533
1534 #[test]
1535 fn test_sort_directives_by_date() {
1536 let mut directives = vec![
1537 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1538 Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1539 Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1540 ];
1541
1542 sort_directives(&mut directives);
1543
1544 assert_eq!(directives[0].date(), date(2024, 1, 1));
1545 assert_eq!(directives[1].date(), date(2024, 1, 10));
1546 assert_eq!(directives[2].date(), date(2024, 1, 15));
1547 }
1548
1549 #[test]
1550 fn test_sort_directives_by_type_same_date() {
1551 let mut directives = vec![
1553 Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1554 Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1555 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1556 Directive::Balance(Balance::new(
1557 date(2024, 1, 1),
1558 "Assets:Bank",
1559 Amount::new(dec!(0), "USD"),
1560 )),
1561 ];
1562
1563 sort_directives(&mut directives);
1564
1565 assert_eq!(directives[0].type_name(), "open");
1566 assert_eq!(directives[1].type_name(), "balance");
1567 assert_eq!(directives[2].type_name(), "transaction");
1568 assert_eq!(directives[3].type_name(), "close");
1569 }
1570
1571 #[test]
1572 fn test_sort_directives_pad_before_balance() {
1573 let mut directives = vec![
1575 Directive::Balance(Balance::new(
1576 date(2024, 1, 1),
1577 "Assets:Bank",
1578 Amount::new(dec!(1000), "USD"),
1579 )),
1580 Directive::Pad(Pad::new(
1581 date(2024, 1, 1),
1582 "Assets:Bank",
1583 "Equity:Opening-Balances",
1584 )),
1585 ];
1586
1587 sort_directives(&mut directives);
1588
1589 assert_eq!(directives[0].type_name(), "pad");
1590 assert_eq!(directives[1].type_name(), "balance");
1591 }
1592
1593 #[test]
1594 fn test_sort_augmentations_before_reductions_same_date() {
1595 let reduction = Directive::Transaction(
1599 Transaction::new(date(2024, 9, 1), "Transfer Received")
1600 .with_synthesized_posting(
1601 Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1602 CostSpec::empty()
1603 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1604 .with_currency("EUR"),
1605 ),
1606 )
1607 .with_synthesized_posting(
1608 Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1609 CostSpec::empty()
1610 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1611 .with_currency("EUR"),
1612 ),
1613 ),
1614 );
1615
1616 let augmentation = Directive::Transaction(
1617 Transaction::new(date(2024, 9, 1), "Transfer Sent")
1618 .with_synthesized_posting(Posting::new(
1619 "Assets:AccountA",
1620 Amount::new(dec!(-10.00), "EUR"),
1621 ))
1622 .with_synthesized_posting(
1623 Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1624 CostSpec::empty()
1625 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1626 .with_currency("EUR"),
1627 ),
1628 ),
1629 );
1630
1631 let mut directives = vec![reduction, augmentation];
1633 sort_directives(&mut directives);
1634
1635 assert!(
1637 !directives[0].has_cost_reduction(),
1638 "first directive should be augmentation"
1639 );
1640 assert!(
1641 directives[1].has_cost_reduction(),
1642 "second directive should be reduction"
1643 );
1644 }
1645
1646 #[test]
1647 fn test_has_cost_reduction() {
1648 let reduction = Directive::Transaction(
1650 Transaction::new(date(2024, 1, 1), "Sell")
1651 .with_synthesized_posting(
1652 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
1653 CostSpec::empty()
1654 .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1655 .with_currency("USD"),
1656 ),
1657 )
1658 .with_synthesized_posting(Posting::new(
1659 "Assets:Cash",
1660 Amount::new(dec!(1500), "USD"),
1661 )),
1662 );
1663 assert!(reduction.has_cost_reduction());
1664
1665 let augmentation = Directive::Transaction(
1667 Transaction::new(date(2024, 1, 1), "Buy")
1668 .with_synthesized_posting(
1669 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1670 CostSpec::empty()
1671 .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1672 .with_currency("USD"),
1673 ),
1674 )
1675 .with_synthesized_posting(Posting::new(
1676 "Assets:Cash",
1677 Amount::new(dec!(-1500), "USD"),
1678 )),
1679 );
1680 assert!(!augmentation.has_cost_reduction());
1681
1682 let simple = Directive::Transaction(
1684 Transaction::new(date(2024, 1, 1), "Payment")
1685 .with_synthesized_posting(Posting::new(
1686 "Expenses:Food",
1687 Amount::new(dec!(50), "USD"),
1688 ))
1689 .with_synthesized_posting(Posting::new(
1690 "Assets:Cash",
1691 Amount::new(dec!(-50), "USD"),
1692 )),
1693 );
1694 assert!(!simple.has_cost_reduction());
1695 }
1696
1697 #[test]
1698 fn test_transaction_flags() {
1699 let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1700
1701 assert!(make_txn('*').is_complete());
1703 assert!(make_txn('!').is_incomplete());
1704 assert!(make_txn('!').is_pending());
1705
1706 assert!(make_txn('S').is_summarization());
1708 assert!(make_txn('T').is_transfer());
1709 assert!(make_txn('C').is_conversion());
1710 assert!(make_txn('U').is_unrealized());
1711 assert!(make_txn('R').is_return());
1712 assert!(make_txn('M').is_merge());
1713 assert!(make_txn('#').is_bookmarked());
1714 assert!(make_txn('?').needs_investigation());
1715
1716 assert!(!make_txn('*').is_pending());
1718 assert!(!make_txn('!').is_complete());
1719 }
1720
1721 #[test]
1722 fn test_is_valid_flag() {
1723 for flag in [
1725 '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1726 ] {
1727 assert!(
1728 Transaction::is_valid_flag(flag),
1729 "Flag '{flag}' should be valid"
1730 );
1731 }
1732
1733 for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1735 assert!(
1736 !Transaction::is_valid_flag(flag),
1737 "Flag '{flag}' should be invalid"
1738 );
1739 }
1740 }
1741
1742 #[test]
1743 fn test_transaction_display_includes_metadata() {
1744 let mut meta = Metadata::default();
1745 meta.insert(
1746 "document".to_string(),
1747 MetaValue::String("myfile.pdf".to_string()),
1748 );
1749
1750 let txn = Transaction {
1751 date: date(2026, 2, 23),
1752 flag: '*',
1753 payee: None,
1754 narration: "Example".into(),
1755 tags: vec![],
1756 links: vec![],
1757 meta,
1758 postings: vec![
1759 crate::Spanned::synthesized(Posting::new(
1760 "Assets:Bank",
1761 Amount::new(dec!(-2), "USD"),
1762 )),
1763 crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1764 ],
1765 trailing_comments: Vec::new(),
1766 };
1767
1768 let output = txn.to_string();
1769 assert!(
1770 output.contains("document: \"myfile.pdf\""),
1771 "Transaction Display should include metadata: {output}"
1772 );
1773 assert!(
1774 output.contains("Assets:Bank"),
1775 "Transaction Display should include postings: {output}"
1776 );
1777 }
1778
1779 #[test]
1780 fn test_posting_display_includes_metadata() {
1781 let mut meta = Metadata::default();
1782 meta.insert(
1783 "category".to_string(),
1784 MetaValue::String("groceries".to_string()),
1785 );
1786
1787 let posting = Posting {
1788 account: "Expenses:Food".into(),
1789 units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1790 cost: None,
1791 price: None,
1792 flag: None,
1793 meta,
1794 comments: Vec::new(),
1795 trailing_comments: Vec::new(),
1796 };
1797
1798 let output = posting.to_string();
1799 assert!(
1800 output.contains("category: \"groceries\""),
1801 "Posting Display should include metadata: {output}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_directive_display() {
1807 let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1809 let dir = Directive::Transaction(txn.clone());
1810
1811 assert_eq!(format!("{dir}"), format!("{txn}"));
1813
1814 let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1816 let dir_open = Directive::Open(open.clone());
1817 assert_eq!(format!("{dir_open}"), format!("{open}"));
1818
1819 let balance = Balance::new(
1820 date(2024, 1, 1),
1821 "Assets:Bank",
1822 Amount::new(dec!(100), "USD"),
1823 );
1824 let dir_balance = Directive::Balance(balance.clone());
1825 assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1826 }
1827
1828 #[test]
1831 fn parse_precision_meta_accepts_non_negative_integers() {
1832 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1833 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1834 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1835 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1839 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1840 }
1841
1842 #[test]
1843 fn parse_precision_meta_rejects_negatives() {
1844 let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
1845 assert!(err.contains("non-negative"), "got: {err}");
1846 }
1847
1848 #[test]
1849 fn parse_precision_meta_rejects_fractional() {
1850 let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
1851 assert!(err.contains("integer"), "got: {err}");
1852 }
1853
1854 #[test]
1855 fn parse_precision_meta_rejects_overflow() {
1856 let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
1858 assert!(err.contains("exceeds"), "got: {err}");
1859 }
1860
1861 #[test]
1862 fn parse_precision_meta_rejects_non_number_variants() {
1863 use crate::Amount;
1868 use rust_decimal_macros::dec;
1869 let cases = [
1870 (MetaValue::String("2".into()), "string"),
1871 (MetaValue::Account("Assets:Cash".into()), "account"),
1872 (MetaValue::Currency("USD".into()), "currency"),
1873 (MetaValue::Tag("foo".into()), "tag"),
1874 (MetaValue::Link("bar".into()), "link"),
1875 (MetaValue::Date(date(2024, 1, 1)), "date"),
1876 (MetaValue::Bool(true), "bool"),
1877 (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
1878 (MetaValue::None, "none"),
1879 ];
1880 for (case, kind) in cases {
1881 let err = match parse_precision_meta(&case) {
1882 Ok(_) => panic!("should have rejected {case:?}"),
1883 Err(e) => e,
1884 };
1885 assert!(
1886 err.contains(kind),
1887 "error for {case:?} should mention kind {kind:?}, got: {err}"
1888 );
1889 }
1890 }
1891}