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 Int(i64),
62}
63
64impl fmt::Display for MetaValue {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 Self::String(s) => write!(f, "\"{}\"", crate::format::escape_string(s)),
68 Self::Account(a) => write!(f, "{a}"),
69 Self::Currency(c) => write!(f, "{c}"),
70 Self::Tag(t) => write!(f, "#{t}"),
71 Self::Link(l) => write!(f, "^{l}"),
72 Self::Date(d) => write!(f, "{d}"),
73 Self::Number(n) => write!(f, "{n}"),
74 Self::Bool(b) => write!(f, "{b}"),
75 Self::Amount(a) => write!(f, "{a}"),
76 Self::None => write!(f, "None"),
77 Self::Int(i) => write!(f, "{i}"),
78 }
79 }
80}
81
82pub type Metadata = FxHashMap<String, MetaValue>;
84
85#[must_use]
95pub fn parse_bool_word(word: &str) -> Option<bool> {
96 if word.eq_ignore_ascii_case("true") || word == "1" {
97 Some(true)
98 } else if word.eq_ignore_ascii_case("false") || word == "0" {
99 Some(false)
100 } else {
101 None
102 }
103}
104
105#[must_use]
114pub fn meta_value_as_bool(value: &MetaValue) -> Option<bool> {
115 match value {
116 MetaValue::Bool(b) => Some(*b),
117 MetaValue::String(s) => parse_bool_word(s),
118 MetaValue::Currency(c) => parse_bool_word(c),
119 _ => None,
120 }
121}
122
123#[must_use = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
136pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
137 use rust_decimal::prelude::ToPrimitive;
138 match value {
139 MetaValue::Int(i) => u32::try_from(*i).map_err(|_| {
141 if *i < 0 {
142 format!("expected a non-negative integer, got {i}")
143 } else {
144 format!(
145 "value {i} exceeds the maximum supported precision ({})",
146 u32::MAX
147 )
148 }
149 }),
150 MetaValue::Number(n) => {
152 if n.is_sign_negative() {
153 return Err(format!("expected a non-negative integer, got {n}"));
154 }
155 if !n.fract().is_zero() {
156 return Err(format!("expected an integer, got {n}"));
157 }
158 n.to_u32().ok_or_else(|| {
159 format!(
160 "value {n} exceeds the maximum supported precision ({})",
161 u32::MAX
162 )
163 })
164 }
165 _ => Err(format!(
166 "expected a non-negative integer, got {} value",
167 meta_value_kind(value)
168 )),
169 }
170}
171
172const fn meta_value_kind(v: &MetaValue) -> &'static str {
173 match v {
174 MetaValue::String(_) => "string",
175 MetaValue::Account(_) => "account",
176 MetaValue::Currency(_) => "currency",
177 MetaValue::Tag(_) => "tag",
178 MetaValue::Link(_) => "link",
179 MetaValue::Date(_) => "date",
180 MetaValue::Number(_) => "number",
181 MetaValue::Bool(_) => "bool",
182 MetaValue::Amount(_) => "amount",
183 MetaValue::None => "none",
184 MetaValue::Int(_) => "int",
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(
199 feature = "rkyv",
200 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
201)]
202pub struct Posting {
203 pub account: crate::Account,
205 pub units: Option<IncompleteAmount>,
207 pub cost: Option<Box<CostSpec>>,
216 pub price: Option<Box<PriceAnnotation>>,
219 pub flag: Option<char>,
221 pub meta: Metadata,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub comments: Vec<String>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub trailing_comments: Vec<String>,
229}
230
231impl Posting {
232 #[must_use]
234 pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
235 Self {
236 account: account.into(),
237 units: Some(IncompleteAmount::Complete(units)),
238 cost: None,
239 price: None,
240 flag: None,
241 meta: Metadata::default(),
242 comments: Vec::new(),
243 trailing_comments: Vec::new(),
244 }
245 }
246
247 #[must_use]
249 pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
250 Self {
251 account: account.into(),
252 units: Some(units),
253 cost: None,
254 price: None,
255 flag: None,
256 meta: Metadata::default(),
257 comments: Vec::new(),
258 trailing_comments: Vec::new(),
259 }
260 }
261
262 #[must_use]
264 pub fn auto(account: impl Into<crate::Account>) -> Self {
265 Self {
266 account: account.into(),
267 units: None,
268 cost: None,
269 price: None,
270 flag: None,
271 meta: Metadata::default(),
272 comments: Vec::new(),
273 trailing_comments: Vec::new(),
274 }
275 }
276
277 #[must_use]
279 pub fn amount(&self) -> Option<&Amount> {
280 self.units.as_ref().and_then(|u| u.as_amount())
281 }
282
283 #[must_use]
300 pub fn with_cost(mut self, cost: CostSpec) -> Self {
301 self.cost = Some(Box::new(cost));
302 self
303 }
304
305 #[must_use]
309 pub fn with_price(mut self, price: PriceAnnotation) -> Self {
310 self.price = Some(Box::new(price));
311 self
312 }
313
314 #[must_use]
318 pub const fn with_flag(mut self, flag: char) -> Self {
319 self.flag = Some(flag);
320 self
321 }
322
323 #[must_use]
325 pub const fn has_units(&self) -> bool {
326 self.units.is_some()
327 }
328}
329
330impl fmt::Display for Posting {
331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332 write!(f, " ")?;
333 if let Some(flag) = self.flag {
334 write!(f, "{flag} ")?;
335 }
336 write!(f, "{}", self.account)?;
337 if let Some(units) = &self.units {
338 write!(f, " {units}")?;
339 }
340 if let Some(cost) = &self.cost {
341 write!(f, " {cost}")?;
342 }
343 if let Some(price) = &self.price {
344 write!(f, " {price}")?;
345 }
346 for (key, value) in &self.meta {
348 write!(f, "\n {key}: {value}")?;
349 }
350 Ok(())
351 }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356#[cfg_attr(
357 feature = "rkyv",
358 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
359)]
360pub enum PriceKind {
361 Unit,
363 Total,
365}
366
367impl fmt::Display for PriceKind {
368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369 f.write_str(match self {
370 Self::Unit => "@",
371 Self::Total => "@@",
372 })
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[cfg_attr(
390 feature = "rkyv",
391 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
392)]
393pub struct PriceAnnotation {
394 pub kind: PriceKind,
396 pub amount: Option<IncompleteAmount>,
401}
402
403impl PriceAnnotation {
404 #[must_use]
406 pub const fn unit(amount: Amount) -> Self {
407 Self {
408 kind: PriceKind::Unit,
409 amount: Some(IncompleteAmount::Complete(amount)),
410 }
411 }
412
413 #[must_use]
415 pub const fn total(amount: Amount) -> Self {
416 Self {
417 kind: PriceKind::Total,
418 amount: Some(IncompleteAmount::Complete(amount)),
419 }
420 }
421
422 #[must_use]
424 pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
425 Self {
426 kind: PriceKind::Unit,
427 amount: Some(amount),
428 }
429 }
430
431 #[must_use]
433 pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
434 Self {
435 kind: PriceKind::Total,
436 amount: Some(amount),
437 }
438 }
439
440 #[must_use]
442 pub const fn unit_empty() -> Self {
443 Self {
444 kind: PriceKind::Unit,
445 amount: None,
446 }
447 }
448
449 #[must_use]
451 pub const fn total_empty() -> Self {
452 Self {
453 kind: PriceKind::Total,
454 amount: None,
455 }
456 }
457
458 #[must_use]
460 pub fn amount(&self) -> Option<&Amount> {
461 self.amount.as_ref().and_then(IncompleteAmount::as_amount)
462 }
463
464 #[must_use]
466 pub const fn is_unit(&self) -> bool {
467 matches!(self.kind, PriceKind::Unit)
468 }
469}
470
471impl fmt::Display for PriceAnnotation {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 match &self.amount {
474 Some(amt) => write!(f, "{} {amt}", self.kind),
475 None => write!(f, "{}", self.kind),
476 }
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
485pub enum DirectivePriority {
486 Open = 0,
488 Commodity = 1,
490 Pad = 2,
492 Balance = 3,
494 Transaction = 4,
496 Note = 5,
498 Document = 6,
500 Event = 7,
502 Query = 8,
504 Price = 9,
506 Close = 10,
508 Custom = 11,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514#[cfg_attr(
515 feature = "rkyv",
516 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
517)]
518pub enum Directive {
519 Transaction(Transaction),
521 Balance(Balance),
523 Open(Open),
525 Close(Close),
527 Commodity(Commodity),
529 Pad(Pad),
531 Event(Event),
533 Query(Query),
535 Note(Note),
537 Document(Document),
539 Price(Price),
541 Custom(Custom),
543}
544
545impl Directive {
546 #[must_use]
548 pub const fn date(&self) -> NaiveDate {
549 match self {
550 Self::Transaction(t) => t.date,
551 Self::Balance(b) => b.date,
552 Self::Open(o) => o.date,
553 Self::Close(c) => c.date,
554 Self::Commodity(c) => c.date,
555 Self::Pad(p) => p.date,
556 Self::Event(e) => e.date,
557 Self::Query(q) => q.date,
558 Self::Note(n) => n.date,
559 Self::Document(d) => d.date,
560 Self::Price(p) => p.date,
561 Self::Custom(c) => c.date,
562 }
563 }
564
565 #[must_use]
567 pub const fn meta(&self) -> &Metadata {
568 match self {
569 Self::Transaction(t) => &t.meta,
570 Self::Balance(b) => &b.meta,
571 Self::Open(o) => &o.meta,
572 Self::Close(c) => &c.meta,
573 Self::Commodity(c) => &c.meta,
574 Self::Pad(p) => &p.meta,
575 Self::Event(e) => &e.meta,
576 Self::Query(q) => &q.meta,
577 Self::Note(n) => &n.meta,
578 Self::Document(d) => &d.meta,
579 Self::Price(p) => &p.meta,
580 Self::Custom(c) => &c.meta,
581 }
582 }
583
584 #[must_use]
586 pub const fn is_transaction(&self) -> bool {
587 matches!(self, Self::Transaction(_))
588 }
589
590 #[must_use]
592 pub const fn as_transaction(&self) -> Option<&Transaction> {
593 match self {
594 Self::Transaction(t) => Some(t),
595 _ => None,
596 }
597 }
598
599 #[must_use]
601 pub const fn type_name(&self) -> &'static str {
602 match self {
603 Self::Transaction(_) => "transaction",
604 Self::Balance(_) => "balance",
605 Self::Open(_) => "open",
606 Self::Close(_) => "close",
607 Self::Commodity(_) => "commodity",
608 Self::Pad(_) => "pad",
609 Self::Event(_) => "event",
610 Self::Query(_) => "query",
611 Self::Note(_) => "note",
612 Self::Document(_) => "document",
613 Self::Price(_) => "price",
614 Self::Custom(_) => "custom",
615 }
616 }
617
618 #[must_use]
622 pub const fn priority(&self) -> DirectivePriority {
623 match self {
624 Self::Open(_) => DirectivePriority::Open,
625 Self::Commodity(_) => DirectivePriority::Commodity,
626 Self::Pad(_) => DirectivePriority::Pad,
627 Self::Balance(_) => DirectivePriority::Balance,
628 Self::Transaction(_) => DirectivePriority::Transaction,
629 Self::Note(_) => DirectivePriority::Note,
630 Self::Document(_) => DirectivePriority::Document,
631 Self::Event(_) => DirectivePriority::Event,
632 Self::Query(_) => DirectivePriority::Query,
633 Self::Price(_) => DirectivePriority::Price,
634 Self::Close(_) => DirectivePriority::Close,
635 Self::Custom(_) => DirectivePriority::Custom,
636 }
637 }
638}
639
640pub fn sort_directives(directives: &mut [Directive]) {
646 directives.sort_by_cached_key(booking_sort_key);
647}
648
649#[must_use]
675pub const fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority) {
676 (d.date(), d.priority())
677}
678
679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684#[cfg_attr(
685 feature = "rkyv",
686 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
687)]
688pub struct Transaction {
689 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
691 pub date: NaiveDate,
692 pub flag: char,
694 #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
696 pub payee: Option<InternedStr>,
697 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
699 pub narration: InternedStr,
700 pub tags: Vec<crate::Tag>,
702 pub links: Vec<crate::Link>,
704 pub meta: Metadata,
706 pub postings: Vec<crate::Spanned<Posting>>,
713 #[serde(default, skip_serializing_if = "Vec::is_empty")]
715 pub trailing_comments: Vec<String>,
716}
717
718impl Transaction {
719 #[must_use]
721 pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
722 Self {
723 date,
724 flag: '*',
725 payee: None,
726 narration: narration.into(),
727 tags: Vec::new(),
728 links: Vec::new(),
729 meta: Metadata::default(),
730 postings: Vec::new(),
731 trailing_comments: Vec::new(),
732 }
733 }
734
735 #[must_use]
737 pub const fn with_flag(mut self, flag: char) -> Self {
738 self.flag = flag;
739 self
740 }
741
742 #[must_use]
744 pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
745 self.payee = Some(payee.into());
746 self
747 }
748
749 #[must_use]
751 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
752 self.tags.push(tag.into());
753 self
754 }
755
756 #[must_use]
758 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
759 self.links.push(link.into());
760 self
761 }
762
763 #[must_use]
768 pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
769 self.postings.push(posting);
770 self
771 }
772
773 #[must_use]
781 pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
782 self.postings.push(crate::Spanned::synthesized(posting));
783 self
784 }
785
786 #[must_use]
788 pub const fn is_complete(&self) -> bool {
789 self.flag == '*'
790 }
791
792 #[must_use]
794 pub const fn is_incomplete(&self) -> bool {
795 self.flag == '!'
796 }
797
798 #[must_use]
801 pub const fn is_pending(&self) -> bool {
802 self.flag == '!'
803 }
804
805 #[must_use]
807 pub const fn is_summarization(&self) -> bool {
808 self.flag == 'S'
809 }
810
811 #[must_use]
813 pub const fn is_transfer(&self) -> bool {
814 self.flag == 'T'
815 }
816
817 #[must_use]
819 pub const fn is_conversion(&self) -> bool {
820 self.flag == 'C'
821 }
822
823 #[must_use]
825 pub const fn is_unrealized(&self) -> bool {
826 self.flag == 'U'
827 }
828
829 #[must_use]
831 pub const fn is_return(&self) -> bool {
832 self.flag == 'R'
833 }
834
835 #[must_use]
837 pub const fn is_merge(&self) -> bool {
838 self.flag == 'M'
839 }
840
841 #[must_use]
843 pub const fn is_bookmarked(&self) -> bool {
844 self.flag == '#'
845 }
846
847 #[must_use]
849 pub const fn needs_investigation(&self) -> bool {
850 self.flag == '?'
851 }
852
853 #[must_use]
855 pub const fn is_valid_flag(flag: char) -> bool {
856 matches!(
857 flag,
858 '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
859 )
860 }
861}
862
863impl fmt::Display for Transaction {
864 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865 write!(f, "{} {} ", self.date, self.flag)?;
866 if let Some(payee) = &self.payee {
867 write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
868 }
869 write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
870 for tag in &self.tags {
871 write!(f, " #{tag}")?;
872 }
873 for link in &self.links {
874 write!(f, " ^{link}")?;
875 }
876 for (key, value) in &self.meta {
878 write!(f, "\n {key}: {value}")?;
879 }
880 for posting in &self.postings {
881 write!(f, "\n{posting}")?;
882 }
883 Ok(())
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[cfg_attr(
892 feature = "rkyv",
893 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
894)]
895pub struct Balance {
896 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
898 pub date: NaiveDate,
899 pub account: crate::Account,
901 pub amount: Amount,
903 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
905 pub tolerance: Option<Decimal>,
906 pub meta: Metadata,
908}
909
910impl Balance {
911 #[must_use]
913 pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
914 Self {
915 date,
916 account: account.into(),
917 amount,
918 tolerance: None,
919 meta: Metadata::default(),
920 }
921 }
922
923 #[must_use]
925 pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
926 self.tolerance = Some(tolerance);
927 self
928 }
929
930 #[must_use]
932 pub fn with_meta(mut self, meta: Metadata) -> Self {
933 self.meta = meta;
934 self
935 }
936}
937
938impl fmt::Display for Balance {
939 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
940 write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
941 if let Some(tol) = self.tolerance {
942 write!(f, " ~ {tol}")?;
943 }
944 Ok(())
945 }
946}
947
948#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952#[cfg_attr(
953 feature = "rkyv",
954 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
955)]
956pub struct Open {
957 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
959 pub date: NaiveDate,
960 pub account: crate::Account,
962 pub currencies: Vec<crate::Currency>,
964 pub booking: Option<String>,
966 pub meta: Metadata,
968}
969
970impl Open {
971 #[must_use]
973 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
974 Self {
975 date,
976 account: account.into(),
977 currencies: Vec::new(),
978 booking: None,
979 meta: Metadata::default(),
980 }
981 }
982
983 #[must_use]
985 pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
986 self.currencies = currencies;
987 self
988 }
989
990 #[must_use]
992 pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
993 self.booking = Some(booking.into());
994 self
995 }
996
997 #[must_use]
999 pub fn with_meta(mut self, meta: Metadata) -> Self {
1000 self.meta = meta;
1001 self
1002 }
1003}
1004
1005impl fmt::Display for Open {
1006 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1007 write!(f, "{} open {}", self.date, self.account)?;
1008 if !self.currencies.is_empty() {
1009 let currencies: Vec<&str> = self
1010 .currencies
1011 .iter()
1012 .map(crate::Currency::as_str)
1013 .collect();
1014 write!(f, " {}", currencies.join(","))?;
1015 }
1016 if let Some(booking) = &self.booking {
1017 write!(f, " \"{}\"", crate::format::escape_string(booking))?;
1018 }
1019 Ok(())
1020 }
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027#[cfg_attr(
1028 feature = "rkyv",
1029 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1030)]
1031pub struct Close {
1032 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1034 pub date: NaiveDate,
1035 pub account: crate::Account,
1037 pub meta: Metadata,
1039}
1040
1041impl Close {
1042 #[must_use]
1044 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1045 Self {
1046 date,
1047 account: account.into(),
1048 meta: Metadata::default(),
1049 }
1050 }
1051
1052 #[must_use]
1054 pub fn with_meta(mut self, meta: Metadata) -> Self {
1055 self.meta = meta;
1056 self
1057 }
1058}
1059
1060impl fmt::Display for Close {
1061 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1062 write!(f, "{} close {}", self.date, self.account)
1063 }
1064}
1065
1066#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070#[cfg_attr(
1071 feature = "rkyv",
1072 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1073)]
1074pub struct Commodity {
1075 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1077 pub date: NaiveDate,
1078 pub currency: crate::Currency,
1080 pub meta: Metadata,
1082}
1083
1084impl Commodity {
1085 #[must_use]
1087 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1088 Self {
1089 date,
1090 currency: currency.into(),
1091 meta: Metadata::default(),
1092 }
1093 }
1094
1095 #[must_use]
1097 pub fn with_meta(mut self, meta: Metadata) -> Self {
1098 self.meta = meta;
1099 self
1100 }
1101}
1102
1103impl fmt::Display for Commodity {
1104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1105 write!(f, "{} commodity {}", self.date, self.currency)
1106 }
1107}
1108
1109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(
1115 feature = "rkyv",
1116 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1117)]
1118pub struct Pad {
1119 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1121 pub date: NaiveDate,
1122 pub account: crate::Account,
1124 pub source_account: crate::Account,
1126 pub meta: Metadata,
1128}
1129
1130impl Pad {
1131 #[must_use]
1133 pub fn new(
1134 date: NaiveDate,
1135 account: impl Into<crate::Account>,
1136 source_account: impl Into<crate::Account>,
1137 ) -> Self {
1138 Self {
1139 date,
1140 account: account.into(),
1141 source_account: source_account.into(),
1142 meta: Metadata::default(),
1143 }
1144 }
1145
1146 #[must_use]
1148 pub fn with_meta(mut self, meta: Metadata) -> Self {
1149 self.meta = meta;
1150 self
1151 }
1152}
1153
1154impl fmt::Display for Pad {
1155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1156 write!(
1157 f,
1158 "{} pad {} {}",
1159 self.date, self.account, self.source_account
1160 )
1161 }
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1168#[cfg_attr(
1169 feature = "rkyv",
1170 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1171)]
1172pub struct Event {
1173 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1175 pub date: NaiveDate,
1176 pub event_type: String,
1178 pub value: String,
1180 pub meta: Metadata,
1182}
1183
1184impl Event {
1185 #[must_use]
1187 pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1188 Self {
1189 date,
1190 event_type: event_type.into(),
1191 value: value.into(),
1192 meta: Metadata::default(),
1193 }
1194 }
1195
1196 #[must_use]
1198 pub fn with_meta(mut self, meta: Metadata) -> Self {
1199 self.meta = meta;
1200 self
1201 }
1202}
1203
1204impl fmt::Display for Event {
1205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1206 write!(
1207 f,
1208 "{} event \"{}\" \"{}\"",
1209 self.date,
1210 crate::format::escape_string(&self.event_type),
1211 crate::format::escape_string(&self.value)
1212 )
1213 }
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220#[cfg_attr(
1221 feature = "rkyv",
1222 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1223)]
1224pub struct Query {
1225 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1227 pub date: NaiveDate,
1228 pub name: String,
1230 pub query: String,
1232 pub meta: Metadata,
1234}
1235
1236impl Query {
1237 #[must_use]
1239 pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1240 Self {
1241 date,
1242 name: name.into(),
1243 query: query.into(),
1244 meta: Metadata::default(),
1245 }
1246 }
1247
1248 #[must_use]
1250 pub fn with_meta(mut self, meta: Metadata) -> Self {
1251 self.meta = meta;
1252 self
1253 }
1254}
1255
1256impl fmt::Display for Query {
1257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1258 write!(
1259 f,
1260 "{} query \"{}\" \"{}\"",
1261 self.date,
1262 crate::format::escape_string(&self.name),
1263 crate::format::escape_string(&self.query)
1264 )
1265 }
1266}
1267
1268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272#[cfg_attr(
1273 feature = "rkyv",
1274 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1275)]
1276pub struct Note {
1277 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1279 pub date: NaiveDate,
1280 pub account: crate::Account,
1282 pub comment: String,
1284 pub meta: Metadata,
1286}
1287
1288impl Note {
1289 #[must_use]
1291 pub fn new(
1292 date: NaiveDate,
1293 account: impl Into<crate::Account>,
1294 comment: impl Into<String>,
1295 ) -> Self {
1296 Self {
1297 date,
1298 account: account.into(),
1299 comment: comment.into(),
1300 meta: Metadata::default(),
1301 }
1302 }
1303
1304 #[must_use]
1306 pub fn with_meta(mut self, meta: Metadata) -> Self {
1307 self.meta = meta;
1308 self
1309 }
1310}
1311
1312impl fmt::Display for Note {
1313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1314 write!(
1315 f,
1316 "{} note {} \"{}\"",
1317 self.date,
1318 self.account,
1319 crate::format::escape_string(&self.comment)
1320 )
1321 }
1322}
1323
1324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1328#[cfg_attr(
1329 feature = "rkyv",
1330 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1331)]
1332pub struct Document {
1333 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1335 pub date: NaiveDate,
1336 pub account: crate::Account,
1338 pub path: String,
1340 pub tags: Vec<crate::Tag>,
1342 pub links: Vec<crate::Link>,
1344 pub meta: Metadata,
1346}
1347
1348impl Document {
1349 #[must_use]
1351 pub fn new(
1352 date: NaiveDate,
1353 account: impl Into<crate::Account>,
1354 path: impl Into<String>,
1355 ) -> Self {
1356 Self {
1357 date,
1358 account: account.into(),
1359 path: path.into(),
1360 tags: Vec::new(),
1361 links: Vec::new(),
1362 meta: Metadata::default(),
1363 }
1364 }
1365
1366 #[must_use]
1368 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1369 self.tags.push(tag.into());
1370 self
1371 }
1372
1373 #[must_use]
1375 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1376 self.links.push(link.into());
1377 self
1378 }
1379
1380 #[must_use]
1382 pub fn with_meta(mut self, meta: Metadata) -> Self {
1383 self.meta = meta;
1384 self
1385 }
1386}
1387
1388impl fmt::Display for Document {
1389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390 write!(
1391 f,
1392 "{} document {} \"{}\"",
1393 self.date,
1394 self.account,
1395 crate::format::escape_string(&self.path)
1396 )
1397 }
1398}
1399
1400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1404#[cfg_attr(
1405 feature = "rkyv",
1406 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1407)]
1408pub struct Price {
1409 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1411 pub date: NaiveDate,
1412 pub currency: crate::Currency,
1414 pub amount: Amount,
1416 pub meta: Metadata,
1418}
1419
1420impl Price {
1421 #[must_use]
1423 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1424 Self {
1425 date,
1426 currency: currency.into(),
1427 amount,
1428 meta: Metadata::default(),
1429 }
1430 }
1431
1432 #[must_use]
1434 pub fn with_meta(mut self, meta: Metadata) -> Self {
1435 self.meta = meta;
1436 self
1437 }
1438}
1439
1440impl fmt::Display for Price {
1441 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1442 write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1443 }
1444}
1445
1446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1450#[cfg_attr(
1451 feature = "rkyv",
1452 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1453)]
1454pub struct Custom {
1455 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1457 pub date: NaiveDate,
1458 pub custom_type: String,
1460 pub values: Vec<MetaValue>,
1462 pub meta: Metadata,
1464}
1465
1466impl Custom {
1467 #[must_use]
1469 pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1470 Self {
1471 date,
1472 custom_type: custom_type.into(),
1473 values: Vec::new(),
1474 meta: Metadata::default(),
1475 }
1476 }
1477
1478 #[must_use]
1480 pub fn with_value(mut self, value: MetaValue) -> Self {
1481 self.values.push(value);
1482 self
1483 }
1484
1485 #[must_use]
1487 pub fn with_meta(mut self, meta: Metadata) -> Self {
1488 self.meta = meta;
1489 self
1490 }
1491}
1492
1493impl fmt::Display for Custom {
1494 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1495 write!(
1496 f,
1497 "{} custom \"{}\"",
1498 self.date,
1499 crate::format::escape_string(&self.custom_type)
1500 )?;
1501 for value in &self.values {
1502 write!(f, " {value}")?;
1503 }
1504 Ok(())
1505 }
1506}
1507
1508impl fmt::Display for Directive {
1509 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1510 match self {
1511 Self::Transaction(t) => write!(f, "{t}"),
1512 Self::Balance(b) => write!(f, "{b}"),
1513 Self::Open(o) => write!(f, "{o}"),
1514 Self::Close(c) => write!(f, "{c}"),
1515 Self::Commodity(c) => write!(f, "{c}"),
1516 Self::Pad(p) => write!(f, "{p}"),
1517 Self::Event(e) => write!(f, "{e}"),
1518 Self::Query(q) => write!(f, "{q}"),
1519 Self::Note(n) => write!(f, "{n}"),
1520 Self::Document(d) => write!(f, "{d}"),
1521 Self::Price(p) => write!(f, "{p}"),
1522 Self::Custom(c) => write!(f, "{c}"),
1523 }
1524 }
1525}
1526
1527#[cfg(test)]
1528mod bool_vocabulary_tests {
1529 use super::*;
1530
1531 #[test]
1538 fn options_and_metadata_accept_the_same_spellings() {
1539 for word in ["TRUE", "true", "True", "1"] {
1540 assert_eq!(parse_bool_word(word), Some(true), "{word}");
1541 }
1542 for word in ["FALSE", "false", "False", "0"] {
1543 assert_eq!(parse_bool_word(word), Some(false), "{word}");
1544 }
1545 for word in ["YES", "NO", "T", "F", "on", "", "2", "maybe"] {
1546 assert_eq!(
1547 parse_bool_word(word),
1548 None,
1549 "{word} is not accepted by `option`, so metadata must not take \
1550 it either — the option parser warns (E7002) and metadata \
1551 leaves the default"
1552 );
1553 }
1554 }
1555
1556 #[test]
1559 fn a_bare_word_is_read_however_it_lexed() {
1560 assert_eq!(meta_value_as_bool(&MetaValue::Bool(true)), Some(true));
1561 assert_eq!(
1562 meta_value_as_bool(&MetaValue::String("TRUE".into())),
1563 Some(true)
1564 );
1565 assert_eq!(
1566 meta_value_as_bool(&MetaValue::Currency("TRUE".into())),
1567 Some(true)
1568 );
1569 assert_eq!(
1570 meta_value_as_bool(&MetaValue::Currency("FALSE".into())),
1571 Some(false)
1572 );
1573 assert_eq!(meta_value_as_bool(&MetaValue::Currency("USD".into())), None);
1574 }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580 use rust_decimal_macros::dec;
1581
1582 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1583 crate::naive_date(year, month, day).unwrap()
1584 }
1585
1586 #[cfg(feature = "rkyv")]
1599 #[test]
1600 fn meta_value_archived_bytes_snapshot() {
1601 let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();
1602
1603 let cases: &[(&str, MetaValue)] = &[
1606 ("String", MetaValue::String("USD".to_string())),
1607 (
1608 "Account",
1609 MetaValue::Account(crate::Account::from("Assets:Bank")),
1610 ),
1611 (
1612 "Currency",
1613 MetaValue::Currency(crate::Currency::from("USD")),
1614 ),
1615 ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
1616 ("Link", MetaValue::Link(crate::Link::from("t"))),
1617 ("Date", MetaValue::Date(date(2024, 1, 15))),
1618 ("Number", MetaValue::Number(dec!(42))),
1619 ("Bool", MetaValue::Bool(true)),
1620 ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
1621 ("None", MetaValue::None),
1622 ("Int", MetaValue::Int(42)),
1623 ];
1624
1625 let archived: Vec<(&str, Vec<u8>)> =
1626 cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();
1627
1628 for (name, bytes) in &archived {
1629 assert!(
1630 !bytes.is_empty(),
1631 "MetaValue::{name} archived to empty bytes"
1632 );
1633 }
1634 for (i, (na, a)) in archived.iter().enumerate() {
1635 for (nb, b) in archived.iter().skip(i + 1) {
1636 assert_ne!(
1637 a, b,
1638 "MetaValue::{na} and MetaValue::{nb} archive identically — a \
1639 discriminant collision (variant reorder?) the cache can't tell apart"
1640 );
1641 }
1642 }
1643 }
1644
1645 #[test]
1646 fn test_transaction() {
1647 let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1648 .with_payee("Whole Foods")
1649 .with_flag('*')
1650 .with_tag("food")
1651 .with_synthesized_posting(Posting::new(
1652 "Expenses:Food",
1653 Amount::new(dec!(50.00), "USD"),
1654 ))
1655 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1656
1657 assert_eq!(txn.flag, '*');
1658 assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1659 assert_eq!(txn.postings.len(), 2);
1660 assert!(txn.is_complete());
1661 }
1662
1663 #[test]
1664 fn test_balance() {
1665 let bal = Balance::new(
1666 date(2024, 1, 1),
1667 "Assets:Checking",
1668 Amount::new(dec!(1000.00), "USD"),
1669 );
1670
1671 assert_eq!(bal.account, "Assets:Checking");
1672 assert_eq!(bal.amount.number, dec!(1000.00));
1673 }
1674
1675 #[test]
1676 fn test_open() {
1677 let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1678 .with_currencies(vec!["USD".into()])
1679 .with_booking("FIFO");
1680
1681 assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1682 assert_eq!(open.booking, Some("FIFO".to_string()));
1683 }
1684
1685 #[test]
1686 fn test_directive_date() {
1687 let txn = Transaction::new(date(2024, 1, 15), "Test");
1688 let dir = Directive::Transaction(txn);
1689
1690 assert_eq!(dir.date(), date(2024, 1, 15));
1691 assert!(dir.is_transaction());
1692 assert_eq!(dir.type_name(), "transaction");
1693 }
1694
1695 #[test]
1696 fn test_posting_display() {
1697 let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1698 let s = format!("{posting}");
1699 assert!(s.contains("Assets:Checking"));
1700 assert!(s.contains("100.00 USD"));
1701 }
1702
1703 #[test]
1704 fn test_transaction_display() {
1705 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1706 .with_payee("Test Payee")
1707 .with_synthesized_posting(Posting::new(
1708 "Expenses:Test",
1709 Amount::new(dec!(50.00), "USD"),
1710 ))
1711 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1712
1713 let s = format!("{txn}");
1714 assert!(s.contains("2024-01-15"));
1715 assert!(s.contains("Test Payee"));
1716 assert!(s.contains("Test transaction"));
1717 }
1718
1719 #[test]
1720 fn test_directive_priority() {
1721 assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1723 assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1724 assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1725 assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1726 assert!(DirectivePriority::Price < DirectivePriority::Close);
1727 }
1728
1729 #[test]
1730 fn test_sort_directives_by_date() {
1731 let mut directives = vec![
1732 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1733 Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1734 Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1735 ];
1736
1737 sort_directives(&mut directives);
1738
1739 assert_eq!(directives[0].date(), date(2024, 1, 1));
1740 assert_eq!(directives[1].date(), date(2024, 1, 10));
1741 assert_eq!(directives[2].date(), date(2024, 1, 15));
1742 }
1743
1744 #[test]
1745 fn test_sort_directives_by_type_same_date() {
1746 let mut directives = vec![
1748 Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1749 Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1750 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1751 Directive::Balance(Balance::new(
1752 date(2024, 1, 1),
1753 "Assets:Bank",
1754 Amount::new(dec!(0), "USD"),
1755 )),
1756 ];
1757
1758 sort_directives(&mut directives);
1759
1760 assert_eq!(directives[0].type_name(), "open");
1761 assert_eq!(directives[1].type_name(), "balance");
1762 assert_eq!(directives[2].type_name(), "transaction");
1763 assert_eq!(directives[3].type_name(), "close");
1764 }
1765
1766 #[test]
1767 fn test_sort_directives_pad_before_balance() {
1768 let mut directives = vec![
1770 Directive::Balance(Balance::new(
1771 date(2024, 1, 1),
1772 "Assets:Bank",
1773 Amount::new(dec!(1000), "USD"),
1774 )),
1775 Directive::Pad(Pad::new(
1776 date(2024, 1, 1),
1777 "Assets:Bank",
1778 "Equity:Opening-Balances",
1779 )),
1780 ];
1781
1782 sort_directives(&mut directives);
1783
1784 assert_eq!(directives[0].type_name(), "pad");
1785 assert_eq!(directives[1].type_name(), "balance");
1786 }
1787
1788 #[test]
1789 fn same_date_directives_sort_in_file_order() {
1790 let looks_like_a_reduction = Directive::Transaction(
1801 Transaction::new(date(2024, 9, 1), "Transfer Received")
1802 .with_synthesized_posting(
1803 Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1804 CostSpec::empty()
1805 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1806 .with_currency("EUR"),
1807 ),
1808 )
1809 .with_synthesized_posting(
1810 Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1811 CostSpec::empty()
1812 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1813 .with_currency("EUR"),
1814 ),
1815 ),
1816 );
1817
1818 let augmentation = Directive::Transaction(
1819 Transaction::new(date(2024, 9, 1), "Transfer Sent")
1820 .with_synthesized_posting(Posting::new(
1821 "Assets:AccountA",
1822 Amount::new(dec!(-10.00), "EUR"),
1823 ))
1824 .with_synthesized_posting(
1825 Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1826 CostSpec::empty()
1827 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1828 .with_currency("EUR"),
1829 ),
1830 ),
1831 );
1832
1833 let mut directives = vec![looks_like_a_reduction, augmentation];
1834 sort_directives(&mut directives);
1835
1836 let narrations: Vec<&str> = directives
1837 .iter()
1838 .map(|d| match d {
1839 Directive::Transaction(t) => t.narration.as_str(),
1840 _ => unreachable!(),
1841 })
1842 .collect();
1843 assert_eq!(
1844 narrations,
1845 vec!["Transfer Received", "Transfer Sent"],
1846 "same-date directives must keep file order; floating augmentations \
1847 ahead of reductions is what #2093 reported"
1848 );
1849 }
1850
1851 #[test]
1852 fn test_transaction_flags() {
1853 let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1854
1855 assert!(make_txn('*').is_complete());
1857 assert!(make_txn('!').is_incomplete());
1858 assert!(make_txn('!').is_pending());
1859
1860 assert!(make_txn('S').is_summarization());
1862 assert!(make_txn('T').is_transfer());
1863 assert!(make_txn('C').is_conversion());
1864 assert!(make_txn('U').is_unrealized());
1865 assert!(make_txn('R').is_return());
1866 assert!(make_txn('M').is_merge());
1867 assert!(make_txn('#').is_bookmarked());
1868 assert!(make_txn('?').needs_investigation());
1869
1870 assert!(!make_txn('*').is_pending());
1872 assert!(!make_txn('!').is_complete());
1873 }
1874
1875 #[test]
1876 fn test_is_valid_flag() {
1877 for flag in [
1879 '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1880 ] {
1881 assert!(
1882 Transaction::is_valid_flag(flag),
1883 "Flag '{flag}' should be valid"
1884 );
1885 }
1886
1887 for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1889 assert!(
1890 !Transaction::is_valid_flag(flag),
1891 "Flag '{flag}' should be invalid"
1892 );
1893 }
1894 }
1895
1896 #[test]
1897 fn test_transaction_display_includes_metadata() {
1898 let mut meta = Metadata::default();
1899 meta.insert(
1900 "document".to_string(),
1901 MetaValue::String("myfile.pdf".to_string()),
1902 );
1903
1904 let txn = Transaction {
1905 date: date(2026, 2, 23),
1906 flag: '*',
1907 payee: None,
1908 narration: "Example".into(),
1909 tags: vec![],
1910 links: vec![],
1911 meta,
1912 postings: vec![
1913 crate::Spanned::synthesized(Posting::new(
1914 "Assets:Bank",
1915 Amount::new(dec!(-2), "USD"),
1916 )),
1917 crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1918 ],
1919 trailing_comments: Vec::new(),
1920 };
1921
1922 let output = txn.to_string();
1923 assert!(
1924 output.contains("document: \"myfile.pdf\""),
1925 "Transaction Display should include metadata: {output}"
1926 );
1927 assert!(
1928 output.contains("Assets:Bank"),
1929 "Transaction Display should include postings: {output}"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_posting_display_includes_metadata() {
1935 let mut meta = Metadata::default();
1936 meta.insert(
1937 "category".to_string(),
1938 MetaValue::String("groceries".to_string()),
1939 );
1940
1941 let posting = Posting {
1942 account: "Expenses:Food".into(),
1943 units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1944 cost: None,
1945 price: None,
1946 flag: None,
1947 meta,
1948 comments: Vec::new(),
1949 trailing_comments: Vec::new(),
1950 };
1951
1952 let output = posting.to_string();
1953 assert!(
1954 output.contains("category: \"groceries\""),
1955 "Posting Display should include metadata: {output}"
1956 );
1957 }
1958
1959 #[test]
1960 fn test_directive_display() {
1961 let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1963 let dir = Directive::Transaction(txn.clone());
1964
1965 assert_eq!(format!("{dir}"), format!("{txn}"));
1967
1968 let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1970 let dir_open = Directive::Open(open.clone());
1971 assert_eq!(format!("{dir_open}"), format!("{open}"));
1972
1973 let balance = Balance::new(
1974 date(2024, 1, 1),
1975 "Assets:Bank",
1976 Amount::new(dec!(100), "USD"),
1977 );
1978 let dir_balance = Directive::Balance(balance.clone());
1979 assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1980 }
1981
1982 #[test]
1985 fn parse_precision_meta_accepts_non_negative_integers() {
1986 assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
1989 assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
1990 assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
1991 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1992 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1993 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1994 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1998 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1999 }
2000
2001 #[test]
2002 fn parse_precision_meta_rejects_negatives() {
2003 let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
2004 assert!(err.contains("non-negative"), "got: {err}");
2005 let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
2006 assert!(err.contains("non-negative"), "got: {err}");
2007 }
2008
2009 #[test]
2010 fn parse_precision_meta_rejects_fractional() {
2011 let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
2012 assert!(err.contains("integer"), "got: {err}");
2013 }
2014
2015 #[test]
2016 fn parse_precision_meta_rejects_overflow() {
2017 let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
2019 assert!(err.contains("exceeds"), "got: {err}");
2020 let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
2021 assert!(err.contains("exceeds"), "got: {err}");
2022 }
2023
2024 #[test]
2025 fn meta_value_int_display_and_kind() {
2026 assert_eq!(MetaValue::Int(42).to_string(), "42");
2027 assert_eq!(MetaValue::Int(-7).to_string(), "-7");
2028 assert_eq!(
2029 crate::format::format_meta_value(
2030 &MetaValue::Int(42),
2031 &crate::format::FormatConfig::default()
2032 ),
2033 "42"
2034 );
2035 assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
2036 }
2037
2038 #[test]
2039 fn parse_precision_meta_rejects_non_number_variants() {
2040 use crate::Amount;
2045 use rust_decimal_macros::dec;
2046 let cases = [
2047 (MetaValue::String("2".into()), "string"),
2048 (MetaValue::Account("Assets:Cash".into()), "account"),
2049 (MetaValue::Currency("USD".into()), "currency"),
2050 (MetaValue::Tag("foo".into()), "tag"),
2051 (MetaValue::Link("bar".into()), "link"),
2052 (MetaValue::Date(date(2024, 1, 1)), "date"),
2053 (MetaValue::Bool(true), "bool"),
2054 (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
2055 (MetaValue::None, "none"),
2056 ];
2057 for (case, kind) in cases {
2058 let err = match parse_precision_meta(&case) {
2059 Ok(_) => panic!("should have rejected {case:?}"),
2060 Err(e) => e,
2061 };
2062 assert!(
2063 err.contains(kind),
2064 "error for {case:?} should mention kind {kind:?}, got: {err}"
2065 );
2066 }
2067 }
2068}