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 Balance = 2,
501 Pad = 3,
503 Transaction = 4,
505 Note = 5,
507 Document = 6,
509 Event = 7,
511 Query = 8,
513 Price = 9,
515 Close = 10,
517 Custom = 11,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[cfg_attr(
524 feature = "rkyv",
525 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
526)]
527pub enum Directive {
528 Transaction(Transaction),
530 Balance(Balance),
532 Open(Open),
534 Close(Close),
536 Commodity(Commodity),
538 Pad(Pad),
540 Event(Event),
542 Query(Query),
544 Note(Note),
546 Document(Document),
548 Price(Price),
550 Custom(Custom),
552}
553
554impl Directive {
555 #[must_use]
557 pub const fn date(&self) -> NaiveDate {
558 match self {
559 Self::Transaction(t) => t.date,
560 Self::Balance(b) => b.date,
561 Self::Open(o) => o.date,
562 Self::Close(c) => c.date,
563 Self::Commodity(c) => c.date,
564 Self::Pad(p) => p.date,
565 Self::Event(e) => e.date,
566 Self::Query(q) => q.date,
567 Self::Note(n) => n.date,
568 Self::Document(d) => d.date,
569 Self::Price(p) => p.date,
570 Self::Custom(c) => c.date,
571 }
572 }
573
574 #[must_use]
576 pub const fn meta(&self) -> &Metadata {
577 match self {
578 Self::Transaction(t) => &t.meta,
579 Self::Balance(b) => &b.meta,
580 Self::Open(o) => &o.meta,
581 Self::Close(c) => &c.meta,
582 Self::Commodity(c) => &c.meta,
583 Self::Pad(p) => &p.meta,
584 Self::Event(e) => &e.meta,
585 Self::Query(q) => &q.meta,
586 Self::Note(n) => &n.meta,
587 Self::Document(d) => &d.meta,
588 Self::Price(p) => &p.meta,
589 Self::Custom(c) => &c.meta,
590 }
591 }
592
593 #[must_use]
595 pub const fn is_transaction(&self) -> bool {
596 matches!(self, Self::Transaction(_))
597 }
598
599 #[must_use]
601 pub const fn as_transaction(&self) -> Option<&Transaction> {
602 match self {
603 Self::Transaction(t) => Some(t),
604 _ => None,
605 }
606 }
607
608 #[must_use]
610 pub const fn type_name(&self) -> &'static str {
611 match self {
612 Self::Transaction(_) => "transaction",
613 Self::Balance(_) => "balance",
614 Self::Open(_) => "open",
615 Self::Close(_) => "close",
616 Self::Commodity(_) => "commodity",
617 Self::Pad(_) => "pad",
618 Self::Event(_) => "event",
619 Self::Query(_) => "query",
620 Self::Note(_) => "note",
621 Self::Document(_) => "document",
622 Self::Price(_) => "price",
623 Self::Custom(_) => "custom",
624 }
625 }
626
627 #[must_use]
631 pub const fn priority(&self) -> DirectivePriority {
632 match self {
633 Self::Open(_) => DirectivePriority::Open,
634 Self::Commodity(_) => DirectivePriority::Commodity,
635 Self::Pad(_) => DirectivePriority::Pad,
636 Self::Balance(_) => DirectivePriority::Balance,
637 Self::Transaction(_) => DirectivePriority::Transaction,
638 Self::Note(_) => DirectivePriority::Note,
639 Self::Document(_) => DirectivePriority::Document,
640 Self::Event(_) => DirectivePriority::Event,
641 Self::Query(_) => DirectivePriority::Query,
642 Self::Price(_) => DirectivePriority::Price,
643 Self::Close(_) => DirectivePriority::Close,
644 Self::Custom(_) => DirectivePriority::Custom,
645 }
646 }
647}
648
649pub fn sort_directives(directives: &mut [Directive]) {
681 directives.sort_by_cached_key(booking_sort_key);
682}
683
684#[must_use]
713pub const fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority) {
714 (d.date(), d.priority())
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[cfg_attr(
723 feature = "rkyv",
724 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
725)]
726pub struct Transaction {
727 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
729 pub date: NaiveDate,
730 pub flag: char,
732 #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
734 pub payee: Option<InternedStr>,
735 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
737 pub narration: InternedStr,
738 pub tags: Vec<crate::Tag>,
740 pub links: Vec<crate::Link>,
742 pub meta: Metadata,
744 pub postings: Vec<crate::Spanned<Posting>>,
751 #[serde(default, skip_serializing_if = "Vec::is_empty")]
753 pub trailing_comments: Vec<String>,
754}
755
756impl Transaction {
757 #[must_use]
759 pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
760 Self {
761 date,
762 flag: '*',
763 payee: None,
764 narration: narration.into(),
765 tags: Vec::new(),
766 links: Vec::new(),
767 meta: Metadata::default(),
768 postings: Vec::new(),
769 trailing_comments: Vec::new(),
770 }
771 }
772
773 #[must_use]
775 pub const fn with_flag(mut self, flag: char) -> Self {
776 self.flag = flag;
777 self
778 }
779
780 #[must_use]
782 pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
783 self.payee = Some(payee.into());
784 self
785 }
786
787 #[must_use]
789 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
790 self.tags.push(tag.into());
791 self
792 }
793
794 #[must_use]
796 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
797 self.links.push(link.into());
798 self
799 }
800
801 #[must_use]
806 pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
807 self.postings.push(posting);
808 self
809 }
810
811 #[must_use]
819 pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
820 self.postings.push(crate::Spanned::synthesized(posting));
821 self
822 }
823
824 #[must_use]
826 pub const fn is_complete(&self) -> bool {
827 self.flag == '*'
828 }
829
830 #[must_use]
832 pub const fn is_incomplete(&self) -> bool {
833 self.flag == '!'
834 }
835
836 #[must_use]
839 pub const fn is_pending(&self) -> bool {
840 self.flag == '!'
841 }
842
843 #[must_use]
845 pub const fn is_summarization(&self) -> bool {
846 self.flag == 'S'
847 }
848
849 #[must_use]
851 pub const fn is_transfer(&self) -> bool {
852 self.flag == 'T'
853 }
854
855 #[must_use]
857 pub const fn is_conversion(&self) -> bool {
858 self.flag == 'C'
859 }
860
861 #[must_use]
863 pub const fn is_unrealized(&self) -> bool {
864 self.flag == 'U'
865 }
866
867 #[must_use]
869 pub const fn is_return(&self) -> bool {
870 self.flag == 'R'
871 }
872
873 #[must_use]
875 pub const fn is_merge(&self) -> bool {
876 self.flag == 'M'
877 }
878
879 #[must_use]
881 pub const fn is_bookmarked(&self) -> bool {
882 self.flag == '#'
883 }
884
885 #[must_use]
887 pub const fn needs_investigation(&self) -> bool {
888 self.flag == '?'
889 }
890
891 #[must_use]
893 pub const fn is_valid_flag(flag: char) -> bool {
894 matches!(
895 flag,
896 '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
897 )
898 }
899}
900
901impl fmt::Display for Transaction {
902 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
903 write!(f, "{} {} ", self.date, self.flag)?;
904 if let Some(payee) = &self.payee {
905 write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
906 }
907 write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
908 for tag in &self.tags {
909 write!(f, " #{tag}")?;
910 }
911 for link in &self.links {
912 write!(f, " ^{link}")?;
913 }
914 for (key, value) in &self.meta {
916 write!(f, "\n {key}: {value}")?;
917 }
918 for posting in &self.postings {
919 write!(f, "\n{posting}")?;
920 }
921 Ok(())
922 }
923}
924
925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929#[cfg_attr(
930 feature = "rkyv",
931 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
932)]
933pub struct Balance {
934 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
936 pub date: NaiveDate,
937 pub account: crate::Account,
939 pub amount: Amount,
941 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
943 pub tolerance: Option<Decimal>,
944 pub meta: Metadata,
946}
947
948impl Balance {
949 #[must_use]
951 pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
952 Self {
953 date,
954 account: account.into(),
955 amount,
956 tolerance: None,
957 meta: Metadata::default(),
958 }
959 }
960
961 #[must_use]
963 pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
964 self.tolerance = Some(tolerance);
965 self
966 }
967
968 #[must_use]
970 pub fn with_meta(mut self, meta: Metadata) -> Self {
971 self.meta = meta;
972 self
973 }
974}
975
976impl fmt::Display for Balance {
977 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
978 write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
979 if let Some(tol) = self.tolerance {
980 write!(f, " ~ {tol}")?;
981 }
982 Ok(())
983 }
984}
985
986#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990#[cfg_attr(
991 feature = "rkyv",
992 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
993)]
994pub struct Open {
995 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
997 pub date: NaiveDate,
998 pub account: crate::Account,
1000 pub currencies: Vec<crate::Currency>,
1002 pub booking: Option<String>,
1004 pub meta: Metadata,
1006}
1007
1008impl Open {
1009 #[must_use]
1011 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1012 Self {
1013 date,
1014 account: account.into(),
1015 currencies: Vec::new(),
1016 booking: None,
1017 meta: Metadata::default(),
1018 }
1019 }
1020
1021 #[must_use]
1023 pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
1024 self.currencies = currencies;
1025 self
1026 }
1027
1028 #[must_use]
1030 pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
1031 self.booking = Some(booking.into());
1032 self
1033 }
1034
1035 #[must_use]
1037 pub fn with_meta(mut self, meta: Metadata) -> Self {
1038 self.meta = meta;
1039 self
1040 }
1041}
1042
1043impl fmt::Display for Open {
1044 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1045 write!(f, "{} open {}", self.date, self.account)?;
1046 if !self.currencies.is_empty() {
1047 let currencies: Vec<&str> = self
1048 .currencies
1049 .iter()
1050 .map(crate::Currency::as_str)
1051 .collect();
1052 write!(f, " {}", currencies.join(","))?;
1053 }
1054 if let Some(booking) = &self.booking {
1055 write!(f, " \"{}\"", crate::format::escape_string(booking))?;
1056 }
1057 Ok(())
1058 }
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065#[cfg_attr(
1066 feature = "rkyv",
1067 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1068)]
1069pub struct Close {
1070 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1072 pub date: NaiveDate,
1073 pub account: crate::Account,
1075 pub meta: Metadata,
1077}
1078
1079impl Close {
1080 #[must_use]
1082 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1083 Self {
1084 date,
1085 account: account.into(),
1086 meta: Metadata::default(),
1087 }
1088 }
1089
1090 #[must_use]
1092 pub fn with_meta(mut self, meta: Metadata) -> Self {
1093 self.meta = meta;
1094 self
1095 }
1096}
1097
1098impl fmt::Display for Close {
1099 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1100 write!(f, "{} close {}", self.date, self.account)
1101 }
1102}
1103
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1108#[cfg_attr(
1109 feature = "rkyv",
1110 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1111)]
1112pub struct Commodity {
1113 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1115 pub date: NaiveDate,
1116 pub currency: crate::Currency,
1118 pub meta: Metadata,
1120}
1121
1122impl Commodity {
1123 #[must_use]
1125 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1126 Self {
1127 date,
1128 currency: currency.into(),
1129 meta: Metadata::default(),
1130 }
1131 }
1132
1133 #[must_use]
1135 pub fn with_meta(mut self, meta: Metadata) -> Self {
1136 self.meta = meta;
1137 self
1138 }
1139}
1140
1141impl fmt::Display for Commodity {
1142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1143 write!(f, "{} commodity {}", self.date, self.currency)
1144 }
1145}
1146
1147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1152#[cfg_attr(
1153 feature = "rkyv",
1154 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1155)]
1156pub struct Pad {
1157 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1159 pub date: NaiveDate,
1160 pub account: crate::Account,
1162 pub source_account: crate::Account,
1164 pub meta: Metadata,
1166}
1167
1168impl Pad {
1169 #[must_use]
1171 pub fn new(
1172 date: NaiveDate,
1173 account: impl Into<crate::Account>,
1174 source_account: impl Into<crate::Account>,
1175 ) -> Self {
1176 Self {
1177 date,
1178 account: account.into(),
1179 source_account: source_account.into(),
1180 meta: Metadata::default(),
1181 }
1182 }
1183
1184 #[must_use]
1186 pub fn with_meta(mut self, meta: Metadata) -> Self {
1187 self.meta = meta;
1188 self
1189 }
1190}
1191
1192impl fmt::Display for Pad {
1193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1194 write!(
1195 f,
1196 "{} pad {} {}",
1197 self.date, self.account, self.source_account
1198 )
1199 }
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206#[cfg_attr(
1207 feature = "rkyv",
1208 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1209)]
1210pub struct Event {
1211 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1213 pub date: NaiveDate,
1214 pub event_type: String,
1216 pub value: String,
1218 pub meta: Metadata,
1220}
1221
1222impl Event {
1223 #[must_use]
1225 pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1226 Self {
1227 date,
1228 event_type: event_type.into(),
1229 value: value.into(),
1230 meta: Metadata::default(),
1231 }
1232 }
1233
1234 #[must_use]
1236 pub fn with_meta(mut self, meta: Metadata) -> Self {
1237 self.meta = meta;
1238 self
1239 }
1240}
1241
1242impl fmt::Display for Event {
1243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1244 write!(
1245 f,
1246 "{} event \"{}\" \"{}\"",
1247 self.date,
1248 crate::format::escape_string(&self.event_type),
1249 crate::format::escape_string(&self.value)
1250 )
1251 }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258#[cfg_attr(
1259 feature = "rkyv",
1260 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1261)]
1262pub struct Query {
1263 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1265 pub date: NaiveDate,
1266 pub name: String,
1268 pub query: String,
1270 pub meta: Metadata,
1272}
1273
1274impl Query {
1275 #[must_use]
1277 pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1278 Self {
1279 date,
1280 name: name.into(),
1281 query: query.into(),
1282 meta: Metadata::default(),
1283 }
1284 }
1285
1286 #[must_use]
1288 pub fn with_meta(mut self, meta: Metadata) -> Self {
1289 self.meta = meta;
1290 self
1291 }
1292}
1293
1294impl fmt::Display for Query {
1295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1296 write!(
1297 f,
1298 "{} query \"{}\" \"{}\"",
1299 self.date,
1300 crate::format::escape_string(&self.name),
1301 crate::format::escape_string(&self.query)
1302 )
1303 }
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1310#[cfg_attr(
1311 feature = "rkyv",
1312 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1313)]
1314pub struct Note {
1315 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1317 pub date: NaiveDate,
1318 pub account: crate::Account,
1320 pub comment: String,
1322 pub tags: Vec<crate::Tag>,
1328 pub links: Vec<crate::Link>,
1330 pub meta: Metadata,
1332}
1333
1334impl Note {
1335 #[must_use]
1337 pub fn new(
1338 date: NaiveDate,
1339 account: impl Into<crate::Account>,
1340 comment: impl Into<String>,
1341 ) -> Self {
1342 Self {
1343 date,
1344 account: account.into(),
1345 comment: comment.into(),
1346 tags: Vec::new(),
1347 links: Vec::new(),
1348 meta: Metadata::default(),
1349 }
1350 }
1351
1352 #[must_use]
1354 pub fn with_tags(mut self, tags: Vec<crate::Tag>) -> Self {
1355 self.tags = tags;
1356 self
1357 }
1358
1359 #[must_use]
1361 pub fn with_links(mut self, links: Vec<crate::Link>) -> Self {
1362 self.links = links;
1363 self
1364 }
1365
1366 #[must_use]
1368 pub fn with_meta(mut self, meta: Metadata) -> Self {
1369 self.meta = meta;
1370 self
1371 }
1372}
1373
1374impl fmt::Display for Note {
1375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1376 write!(
1377 f,
1378 "{} note {} \"{}\"",
1379 self.date,
1380 self.account,
1381 crate::format::escape_string(&self.comment)
1382 )
1383 }
1384}
1385
1386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1390#[cfg_attr(
1391 feature = "rkyv",
1392 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1393)]
1394pub struct Document {
1395 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1397 pub date: NaiveDate,
1398 pub account: crate::Account,
1400 pub path: String,
1402 pub tags: Vec<crate::Tag>,
1404 pub links: Vec<crate::Link>,
1406 pub meta: Metadata,
1408}
1409
1410impl Document {
1411 #[must_use]
1413 pub fn new(
1414 date: NaiveDate,
1415 account: impl Into<crate::Account>,
1416 path: impl Into<String>,
1417 ) -> Self {
1418 Self {
1419 date,
1420 account: account.into(),
1421 path: path.into(),
1422 tags: Vec::new(),
1423 links: Vec::new(),
1424 meta: Metadata::default(),
1425 }
1426 }
1427
1428 #[must_use]
1430 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1431 self.tags.push(tag.into());
1432 self
1433 }
1434
1435 #[must_use]
1437 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1438 self.links.push(link.into());
1439 self
1440 }
1441
1442 #[must_use]
1444 pub fn with_meta(mut self, meta: Metadata) -> Self {
1445 self.meta = meta;
1446 self
1447 }
1448}
1449
1450impl fmt::Display for Document {
1451 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1452 write!(
1453 f,
1454 "{} document {} \"{}\"",
1455 self.date,
1456 self.account,
1457 crate::format::escape_string(&self.path)
1458 )
1459 }
1460}
1461
1462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1466#[cfg_attr(
1467 feature = "rkyv",
1468 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1469)]
1470pub struct Price {
1471 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1473 pub date: NaiveDate,
1474 pub currency: crate::Currency,
1476 pub amount: Amount,
1478 pub meta: Metadata,
1480}
1481
1482impl Price {
1483 #[must_use]
1485 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1486 Self {
1487 date,
1488 currency: currency.into(),
1489 amount,
1490 meta: Metadata::default(),
1491 }
1492 }
1493
1494 #[must_use]
1496 pub fn with_meta(mut self, meta: Metadata) -> Self {
1497 self.meta = meta;
1498 self
1499 }
1500}
1501
1502impl fmt::Display for Price {
1503 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1504 write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1505 }
1506}
1507
1508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1512#[cfg_attr(
1513 feature = "rkyv",
1514 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1515)]
1516pub struct Custom {
1517 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1519 pub date: NaiveDate,
1520 pub custom_type: String,
1522 pub values: Vec<MetaValue>,
1524 pub meta: Metadata,
1526}
1527
1528impl Custom {
1529 #[must_use]
1531 pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1532 Self {
1533 date,
1534 custom_type: custom_type.into(),
1535 values: Vec::new(),
1536 meta: Metadata::default(),
1537 }
1538 }
1539
1540 #[must_use]
1542 pub fn with_value(mut self, value: MetaValue) -> Self {
1543 self.values.push(value);
1544 self
1545 }
1546
1547 #[must_use]
1549 pub fn with_meta(mut self, meta: Metadata) -> Self {
1550 self.meta = meta;
1551 self
1552 }
1553}
1554
1555impl fmt::Display for Custom {
1556 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1557 write!(
1558 f,
1559 "{} custom \"{}\"",
1560 self.date,
1561 crate::format::escape_string(&self.custom_type)
1562 )?;
1563 for value in &self.values {
1564 write!(f, " {value}")?;
1565 }
1566 Ok(())
1567 }
1568}
1569
1570impl fmt::Display for Directive {
1571 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1572 match self {
1573 Self::Transaction(t) => write!(f, "{t}"),
1574 Self::Balance(b) => write!(f, "{b}"),
1575 Self::Open(o) => write!(f, "{o}"),
1576 Self::Close(c) => write!(f, "{c}"),
1577 Self::Commodity(c) => write!(f, "{c}"),
1578 Self::Pad(p) => write!(f, "{p}"),
1579 Self::Event(e) => write!(f, "{e}"),
1580 Self::Query(q) => write!(f, "{q}"),
1581 Self::Note(n) => write!(f, "{n}"),
1582 Self::Document(d) => write!(f, "{d}"),
1583 Self::Price(p) => write!(f, "{p}"),
1584 Self::Custom(c) => write!(f, "{c}"),
1585 }
1586 }
1587}
1588
1589#[cfg(test)]
1590mod bool_vocabulary_tests {
1591 use super::*;
1592
1593 #[test]
1600 fn options_and_metadata_accept_the_same_spellings() {
1601 for word in ["TRUE", "true", "True", "1"] {
1602 assert_eq!(parse_bool_word(word), Some(true), "{word}");
1603 }
1604 for word in ["FALSE", "false", "False", "0"] {
1605 assert_eq!(parse_bool_word(word), Some(false), "{word}");
1606 }
1607 for word in ["YES", "NO", "T", "F", "on", "", "2", "maybe"] {
1608 assert_eq!(
1609 parse_bool_word(word),
1610 None,
1611 "{word} is not accepted by `option`, so metadata must not take \
1612 it either — the option parser warns (E7002) and metadata \
1613 leaves the default"
1614 );
1615 }
1616 }
1617
1618 #[test]
1621 fn a_bare_word_is_read_however_it_lexed() {
1622 assert_eq!(meta_value_as_bool(&MetaValue::Bool(true)), Some(true));
1623 assert_eq!(
1624 meta_value_as_bool(&MetaValue::String("TRUE".into())),
1625 Some(true)
1626 );
1627 assert_eq!(
1628 meta_value_as_bool(&MetaValue::Currency("TRUE".into())),
1629 Some(true)
1630 );
1631 assert_eq!(
1632 meta_value_as_bool(&MetaValue::Currency("FALSE".into())),
1633 Some(false)
1634 );
1635 assert_eq!(meta_value_as_bool(&MetaValue::Currency("USD".into())), None);
1636 }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use super::*;
1642 use rust_decimal_macros::dec;
1643
1644 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1645 crate::naive_date(year, month, day).unwrap()
1646 }
1647
1648 #[cfg(feature = "rkyv")]
1661 #[test]
1662 fn meta_value_archived_bytes_snapshot() {
1663 let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();
1664
1665 let cases: &[(&str, MetaValue)] = &[
1668 ("String", MetaValue::String("USD".to_string())),
1669 (
1670 "Account",
1671 MetaValue::Account(crate::Account::from("Assets:Bank")),
1672 ),
1673 (
1674 "Currency",
1675 MetaValue::Currency(crate::Currency::from("USD")),
1676 ),
1677 ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
1678 ("Link", MetaValue::Link(crate::Link::from("t"))),
1679 ("Date", MetaValue::Date(date(2024, 1, 15))),
1680 ("Number", MetaValue::Number(dec!(42))),
1681 ("Bool", MetaValue::Bool(true)),
1682 ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
1683 ("None", MetaValue::None),
1684 ("Int", MetaValue::Int(42)),
1685 ];
1686
1687 let archived: Vec<(&str, Vec<u8>)> =
1688 cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();
1689
1690 for (name, bytes) in &archived {
1691 assert!(
1692 !bytes.is_empty(),
1693 "MetaValue::{name} archived to empty bytes"
1694 );
1695 }
1696 for (i, (na, a)) in archived.iter().enumerate() {
1697 for (nb, b) in archived.iter().skip(i + 1) {
1698 assert_ne!(
1699 a, b,
1700 "MetaValue::{na} and MetaValue::{nb} archive identically — a \
1701 discriminant collision (variant reorder?) the cache can't tell apart"
1702 );
1703 }
1704 }
1705 }
1706
1707 #[test]
1708 fn test_transaction() {
1709 let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1710 .with_payee("Whole Foods")
1711 .with_flag('*')
1712 .with_tag("food")
1713 .with_synthesized_posting(Posting::new(
1714 "Expenses:Food",
1715 Amount::new(dec!(50.00), "USD"),
1716 ))
1717 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1718
1719 assert_eq!(txn.flag, '*');
1720 assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1721 assert_eq!(txn.postings.len(), 2);
1722 assert!(txn.is_complete());
1723 }
1724
1725 #[test]
1726 fn test_balance() {
1727 let bal = Balance::new(
1728 date(2024, 1, 1),
1729 "Assets:Checking",
1730 Amount::new(dec!(1000.00), "USD"),
1731 );
1732
1733 assert_eq!(bal.account, "Assets:Checking");
1734 assert_eq!(bal.amount.number, dec!(1000.00));
1735 }
1736
1737 #[test]
1738 fn test_open() {
1739 let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1740 .with_currencies(vec!["USD".into()])
1741 .with_booking("FIFO");
1742
1743 assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1744 assert_eq!(open.booking, Some("FIFO".to_string()));
1745 }
1746
1747 #[test]
1748 fn test_directive_date() {
1749 let txn = Transaction::new(date(2024, 1, 15), "Test");
1750 let dir = Directive::Transaction(txn);
1751
1752 assert_eq!(dir.date(), date(2024, 1, 15));
1753 assert!(dir.is_transaction());
1754 assert_eq!(dir.type_name(), "transaction");
1755 }
1756
1757 #[test]
1758 fn test_posting_display() {
1759 let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1760 let s = format!("{posting}");
1761 assert!(s.contains("Assets:Checking"));
1762 assert!(s.contains("100.00 USD"));
1763 }
1764
1765 #[test]
1766 fn test_transaction_display() {
1767 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1768 .with_payee("Test Payee")
1769 .with_synthesized_posting(Posting::new(
1770 "Expenses:Test",
1771 Amount::new(dec!(50.00), "USD"),
1772 ))
1773 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1774
1775 let s = format!("{txn}");
1776 assert!(s.contains("2024-01-15"));
1777 assert!(s.contains("Test Payee"));
1778 assert!(s.contains("Test transaction"));
1779 }
1780
1781 #[test]
1782 fn test_directive_priority() {
1783 assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1785 assert!(DirectivePriority::Balance < DirectivePriority::Pad);
1789 assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1790 assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1791 assert!(DirectivePriority::Price < DirectivePriority::Close);
1792 }
1793
1794 #[test]
1795 fn test_sort_directives_by_date() {
1796 let mut directives = vec![
1797 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1798 Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1799 Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1800 ];
1801
1802 sort_directives(&mut directives);
1803
1804 assert_eq!(directives[0].date(), date(2024, 1, 1));
1805 assert_eq!(directives[1].date(), date(2024, 1, 10));
1806 assert_eq!(directives[2].date(), date(2024, 1, 15));
1807 }
1808
1809 #[test]
1810 fn test_sort_directives_by_type_same_date() {
1811 let mut directives = vec![
1813 Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1814 Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1815 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1816 Directive::Balance(Balance::new(
1817 date(2024, 1, 1),
1818 "Assets:Bank",
1819 Amount::new(dec!(0), "USD"),
1820 )),
1821 ];
1822
1823 sort_directives(&mut directives);
1824
1825 assert_eq!(directives[0].type_name(), "open");
1826 assert_eq!(directives[1].type_name(), "balance");
1827 assert_eq!(directives[2].type_name(), "transaction");
1828 assert_eq!(directives[3].type_name(), "close");
1829 }
1830
1831 #[test]
1832 fn test_sort_directives_balance_before_pad() {
1833 let mut directives = vec![
1842 Directive::Balance(Balance::new(
1843 date(2024, 1, 1),
1844 "Assets:Bank",
1845 Amount::new(dec!(1000), "USD"),
1846 )),
1847 Directive::Pad(Pad::new(
1848 date(2024, 1, 1),
1849 "Assets:Bank",
1850 "Equity:Opening-Balances",
1851 )),
1852 ];
1853
1854 sort_directives(&mut directives);
1855
1856 assert_eq!(directives[0].type_name(), "balance");
1857 assert_eq!(directives[1].type_name(), "pad");
1858 }
1859
1860 #[test]
1861 fn same_date_directives_sort_in_file_order() {
1862 let looks_like_a_reduction = Directive::Transaction(
1873 Transaction::new(date(2024, 9, 1), "Transfer Received")
1874 .with_synthesized_posting(
1875 Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1876 CostSpec::empty()
1877 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1878 .with_currency("EUR"),
1879 ),
1880 )
1881 .with_synthesized_posting(
1882 Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1883 CostSpec::empty()
1884 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1885 .with_currency("EUR"),
1886 ),
1887 ),
1888 );
1889
1890 let augmentation = Directive::Transaction(
1891 Transaction::new(date(2024, 9, 1), "Transfer Sent")
1892 .with_synthesized_posting(Posting::new(
1893 "Assets:AccountA",
1894 Amount::new(dec!(-10.00), "EUR"),
1895 ))
1896 .with_synthesized_posting(
1897 Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1898 CostSpec::empty()
1899 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1900 .with_currency("EUR"),
1901 ),
1902 ),
1903 );
1904
1905 let mut directives = vec![looks_like_a_reduction, augmentation];
1906 sort_directives(&mut directives);
1907
1908 let narrations: Vec<&str> = directives
1909 .iter()
1910 .map(|d| match d {
1911 Directive::Transaction(t) => t.narration.as_str(),
1912 _ => unreachable!(),
1913 })
1914 .collect();
1915 assert_eq!(
1916 narrations,
1917 vec!["Transfer Received", "Transfer Sent"],
1918 "same-date directives must keep file order; floating augmentations \
1919 ahead of reductions is what #2093 reported"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_transaction_flags() {
1925 let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1926
1927 assert!(make_txn('*').is_complete());
1929 assert!(make_txn('!').is_incomplete());
1930 assert!(make_txn('!').is_pending());
1931
1932 assert!(make_txn('S').is_summarization());
1934 assert!(make_txn('T').is_transfer());
1935 assert!(make_txn('C').is_conversion());
1936 assert!(make_txn('U').is_unrealized());
1937 assert!(make_txn('R').is_return());
1938 assert!(make_txn('M').is_merge());
1939 assert!(make_txn('#').is_bookmarked());
1940 assert!(make_txn('?').needs_investigation());
1941
1942 assert!(!make_txn('*').is_pending());
1944 assert!(!make_txn('!').is_complete());
1945 }
1946
1947 #[test]
1948 fn test_is_valid_flag() {
1949 for flag in [
1951 '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1952 ] {
1953 assert!(
1954 Transaction::is_valid_flag(flag),
1955 "Flag '{flag}' should be valid"
1956 );
1957 }
1958
1959 for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1961 assert!(
1962 !Transaction::is_valid_flag(flag),
1963 "Flag '{flag}' should be invalid"
1964 );
1965 }
1966 }
1967
1968 #[test]
1969 fn test_transaction_display_includes_metadata() {
1970 let mut meta = Metadata::default();
1971 meta.insert(
1972 "document".to_string(),
1973 MetaValue::String("myfile.pdf".to_string()),
1974 );
1975
1976 let txn = Transaction {
1977 date: date(2026, 2, 23),
1978 flag: '*',
1979 payee: None,
1980 narration: "Example".into(),
1981 tags: vec![],
1982 links: vec![],
1983 meta,
1984 postings: vec![
1985 crate::Spanned::synthesized(Posting::new(
1986 "Assets:Bank",
1987 Amount::new(dec!(-2), "USD"),
1988 )),
1989 crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1990 ],
1991 trailing_comments: Vec::new(),
1992 };
1993
1994 let output = txn.to_string();
1995 assert!(
1996 output.contains("document: \"myfile.pdf\""),
1997 "Transaction Display should include metadata: {output}"
1998 );
1999 assert!(
2000 output.contains("Assets:Bank"),
2001 "Transaction Display should include postings: {output}"
2002 );
2003 }
2004
2005 #[test]
2006 fn test_posting_display_includes_metadata() {
2007 let mut meta = Metadata::default();
2008 meta.insert(
2009 "category".to_string(),
2010 MetaValue::String("groceries".to_string()),
2011 );
2012
2013 let posting = Posting {
2014 account: "Expenses:Food".into(),
2015 units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
2016 cost: None,
2017 price: None,
2018 flag: None,
2019 meta,
2020 comments: Vec::new(),
2021 trailing_comments: Vec::new(),
2022 };
2023
2024 let output = posting.to_string();
2025 assert!(
2026 output.contains("category: \"groceries\""),
2027 "Posting Display should include metadata: {output}"
2028 );
2029 }
2030
2031 #[test]
2032 fn test_directive_display() {
2033 let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
2035 let dir = Directive::Transaction(txn.clone());
2036
2037 assert_eq!(format!("{dir}"), format!("{txn}"));
2039
2040 let open = Open::new(date(2024, 1, 1), "Assets:Bank");
2042 let dir_open = Directive::Open(open.clone());
2043 assert_eq!(format!("{dir_open}"), format!("{open}"));
2044
2045 let balance = Balance::new(
2046 date(2024, 1, 1),
2047 "Assets:Bank",
2048 Amount::new(dec!(100), "USD"),
2049 );
2050 let dir_balance = Directive::Balance(balance.clone());
2051 assert_eq!(format!("{dir_balance}"), format!("{balance}"));
2052 }
2053
2054 #[test]
2057 fn parse_precision_meta_accepts_non_negative_integers() {
2058 assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
2061 assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
2062 assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
2063 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
2064 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
2065 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
2066 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
2070 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
2071 }
2072
2073 #[test]
2074 fn parse_precision_meta_rejects_negatives() {
2075 let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
2076 assert!(err.contains("non-negative"), "got: {err}");
2077 let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
2078 assert!(err.contains("non-negative"), "got: {err}");
2079 }
2080
2081 #[test]
2082 fn parse_precision_meta_rejects_fractional() {
2083 let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
2084 assert!(err.contains("integer"), "got: {err}");
2085 }
2086
2087 #[test]
2088 fn parse_precision_meta_rejects_overflow() {
2089 let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
2091 assert!(err.contains("exceeds"), "got: {err}");
2092 let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
2093 assert!(err.contains("exceeds"), "got: {err}");
2094 }
2095
2096 #[test]
2097 fn meta_value_int_display_and_kind() {
2098 assert_eq!(MetaValue::Int(42).to_string(), "42");
2099 assert_eq!(MetaValue::Int(-7).to_string(), "-7");
2100 assert_eq!(
2101 crate::format::format_meta_value(
2102 &MetaValue::Int(42),
2103 &crate::format::FormatConfig::default()
2104 ),
2105 "42"
2106 );
2107 assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
2108 }
2109
2110 #[test]
2111 fn parse_precision_meta_rejects_non_number_variants() {
2112 use crate::Amount;
2117 use rust_decimal_macros::dec;
2118 let cases = [
2119 (MetaValue::String("2".into()), "string"),
2120 (MetaValue::Account("Assets:Cash".into()), "account"),
2121 (MetaValue::Currency("USD".into()), "currency"),
2122 (MetaValue::Tag("foo".into()), "tag"),
2123 (MetaValue::Link("bar".into()), "link"),
2124 (MetaValue::Date(date(2024, 1, 1)), "date"),
2125 (MetaValue::Bool(true), "bool"),
2126 (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
2127 (MetaValue::None, "none"),
2128 ];
2129 for (case, kind) in cases {
2130 let err = match parse_precision_meta(&case) {
2131 Ok(_) => panic!("should have rejected {case:?}"),
2132 Err(e) => e,
2133 };
2134 assert!(
2135 err.contains(kind),
2136 "error for {case:?} should mention kind {kind:?}, got: {err}"
2137 );
2138 }
2139 }
2140}