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 = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
98pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
99 use rust_decimal::prelude::ToPrimitive;
100 match value {
101 MetaValue::Int(i) => u32::try_from(*i).map_err(|_| {
103 if *i < 0 {
104 format!("expected a non-negative integer, got {i}")
105 } else {
106 format!(
107 "value {i} exceeds the maximum supported precision ({})",
108 u32::MAX
109 )
110 }
111 }),
112 MetaValue::Number(n) => {
114 if n.is_sign_negative() {
115 return Err(format!("expected a non-negative integer, got {n}"));
116 }
117 if !n.fract().is_zero() {
118 return Err(format!("expected an integer, got {n}"));
119 }
120 n.to_u32().ok_or_else(|| {
121 format!(
122 "value {n} exceeds the maximum supported precision ({})",
123 u32::MAX
124 )
125 })
126 }
127 _ => Err(format!(
128 "expected a non-negative integer, got {} value",
129 meta_value_kind(value)
130 )),
131 }
132}
133
134const fn meta_value_kind(v: &MetaValue) -> &'static str {
135 match v {
136 MetaValue::String(_) => "string",
137 MetaValue::Account(_) => "account",
138 MetaValue::Currency(_) => "currency",
139 MetaValue::Tag(_) => "tag",
140 MetaValue::Link(_) => "link",
141 MetaValue::Date(_) => "date",
142 MetaValue::Number(_) => "number",
143 MetaValue::Bool(_) => "bool",
144 MetaValue::Amount(_) => "amount",
145 MetaValue::None => "none",
146 MetaValue::Int(_) => "int",
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[cfg_attr(
161 feature = "rkyv",
162 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
163)]
164pub struct Posting {
165 pub account: crate::Account,
167 pub units: Option<IncompleteAmount>,
169 pub cost: Option<CostSpec>,
171 pub price: Option<PriceAnnotation>,
173 pub flag: Option<char>,
175 pub meta: Metadata,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub comments: Vec<String>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub trailing_comments: Vec<String>,
183}
184
185impl Posting {
186 #[must_use]
188 pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
189 Self {
190 account: account.into(),
191 units: Some(IncompleteAmount::Complete(units)),
192 cost: None,
193 price: None,
194 flag: None,
195 meta: Metadata::default(),
196 comments: Vec::new(),
197 trailing_comments: Vec::new(),
198 }
199 }
200
201 #[must_use]
203 pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
204 Self {
205 account: account.into(),
206 units: Some(units),
207 cost: None,
208 price: None,
209 flag: None,
210 meta: Metadata::default(),
211 comments: Vec::new(),
212 trailing_comments: Vec::new(),
213 }
214 }
215
216 #[must_use]
218 pub fn auto(account: impl Into<crate::Account>) -> Self {
219 Self {
220 account: account.into(),
221 units: None,
222 cost: None,
223 price: None,
224 flag: None,
225 meta: Metadata::default(),
226 comments: Vec::new(),
227 trailing_comments: Vec::new(),
228 }
229 }
230
231 #[must_use]
233 pub fn amount(&self) -> Option<&Amount> {
234 self.units.as_ref().and_then(|u| u.as_amount())
235 }
236
237 #[must_use]
254 pub fn with_cost(mut self, cost: CostSpec) -> Self {
255 self.cost = Some(cost);
256 self
257 }
258
259 #[must_use]
263 pub fn with_price(mut self, price: PriceAnnotation) -> Self {
264 self.price = Some(price);
265 self
266 }
267
268 #[must_use]
272 pub const fn with_flag(mut self, flag: char) -> Self {
273 self.flag = Some(flag);
274 self
275 }
276
277 #[must_use]
279 pub const fn has_units(&self) -> bool {
280 self.units.is_some()
281 }
282}
283
284impl fmt::Display for Posting {
285 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286 write!(f, " ")?;
287 if let Some(flag) = self.flag {
288 write!(f, "{flag} ")?;
289 }
290 write!(f, "{}", self.account)?;
291 if let Some(units) = &self.units {
292 write!(f, " {units}")?;
293 }
294 if let Some(cost) = &self.cost {
295 write!(f, " {cost}")?;
296 }
297 if let Some(price) = &self.price {
298 write!(f, " {price}")?;
299 }
300 for (key, value) in &self.meta {
302 write!(f, "\n {key}: {value}")?;
303 }
304 Ok(())
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[cfg_attr(
311 feature = "rkyv",
312 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
313)]
314pub enum PriceKind {
315 Unit,
317 Total,
319}
320
321impl fmt::Display for PriceKind {
322 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323 f.write_str(match self {
324 Self::Unit => "@",
325 Self::Total => "@@",
326 })
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[cfg_attr(
344 feature = "rkyv",
345 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
346)]
347pub struct PriceAnnotation {
348 pub kind: PriceKind,
350 pub amount: Option<IncompleteAmount>,
355}
356
357impl PriceAnnotation {
358 #[must_use]
360 pub const fn unit(amount: Amount) -> Self {
361 Self {
362 kind: PriceKind::Unit,
363 amount: Some(IncompleteAmount::Complete(amount)),
364 }
365 }
366
367 #[must_use]
369 pub const fn total(amount: Amount) -> Self {
370 Self {
371 kind: PriceKind::Total,
372 amount: Some(IncompleteAmount::Complete(amount)),
373 }
374 }
375
376 #[must_use]
378 pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
379 Self {
380 kind: PriceKind::Unit,
381 amount: Some(amount),
382 }
383 }
384
385 #[must_use]
387 pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
388 Self {
389 kind: PriceKind::Total,
390 amount: Some(amount),
391 }
392 }
393
394 #[must_use]
396 pub const fn unit_empty() -> Self {
397 Self {
398 kind: PriceKind::Unit,
399 amount: None,
400 }
401 }
402
403 #[must_use]
405 pub const fn total_empty() -> Self {
406 Self {
407 kind: PriceKind::Total,
408 amount: None,
409 }
410 }
411
412 #[must_use]
414 pub fn amount(&self) -> Option<&Amount> {
415 self.amount.as_ref().and_then(IncompleteAmount::as_amount)
416 }
417
418 #[must_use]
420 pub const fn is_unit(&self) -> bool {
421 matches!(self.kind, PriceKind::Unit)
422 }
423}
424
425impl fmt::Display for PriceAnnotation {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 match &self.amount {
428 Some(amt) => write!(f, "{} {amt}", self.kind),
429 None => write!(f, "{}", self.kind),
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
439pub enum DirectivePriority {
440 Open = 0,
442 Commodity = 1,
444 Pad = 2,
446 Balance = 3,
448 Transaction = 4,
450 Note = 5,
452 Document = 6,
454 Event = 7,
456 Query = 8,
458 Price = 9,
460 Close = 10,
462 Custom = 11,
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[cfg_attr(
469 feature = "rkyv",
470 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
471)]
472pub enum Directive {
473 Transaction(Transaction),
475 Balance(Balance),
477 Open(Open),
479 Close(Close),
481 Commodity(Commodity),
483 Pad(Pad),
485 Event(Event),
487 Query(Query),
489 Note(Note),
491 Document(Document),
493 Price(Price),
495 Custom(Custom),
497}
498
499impl Directive {
500 #[must_use]
502 pub const fn date(&self) -> NaiveDate {
503 match self {
504 Self::Transaction(t) => t.date,
505 Self::Balance(b) => b.date,
506 Self::Open(o) => o.date,
507 Self::Close(c) => c.date,
508 Self::Commodity(c) => c.date,
509 Self::Pad(p) => p.date,
510 Self::Event(e) => e.date,
511 Self::Query(q) => q.date,
512 Self::Note(n) => n.date,
513 Self::Document(d) => d.date,
514 Self::Price(p) => p.date,
515 Self::Custom(c) => c.date,
516 }
517 }
518
519 #[must_use]
521 pub const fn meta(&self) -> &Metadata {
522 match self {
523 Self::Transaction(t) => &t.meta,
524 Self::Balance(b) => &b.meta,
525 Self::Open(o) => &o.meta,
526 Self::Close(c) => &c.meta,
527 Self::Commodity(c) => &c.meta,
528 Self::Pad(p) => &p.meta,
529 Self::Event(e) => &e.meta,
530 Self::Query(q) => &q.meta,
531 Self::Note(n) => &n.meta,
532 Self::Document(d) => &d.meta,
533 Self::Price(p) => &p.meta,
534 Self::Custom(c) => &c.meta,
535 }
536 }
537
538 #[must_use]
540 pub const fn is_transaction(&self) -> bool {
541 matches!(self, Self::Transaction(_))
542 }
543
544 #[must_use]
546 pub const fn as_transaction(&self) -> Option<&Transaction> {
547 match self {
548 Self::Transaction(t) => Some(t),
549 _ => None,
550 }
551 }
552
553 #[must_use]
555 pub const fn type_name(&self) -> &'static str {
556 match self {
557 Self::Transaction(_) => "transaction",
558 Self::Balance(_) => "balance",
559 Self::Open(_) => "open",
560 Self::Close(_) => "close",
561 Self::Commodity(_) => "commodity",
562 Self::Pad(_) => "pad",
563 Self::Event(_) => "event",
564 Self::Query(_) => "query",
565 Self::Note(_) => "note",
566 Self::Document(_) => "document",
567 Self::Price(_) => "price",
568 Self::Custom(_) => "custom",
569 }
570 }
571
572 #[must_use]
576 pub const fn priority(&self) -> DirectivePriority {
577 match self {
578 Self::Open(_) => DirectivePriority::Open,
579 Self::Commodity(_) => DirectivePriority::Commodity,
580 Self::Pad(_) => DirectivePriority::Pad,
581 Self::Balance(_) => DirectivePriority::Balance,
582 Self::Transaction(_) => DirectivePriority::Transaction,
583 Self::Note(_) => DirectivePriority::Note,
584 Self::Document(_) => DirectivePriority::Document,
585 Self::Event(_) => DirectivePriority::Event,
586 Self::Query(_) => DirectivePriority::Query,
587 Self::Price(_) => DirectivePriority::Price,
588 Self::Close(_) => DirectivePriority::Close,
589 Self::Custom(_) => DirectivePriority::Custom,
590 }
591 }
592
593 #[must_use]
600 pub fn has_cost_reduction(&self) -> bool {
601 if let Self::Transaction(txn) = self {
602 txn.postings.iter().any(|p| {
603 p.cost.is_some()
604 && p.units
605 .as_ref()
606 .and_then(IncompleteAmount::number)
607 .is_some_and(|n| n.is_sign_negative())
608 })
609 } else {
610 false
611 }
612 }
613}
614
615pub fn sort_directives(directives: &mut [Directive]) {
625 directives.sort_by_cached_key(booking_sort_key);
626}
627
628#[must_use]
640pub fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority, bool) {
641 (d.date(), d.priority(), d.has_cost_reduction())
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649#[cfg_attr(
650 feature = "rkyv",
651 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
652)]
653pub struct Transaction {
654 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
656 pub date: NaiveDate,
657 pub flag: char,
659 #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
661 pub payee: Option<InternedStr>,
662 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
664 pub narration: InternedStr,
665 pub tags: Vec<crate::Tag>,
667 pub links: Vec<crate::Link>,
669 pub meta: Metadata,
671 pub postings: Vec<crate::Spanned<Posting>>,
678 #[serde(default, skip_serializing_if = "Vec::is_empty")]
680 pub trailing_comments: Vec<String>,
681}
682
683impl Transaction {
684 #[must_use]
686 pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
687 Self {
688 date,
689 flag: '*',
690 payee: None,
691 narration: narration.into(),
692 tags: Vec::new(),
693 links: Vec::new(),
694 meta: Metadata::default(),
695 postings: Vec::new(),
696 trailing_comments: Vec::new(),
697 }
698 }
699
700 #[must_use]
702 pub const fn with_flag(mut self, flag: char) -> Self {
703 self.flag = flag;
704 self
705 }
706
707 #[must_use]
709 pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
710 self.payee = Some(payee.into());
711 self
712 }
713
714 #[must_use]
716 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
717 self.tags.push(tag.into());
718 self
719 }
720
721 #[must_use]
723 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
724 self.links.push(link.into());
725 self
726 }
727
728 #[must_use]
733 pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
734 self.postings.push(posting);
735 self
736 }
737
738 #[must_use]
746 pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
747 self.postings.push(crate::Spanned::synthesized(posting));
748 self
749 }
750
751 #[must_use]
753 pub const fn is_complete(&self) -> bool {
754 self.flag == '*'
755 }
756
757 #[must_use]
759 pub const fn is_incomplete(&self) -> bool {
760 self.flag == '!'
761 }
762
763 #[must_use]
766 pub const fn is_pending(&self) -> bool {
767 self.flag == '!'
768 }
769
770 #[must_use]
772 pub const fn is_summarization(&self) -> bool {
773 self.flag == 'S'
774 }
775
776 #[must_use]
778 pub const fn is_transfer(&self) -> bool {
779 self.flag == 'T'
780 }
781
782 #[must_use]
784 pub const fn is_conversion(&self) -> bool {
785 self.flag == 'C'
786 }
787
788 #[must_use]
790 pub const fn is_unrealized(&self) -> bool {
791 self.flag == 'U'
792 }
793
794 #[must_use]
796 pub const fn is_return(&self) -> bool {
797 self.flag == 'R'
798 }
799
800 #[must_use]
802 pub const fn is_merge(&self) -> bool {
803 self.flag == 'M'
804 }
805
806 #[must_use]
808 pub const fn is_bookmarked(&self) -> bool {
809 self.flag == '#'
810 }
811
812 #[must_use]
814 pub const fn needs_investigation(&self) -> bool {
815 self.flag == '?'
816 }
817
818 #[must_use]
820 pub const fn is_valid_flag(flag: char) -> bool {
821 matches!(
822 flag,
823 '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
824 )
825 }
826}
827
828impl fmt::Display for Transaction {
829 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
830 write!(f, "{} {} ", self.date, self.flag)?;
831 if let Some(payee) = &self.payee {
832 write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
833 }
834 write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
835 for tag in &self.tags {
836 write!(f, " #{tag}")?;
837 }
838 for link in &self.links {
839 write!(f, " ^{link}")?;
840 }
841 for (key, value) in &self.meta {
843 write!(f, "\n {key}: {value}")?;
844 }
845 for posting in &self.postings {
846 write!(f, "\n{posting}")?;
847 }
848 Ok(())
849 }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
856#[cfg_attr(
857 feature = "rkyv",
858 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
859)]
860pub struct Balance {
861 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
863 pub date: NaiveDate,
864 pub account: crate::Account,
866 pub amount: Amount,
868 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
870 pub tolerance: Option<Decimal>,
871 pub meta: Metadata,
873}
874
875impl Balance {
876 #[must_use]
878 pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
879 Self {
880 date,
881 account: account.into(),
882 amount,
883 tolerance: None,
884 meta: Metadata::default(),
885 }
886 }
887
888 #[must_use]
890 pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
891 self.tolerance = Some(tolerance);
892 self
893 }
894
895 #[must_use]
897 pub fn with_meta(mut self, meta: Metadata) -> Self {
898 self.meta = meta;
899 self
900 }
901}
902
903impl fmt::Display for Balance {
904 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
905 write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
906 if let Some(tol) = self.tolerance {
907 write!(f, " ~ {tol}")?;
908 }
909 Ok(())
910 }
911}
912
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
917#[cfg_attr(
918 feature = "rkyv",
919 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
920)]
921pub struct Open {
922 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
924 pub date: NaiveDate,
925 pub account: crate::Account,
927 pub currencies: Vec<crate::Currency>,
929 pub booking: Option<String>,
931 pub meta: Metadata,
933}
934
935impl Open {
936 #[must_use]
938 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
939 Self {
940 date,
941 account: account.into(),
942 currencies: Vec::new(),
943 booking: None,
944 meta: Metadata::default(),
945 }
946 }
947
948 #[must_use]
950 pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
951 self.currencies = currencies;
952 self
953 }
954
955 #[must_use]
957 pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
958 self.booking = Some(booking.into());
959 self
960 }
961
962 #[must_use]
964 pub fn with_meta(mut self, meta: Metadata) -> Self {
965 self.meta = meta;
966 self
967 }
968}
969
970impl fmt::Display for Open {
971 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
972 write!(f, "{} open {}", self.date, self.account)?;
973 if !self.currencies.is_empty() {
974 let currencies: Vec<&str> = self
975 .currencies
976 .iter()
977 .map(crate::Currency::as_str)
978 .collect();
979 write!(f, " {}", currencies.join(","))?;
980 }
981 if let Some(booking) = &self.booking {
982 write!(f, " \"{}\"", crate::format::escape_string(booking))?;
983 }
984 Ok(())
985 }
986}
987
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992#[cfg_attr(
993 feature = "rkyv",
994 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
995)]
996pub struct Close {
997 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
999 pub date: NaiveDate,
1000 pub account: crate::Account,
1002 pub meta: Metadata,
1004}
1005
1006impl Close {
1007 #[must_use]
1009 pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1010 Self {
1011 date,
1012 account: account.into(),
1013 meta: Metadata::default(),
1014 }
1015 }
1016
1017 #[must_use]
1019 pub fn with_meta(mut self, meta: Metadata) -> Self {
1020 self.meta = meta;
1021 self
1022 }
1023}
1024
1025impl fmt::Display for Close {
1026 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1027 write!(f, "{} close {}", self.date, self.account)
1028 }
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035#[cfg_attr(
1036 feature = "rkyv",
1037 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1038)]
1039pub struct Commodity {
1040 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1042 pub date: NaiveDate,
1043 pub currency: crate::Currency,
1045 pub meta: Metadata,
1047}
1048
1049impl Commodity {
1050 #[must_use]
1052 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1053 Self {
1054 date,
1055 currency: currency.into(),
1056 meta: Metadata::default(),
1057 }
1058 }
1059
1060 #[must_use]
1062 pub fn with_meta(mut self, meta: Metadata) -> Self {
1063 self.meta = meta;
1064 self
1065 }
1066}
1067
1068impl fmt::Display for Commodity {
1069 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1070 write!(f, "{} commodity {}", self.date, self.currency)
1071 }
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079#[cfg_attr(
1080 feature = "rkyv",
1081 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1082)]
1083pub struct Pad {
1084 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1086 pub date: NaiveDate,
1087 pub account: crate::Account,
1089 pub source_account: crate::Account,
1091 pub meta: Metadata,
1093}
1094
1095impl Pad {
1096 #[must_use]
1098 pub fn new(
1099 date: NaiveDate,
1100 account: impl Into<crate::Account>,
1101 source_account: impl Into<crate::Account>,
1102 ) -> Self {
1103 Self {
1104 date,
1105 account: account.into(),
1106 source_account: source_account.into(),
1107 meta: Metadata::default(),
1108 }
1109 }
1110
1111 #[must_use]
1113 pub fn with_meta(mut self, meta: Metadata) -> Self {
1114 self.meta = meta;
1115 self
1116 }
1117}
1118
1119impl fmt::Display for Pad {
1120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1121 write!(
1122 f,
1123 "{} pad {} {}",
1124 self.date, self.account, self.source_account
1125 )
1126 }
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1133#[cfg_attr(
1134 feature = "rkyv",
1135 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1136)]
1137pub struct Event {
1138 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1140 pub date: NaiveDate,
1141 pub event_type: String,
1143 pub value: String,
1145 pub meta: Metadata,
1147}
1148
1149impl Event {
1150 #[must_use]
1152 pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1153 Self {
1154 date,
1155 event_type: event_type.into(),
1156 value: value.into(),
1157 meta: Metadata::default(),
1158 }
1159 }
1160
1161 #[must_use]
1163 pub fn with_meta(mut self, meta: Metadata) -> Self {
1164 self.meta = meta;
1165 self
1166 }
1167}
1168
1169impl fmt::Display for Event {
1170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1171 write!(
1172 f,
1173 "{} event \"{}\" \"{}\"",
1174 self.date,
1175 crate::format::escape_string(&self.event_type),
1176 crate::format::escape_string(&self.value)
1177 )
1178 }
1179}
1180
1181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1185#[cfg_attr(
1186 feature = "rkyv",
1187 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1188)]
1189pub struct Query {
1190 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1192 pub date: NaiveDate,
1193 pub name: String,
1195 pub query: String,
1197 pub meta: Metadata,
1199}
1200
1201impl Query {
1202 #[must_use]
1204 pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1205 Self {
1206 date,
1207 name: name.into(),
1208 query: query.into(),
1209 meta: Metadata::default(),
1210 }
1211 }
1212
1213 #[must_use]
1215 pub fn with_meta(mut self, meta: Metadata) -> Self {
1216 self.meta = meta;
1217 self
1218 }
1219}
1220
1221impl fmt::Display for Query {
1222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1223 write!(
1224 f,
1225 "{} query \"{}\" \"{}\"",
1226 self.date,
1227 crate::format::escape_string(&self.name),
1228 crate::format::escape_string(&self.query)
1229 )
1230 }
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1237#[cfg_attr(
1238 feature = "rkyv",
1239 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1240)]
1241pub struct Note {
1242 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1244 pub date: NaiveDate,
1245 pub account: crate::Account,
1247 pub comment: String,
1249 pub meta: Metadata,
1251}
1252
1253impl Note {
1254 #[must_use]
1256 pub fn new(
1257 date: NaiveDate,
1258 account: impl Into<crate::Account>,
1259 comment: impl Into<String>,
1260 ) -> Self {
1261 Self {
1262 date,
1263 account: account.into(),
1264 comment: comment.into(),
1265 meta: Metadata::default(),
1266 }
1267 }
1268
1269 #[must_use]
1271 pub fn with_meta(mut self, meta: Metadata) -> Self {
1272 self.meta = meta;
1273 self
1274 }
1275}
1276
1277impl fmt::Display for Note {
1278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1279 write!(
1280 f,
1281 "{} note {} \"{}\"",
1282 self.date,
1283 self.account,
1284 crate::format::escape_string(&self.comment)
1285 )
1286 }
1287}
1288
1289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1293#[cfg_attr(
1294 feature = "rkyv",
1295 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1296)]
1297pub struct Document {
1298 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1300 pub date: NaiveDate,
1301 pub account: crate::Account,
1303 pub path: String,
1305 pub tags: Vec<crate::Tag>,
1307 pub links: Vec<crate::Link>,
1309 pub meta: Metadata,
1311}
1312
1313impl Document {
1314 #[must_use]
1316 pub fn new(
1317 date: NaiveDate,
1318 account: impl Into<crate::Account>,
1319 path: impl Into<String>,
1320 ) -> Self {
1321 Self {
1322 date,
1323 account: account.into(),
1324 path: path.into(),
1325 tags: Vec::new(),
1326 links: Vec::new(),
1327 meta: Metadata::default(),
1328 }
1329 }
1330
1331 #[must_use]
1333 pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1334 self.tags.push(tag.into());
1335 self
1336 }
1337
1338 #[must_use]
1340 pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1341 self.links.push(link.into());
1342 self
1343 }
1344
1345 #[must_use]
1347 pub fn with_meta(mut self, meta: Metadata) -> Self {
1348 self.meta = meta;
1349 self
1350 }
1351}
1352
1353impl fmt::Display for Document {
1354 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1355 write!(
1356 f,
1357 "{} document {} \"{}\"",
1358 self.date,
1359 self.account,
1360 crate::format::escape_string(&self.path)
1361 )
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 Price {
1374 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1376 pub date: NaiveDate,
1377 pub currency: crate::Currency,
1379 pub amount: Amount,
1381 pub meta: Metadata,
1383}
1384
1385impl Price {
1386 #[must_use]
1388 pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1389 Self {
1390 date,
1391 currency: currency.into(),
1392 amount,
1393 meta: Metadata::default(),
1394 }
1395 }
1396
1397 #[must_use]
1399 pub fn with_meta(mut self, meta: Metadata) -> Self {
1400 self.meta = meta;
1401 self
1402 }
1403}
1404
1405impl fmt::Display for Price {
1406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1407 write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1408 }
1409}
1410
1411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1415#[cfg_attr(
1416 feature = "rkyv",
1417 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1418)]
1419pub struct Custom {
1420 #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1422 pub date: NaiveDate,
1423 pub custom_type: String,
1425 pub values: Vec<MetaValue>,
1427 pub meta: Metadata,
1429}
1430
1431impl Custom {
1432 #[must_use]
1434 pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1435 Self {
1436 date,
1437 custom_type: custom_type.into(),
1438 values: Vec::new(),
1439 meta: Metadata::default(),
1440 }
1441 }
1442
1443 #[must_use]
1445 pub fn with_value(mut self, value: MetaValue) -> Self {
1446 self.values.push(value);
1447 self
1448 }
1449
1450 #[must_use]
1452 pub fn with_meta(mut self, meta: Metadata) -> Self {
1453 self.meta = meta;
1454 self
1455 }
1456}
1457
1458impl fmt::Display for Custom {
1459 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1460 write!(
1461 f,
1462 "{} custom \"{}\"",
1463 self.date,
1464 crate::format::escape_string(&self.custom_type)
1465 )?;
1466 for value in &self.values {
1467 write!(f, " {value}")?;
1468 }
1469 Ok(())
1470 }
1471}
1472
1473impl fmt::Display for Directive {
1474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1475 match self {
1476 Self::Transaction(t) => write!(f, "{t}"),
1477 Self::Balance(b) => write!(f, "{b}"),
1478 Self::Open(o) => write!(f, "{o}"),
1479 Self::Close(c) => write!(f, "{c}"),
1480 Self::Commodity(c) => write!(f, "{c}"),
1481 Self::Pad(p) => write!(f, "{p}"),
1482 Self::Event(e) => write!(f, "{e}"),
1483 Self::Query(q) => write!(f, "{q}"),
1484 Self::Note(n) => write!(f, "{n}"),
1485 Self::Document(d) => write!(f, "{d}"),
1486 Self::Price(p) => write!(f, "{p}"),
1487 Self::Custom(c) => write!(f, "{c}"),
1488 }
1489 }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495 use rust_decimal_macros::dec;
1496
1497 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1498 crate::naive_date(year, month, day).unwrap()
1499 }
1500
1501 #[cfg(feature = "rkyv")]
1514 #[test]
1515 fn meta_value_archived_bytes_snapshot() {
1516 let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();
1517
1518 let cases: &[(&str, MetaValue)] = &[
1521 ("String", MetaValue::String("USD".to_string())),
1522 (
1523 "Account",
1524 MetaValue::Account(crate::Account::from("Assets:Bank")),
1525 ),
1526 (
1527 "Currency",
1528 MetaValue::Currency(crate::Currency::from("USD")),
1529 ),
1530 ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
1531 ("Link", MetaValue::Link(crate::Link::from("t"))),
1532 ("Date", MetaValue::Date(date(2024, 1, 15))),
1533 ("Number", MetaValue::Number(dec!(42))),
1534 ("Bool", MetaValue::Bool(true)),
1535 ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
1536 ("None", MetaValue::None),
1537 ("Int", MetaValue::Int(42)),
1538 ];
1539
1540 let archived: Vec<(&str, Vec<u8>)> =
1541 cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();
1542
1543 for (name, bytes) in &archived {
1544 assert!(
1545 !bytes.is_empty(),
1546 "MetaValue::{name} archived to empty bytes"
1547 );
1548 }
1549 for (i, (na, a)) in archived.iter().enumerate() {
1550 for (nb, b) in archived.iter().skip(i + 1) {
1551 assert_ne!(
1552 a, b,
1553 "MetaValue::{na} and MetaValue::{nb} archive identically — a \
1554 discriminant collision (variant reorder?) the cache can't tell apart"
1555 );
1556 }
1557 }
1558 }
1559
1560 #[test]
1561 fn test_transaction() {
1562 let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1563 .with_payee("Whole Foods")
1564 .with_flag('*')
1565 .with_tag("food")
1566 .with_synthesized_posting(Posting::new(
1567 "Expenses:Food",
1568 Amount::new(dec!(50.00), "USD"),
1569 ))
1570 .with_synthesized_posting(Posting::auto("Assets:Checking"));
1571
1572 assert_eq!(txn.flag, '*');
1573 assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1574 assert_eq!(txn.postings.len(), 2);
1575 assert!(txn.is_complete());
1576 }
1577
1578 #[test]
1579 fn test_balance() {
1580 let bal = Balance::new(
1581 date(2024, 1, 1),
1582 "Assets:Checking",
1583 Amount::new(dec!(1000.00), "USD"),
1584 );
1585
1586 assert_eq!(bal.account, "Assets:Checking");
1587 assert_eq!(bal.amount.number, dec!(1000.00));
1588 }
1589
1590 #[test]
1591 fn test_open() {
1592 let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1593 .with_currencies(vec!["USD".into()])
1594 .with_booking("FIFO");
1595
1596 assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1597 assert_eq!(open.booking, Some("FIFO".to_string()));
1598 }
1599
1600 #[test]
1601 fn test_directive_date() {
1602 let txn = Transaction::new(date(2024, 1, 15), "Test");
1603 let dir = Directive::Transaction(txn);
1604
1605 assert_eq!(dir.date(), date(2024, 1, 15));
1606 assert!(dir.is_transaction());
1607 assert_eq!(dir.type_name(), "transaction");
1608 }
1609
1610 #[test]
1611 fn test_posting_display() {
1612 let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1613 let s = format!("{posting}");
1614 assert!(s.contains("Assets:Checking"));
1615 assert!(s.contains("100.00 USD"));
1616 }
1617
1618 #[test]
1619 fn test_transaction_display() {
1620 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1621 .with_payee("Test Payee")
1622 .with_synthesized_posting(Posting::new(
1623 "Expenses:Test",
1624 Amount::new(dec!(50.00), "USD"),
1625 ))
1626 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1627
1628 let s = format!("{txn}");
1629 assert!(s.contains("2024-01-15"));
1630 assert!(s.contains("Test Payee"));
1631 assert!(s.contains("Test transaction"));
1632 }
1633
1634 #[test]
1635 fn test_directive_priority() {
1636 assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1638 assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1639 assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1640 assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1641 assert!(DirectivePriority::Price < DirectivePriority::Close);
1642 }
1643
1644 #[test]
1645 fn test_sort_directives_by_date() {
1646 let mut directives = vec![
1647 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1648 Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1649 Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1650 ];
1651
1652 sort_directives(&mut directives);
1653
1654 assert_eq!(directives[0].date(), date(2024, 1, 1));
1655 assert_eq!(directives[1].date(), date(2024, 1, 10));
1656 assert_eq!(directives[2].date(), date(2024, 1, 15));
1657 }
1658
1659 #[test]
1660 fn test_sort_directives_by_type_same_date() {
1661 let mut directives = vec![
1663 Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1664 Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1665 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1666 Directive::Balance(Balance::new(
1667 date(2024, 1, 1),
1668 "Assets:Bank",
1669 Amount::new(dec!(0), "USD"),
1670 )),
1671 ];
1672
1673 sort_directives(&mut directives);
1674
1675 assert_eq!(directives[0].type_name(), "open");
1676 assert_eq!(directives[1].type_name(), "balance");
1677 assert_eq!(directives[2].type_name(), "transaction");
1678 assert_eq!(directives[3].type_name(), "close");
1679 }
1680
1681 #[test]
1682 fn test_sort_directives_pad_before_balance() {
1683 let mut directives = vec![
1685 Directive::Balance(Balance::new(
1686 date(2024, 1, 1),
1687 "Assets:Bank",
1688 Amount::new(dec!(1000), "USD"),
1689 )),
1690 Directive::Pad(Pad::new(
1691 date(2024, 1, 1),
1692 "Assets:Bank",
1693 "Equity:Opening-Balances",
1694 )),
1695 ];
1696
1697 sort_directives(&mut directives);
1698
1699 assert_eq!(directives[0].type_name(), "pad");
1700 assert_eq!(directives[1].type_name(), "balance");
1701 }
1702
1703 #[test]
1704 fn test_sort_augmentations_before_reductions_same_date() {
1705 let reduction = Directive::Transaction(
1709 Transaction::new(date(2024, 9, 1), "Transfer Received")
1710 .with_synthesized_posting(
1711 Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1712 CostSpec::empty()
1713 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1714 .with_currency("EUR"),
1715 ),
1716 )
1717 .with_synthesized_posting(
1718 Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1719 CostSpec::empty()
1720 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1721 .with_currency("EUR"),
1722 ),
1723 ),
1724 );
1725
1726 let augmentation = Directive::Transaction(
1727 Transaction::new(date(2024, 9, 1), "Transfer Sent")
1728 .with_synthesized_posting(Posting::new(
1729 "Assets:AccountA",
1730 Amount::new(dec!(-10.00), "EUR"),
1731 ))
1732 .with_synthesized_posting(
1733 Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1734 CostSpec::empty()
1735 .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1736 .with_currency("EUR"),
1737 ),
1738 ),
1739 );
1740
1741 let mut directives = vec![reduction, augmentation];
1743 sort_directives(&mut directives);
1744
1745 assert!(
1747 !directives[0].has_cost_reduction(),
1748 "first directive should be augmentation"
1749 );
1750 assert!(
1751 directives[1].has_cost_reduction(),
1752 "second directive should be reduction"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_has_cost_reduction() {
1758 let reduction = Directive::Transaction(
1760 Transaction::new(date(2024, 1, 1), "Sell")
1761 .with_synthesized_posting(
1762 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
1763 CostSpec::empty()
1764 .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1765 .with_currency("USD"),
1766 ),
1767 )
1768 .with_synthesized_posting(Posting::new(
1769 "Assets:Cash",
1770 Amount::new(dec!(1500), "USD"),
1771 )),
1772 );
1773 assert!(reduction.has_cost_reduction());
1774
1775 let augmentation = Directive::Transaction(
1777 Transaction::new(date(2024, 1, 1), "Buy")
1778 .with_synthesized_posting(
1779 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1780 CostSpec::empty()
1781 .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1782 .with_currency("USD"),
1783 ),
1784 )
1785 .with_synthesized_posting(Posting::new(
1786 "Assets:Cash",
1787 Amount::new(dec!(-1500), "USD"),
1788 )),
1789 );
1790 assert!(!augmentation.has_cost_reduction());
1791
1792 let simple = Directive::Transaction(
1794 Transaction::new(date(2024, 1, 1), "Payment")
1795 .with_synthesized_posting(Posting::new(
1796 "Expenses:Food",
1797 Amount::new(dec!(50), "USD"),
1798 ))
1799 .with_synthesized_posting(Posting::new(
1800 "Assets:Cash",
1801 Amount::new(dec!(-50), "USD"),
1802 )),
1803 );
1804 assert!(!simple.has_cost_reduction());
1805 }
1806
1807 #[test]
1808 fn test_transaction_flags() {
1809 let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1810
1811 assert!(make_txn('*').is_complete());
1813 assert!(make_txn('!').is_incomplete());
1814 assert!(make_txn('!').is_pending());
1815
1816 assert!(make_txn('S').is_summarization());
1818 assert!(make_txn('T').is_transfer());
1819 assert!(make_txn('C').is_conversion());
1820 assert!(make_txn('U').is_unrealized());
1821 assert!(make_txn('R').is_return());
1822 assert!(make_txn('M').is_merge());
1823 assert!(make_txn('#').is_bookmarked());
1824 assert!(make_txn('?').needs_investigation());
1825
1826 assert!(!make_txn('*').is_pending());
1828 assert!(!make_txn('!').is_complete());
1829 }
1830
1831 #[test]
1832 fn test_is_valid_flag() {
1833 for flag in [
1835 '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1836 ] {
1837 assert!(
1838 Transaction::is_valid_flag(flag),
1839 "Flag '{flag}' should be valid"
1840 );
1841 }
1842
1843 for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1845 assert!(
1846 !Transaction::is_valid_flag(flag),
1847 "Flag '{flag}' should be invalid"
1848 );
1849 }
1850 }
1851
1852 #[test]
1853 fn test_transaction_display_includes_metadata() {
1854 let mut meta = Metadata::default();
1855 meta.insert(
1856 "document".to_string(),
1857 MetaValue::String("myfile.pdf".to_string()),
1858 );
1859
1860 let txn = Transaction {
1861 date: date(2026, 2, 23),
1862 flag: '*',
1863 payee: None,
1864 narration: "Example".into(),
1865 tags: vec![],
1866 links: vec![],
1867 meta,
1868 postings: vec![
1869 crate::Spanned::synthesized(Posting::new(
1870 "Assets:Bank",
1871 Amount::new(dec!(-2), "USD"),
1872 )),
1873 crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1874 ],
1875 trailing_comments: Vec::new(),
1876 };
1877
1878 let output = txn.to_string();
1879 assert!(
1880 output.contains("document: \"myfile.pdf\""),
1881 "Transaction Display should include metadata: {output}"
1882 );
1883 assert!(
1884 output.contains("Assets:Bank"),
1885 "Transaction Display should include postings: {output}"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_posting_display_includes_metadata() {
1891 let mut meta = Metadata::default();
1892 meta.insert(
1893 "category".to_string(),
1894 MetaValue::String("groceries".to_string()),
1895 );
1896
1897 let posting = Posting {
1898 account: "Expenses:Food".into(),
1899 units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1900 cost: None,
1901 price: None,
1902 flag: None,
1903 meta,
1904 comments: Vec::new(),
1905 trailing_comments: Vec::new(),
1906 };
1907
1908 let output = posting.to_string();
1909 assert!(
1910 output.contains("category: \"groceries\""),
1911 "Posting Display should include metadata: {output}"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_directive_display() {
1917 let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1919 let dir = Directive::Transaction(txn.clone());
1920
1921 assert_eq!(format!("{dir}"), format!("{txn}"));
1923
1924 let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1926 let dir_open = Directive::Open(open.clone());
1927 assert_eq!(format!("{dir_open}"), format!("{open}"));
1928
1929 let balance = Balance::new(
1930 date(2024, 1, 1),
1931 "Assets:Bank",
1932 Amount::new(dec!(100), "USD"),
1933 );
1934 let dir_balance = Directive::Balance(balance.clone());
1935 assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1936 }
1937
1938 #[test]
1941 fn parse_precision_meta_accepts_non_negative_integers() {
1942 assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
1945 assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
1946 assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
1947 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1948 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1949 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1950 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1954 assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1955 }
1956
1957 #[test]
1958 fn parse_precision_meta_rejects_negatives() {
1959 let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
1960 assert!(err.contains("non-negative"), "got: {err}");
1961 let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
1962 assert!(err.contains("non-negative"), "got: {err}");
1963 }
1964
1965 #[test]
1966 fn parse_precision_meta_rejects_fractional() {
1967 let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
1968 assert!(err.contains("integer"), "got: {err}");
1969 }
1970
1971 #[test]
1972 fn parse_precision_meta_rejects_overflow() {
1973 let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
1975 assert!(err.contains("exceeds"), "got: {err}");
1976 let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
1977 assert!(err.contains("exceeds"), "got: {err}");
1978 }
1979
1980 #[test]
1981 fn meta_value_int_display_and_kind() {
1982 assert_eq!(MetaValue::Int(42).to_string(), "42");
1983 assert_eq!(MetaValue::Int(-7).to_string(), "-7");
1984 assert_eq!(crate::format::format_meta_value(&MetaValue::Int(42)), "42");
1985 assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
1986 }
1987
1988 #[test]
1989 fn parse_precision_meta_rejects_non_number_variants() {
1990 use crate::Amount;
1995 use rust_decimal_macros::dec;
1996 let cases = [
1997 (MetaValue::String("2".into()), "string"),
1998 (MetaValue::Account("Assets:Cash".into()), "account"),
1999 (MetaValue::Currency("USD".into()), "currency"),
2000 (MetaValue::Tag("foo".into()), "tag"),
2001 (MetaValue::Link("bar".into()), "link"),
2002 (MetaValue::Date(date(2024, 1, 1)), "date"),
2003 (MetaValue::Bool(true), "bool"),
2004 (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
2005 (MetaValue::None, "none"),
2006 ];
2007 for (case, kind) in cases {
2008 let err = match parse_precision_meta(&case) {
2009 Ok(_) => panic!("should have rejected {case:?}"),
2010 Err(e) => e,
2011 };
2012 assert!(
2013 err.contains(kind),
2014 "error for {case:?} should mention kind {kind:?}, got: {err}"
2015 );
2016 }
2017 }
2018}