Jelajahi Sumber

Add per-change datestamps.

Kestrel 1 Minggu lalu
induk
melakukan
391740a2d3
8 mengubah file dengan 155 tambahan dan 59 penghapusan
  1. 86 45
      src/check.rs
  2. 14 2
      src/cmd/txnmatch.rs
  3. 13 7
      src/data.rs
  4. 12 3
      src/data/format.rs
  5. 10 2
      src/data/parse.rs
  6. 2 0
      src/import.rs
  7. 7 0
      testdata/merge-ds/ledger
  8. 11 0
      testdata/merge-ds/root.toml

+ 86 - 45
src/check.rs

@@ -144,19 +144,20 @@ fn check_balance_group<'a>(
     let first = group[0].borrow();
     let report = ariadne::Report::build(
         ariadne::ReportKind::Error,
-        first.source.unwrap().union(first.change_for(account).unwrap().source.unwrap()),
-    )
-    .with_message("No valid ordering of transactions to satisfy balance assertions")
-    .with_labels(
-        group
-            .iter()
-            .map(|txn| {
-                let txn = txn.borrow();
-                let span = txn.source.unwrap().union(txn.change_for(account).unwrap().source.unwrap());
-                ariadne::Label::new(span)
-                    .with_message("transaction in group")
-            }),
+        first
+            .source
+            .unwrap()
+            .union(first.change_for(account).unwrap().source.unwrap()),
     )
+    .with_message(format!("No valid ordering of transactions to satisfy balance assertions in {account}"))
+    .with_labels(group.iter().map(|txn| {
+        let txn = txn.borrow();
+        let span = txn
+            .source
+            .unwrap()
+            .union(txn.change_for(account).unwrap().source.unwrap());
+        ariadne::Label::new(span).with_message("transaction in group")
+    }))
     .finish();
 
     Err(DataError::Report(Box::new(report)))
@@ -171,7 +172,7 @@ fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
         };
 
         let mut running_balance = BTreeMap::<data::UnitName, Spanned<data::Decimal>>::new();
-        let date_groups = ledger.iter().chunk_by(|tx| tx.borrow().datestamp);
+        let date_groups = ledger.iter().chunk_by(|tx| tx.borrow().datestamp_for(account));
 
         for group in date_groups.into_iter() {
             check_balance_group(account, &mut running_balance, group.1)?;
@@ -183,6 +184,15 @@ fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
 fn check_merge_groups(root: &data::Hoard) -> Result<(), DataError> {
     log::trace!("Checking merge groups...");
 
+    let change_signature = |txn: &data::Transaction, c: &data::Change| {
+        let mut span = c.account.span().union(c.amount.span()).union(c.unit.span());
+        if let Some(ds) = c.datestamp {
+            span = span.union(ds.span());
+        }
+
+        (Spanned::new((), span), c.account.0, c.amount.0, c.datestamp.map(|d| d.0).unwrap_or(txn.datestamp))
+    };
+
     for account in root.account_names() {
         let Some(ledger) = root.ledger_data_for(account) else {
             continue;
@@ -190,48 +200,79 @@ fn check_merge_groups(root: &data::Hoard) -> Result<(), DataError> {
 
         for txn_ref in ledger {
             let txn = txn_ref.borrow();
-            let Some(group_id) = txn.merge_group() else { continue };
+            let Some(group_id) = txn.merge_group() else {
+                continue;
+            };
             let Some(group) = root.merge_group(group_id) else {
                 todo!()
             };
 
-            let txn_changes = txn.changes.iter().map(|c| (c.account, c.amount, c.unit)).sorted().collect_vec();
+            let txn_changes = txn
+                .changes
+                .iter()
+                .map(|c| change_signature(&txn, c))
+                .sorted()
+                .collect_vec();
 
             for placeholder_ref in group {
                 let placeholder = placeholder_ref.borrow();
 
-                if txn.datestamp != placeholder.datestamp {
-                    let report = ariadne::Report::build(
-                        ariadne::ReportKind::Error,
-                        txn.source.unwrap()
-                    )
-                    .with_label(ariadne::Label::new(txn.source.unwrap()).with_message("this transaction"))
-                    .with_label(ariadne::Label::new(placeholder.source.unwrap()).with_message("and this transaction"))
-                    .with_message("different datestamps despite being in one merge group")
-                    .finish();
-
-                    return Err(report.into())
+                if txn.datestamp_for(account) != placeholder.datestamp_for(account) {
+                    let report =
+                        ariadne::Report::build(ariadne::ReportKind::Error, txn.source.unwrap())
+                            .with_label(
+                                ariadne::Label::new(txn.source.unwrap())
+                                    .with_message("this transaction"),
+                            )
+                            .with_label(
+                                ariadne::Label::new(placeholder.source.unwrap())
+                                    .with_message("and this transaction"),
+                            )
+                            .with_message("different datestamps despite being in one merge group")
+                            .finish();
+
+                    return Err(report.into());
                 }
 
-                let placeholder_changes = placeholder.changes.iter().map(|c| (c.account, c.amount, c.unit)).sorted().collect_vec();
-
-                let mut different_changes = txn_changes.iter().zip(placeholder_changes.iter()).filter(|(t,p)| t != p);
-
-                if let Some((t,p)) = different_changes.next() {
-                    let tspan = t.0.span().union(t.1.span()).union(t.2.span());
-                    let pspan = p.0.span().union(p.1.span()).union(p.2.span());
-
-                    let report = ariadne::Report::build(
-                        ariadne::ReportKind::Error,
-                        txn.source.unwrap()
-                    )
-                    .with_label(ariadne::Label::new(txn.source.unwrap()).with_message("in this transaction"))
-                    .with_label(ariadne::Label::new(tspan).with_message("this change differs from"))
-                    .with_label(ariadne::Label::new(placeholder.source.unwrap()).with_message("in this transaction"))
-                    .with_label(ariadne::Label::new(pspan).with_message("this change"))
-                    .with_message("merged transactions differ in at least one change")
-                    .finish();
-                    return Err(report.into())
+                let placeholder_changes = placeholder
+                    .changes
+                    .iter()
+                    .map(|c| change_signature(&placeholder, c))
+                    .sorted()
+                    .collect_vec();
+
+                let mut different_changes = txn_changes
+                    .iter()
+                    .zip(placeholder_changes.iter())
+                    .filter(|(t, p)| t != p);
+
+                if let Some((t, p)) = different_changes.next() {
+                    let tspan = t.0.span();
+                    let pspan = p.0.span();
+
+                    println!("t: {t:?}");
+                    println!("p: {p:?}");
+
+                    println!("txn: {txn:#?}");
+                    println!("placeholder: {placeholder:#?}");
+
+                    let report =
+                        ariadne::Report::build(ariadne::ReportKind::Error, txn.source.unwrap())
+                            .with_label(
+                                ariadne::Label::new(txn.source.unwrap())
+                                    .with_message("in this transaction"),
+                            )
+                            .with_label(
+                                ariadne::Label::new(tspan).with_message("this change differs from"),
+                            )
+                            .with_label(
+                                ariadne::Label::new(placeholder.source.unwrap())
+                                    .with_message("in this transaction"),
+                            )
+                            .with_label(ariadne::Label::new(pspan).with_message("this change"))
+                            .with_message("merged transactions differ in at least one change")
+                            .finish();
+                    return Err(report.into());
                 }
             }
         }

+ 14 - 2
src/cmd/txnmatch.rs

@@ -17,9 +17,13 @@ fn do_merge(
     assert_eq!(txn1.changes.len(), 2);
     assert_eq!(txn2.changes.len(), 2);
 
+    let t1ds = txn1.datestamp;
+    let t2ds = txn2.datestamp;
+
     let (change1, mut rest1) = txn1.split_changes_mut(common).unwrap();
     let (change2, mut rest2) = txn2.split_changes_mut(common).unwrap();
 
+
     let other1 = rest1.next().unwrap();
     let other2 = rest2.next().unwrap();
 
@@ -31,13 +35,21 @@ fn do_merge(
     change2.account = other1.account;
     change2.balance = other1.balance;
 
+    if t1ds != t2ds {
+        change1.datestamp = Some(t2ds.into());
+        change2.datestamp = Some(t1ds.into());
+    }
+
     // generate unused ID
     let merge_id = loop {
         let candidate = nanoid::nanoid!(10);
-        if !data.has_merge_group(&candidate) { break candidate }
+        if !data.has_merge_group(&candidate) {
+            break candidate;
+        }
     };
 
-    txn1.annotations.insert("merge".to_string(), merge_id.clone());
+    txn1.annotations
+        .insert("merge".to_string(), merge_id.clone());
     txn2.annotations.insert("merge".to_string(), merge_id);
 }
 

+ 13 - 7
src/data.rs

@@ -38,6 +38,7 @@ pub struct Change {
     pub amount: Spanned<Decimal>,
     pub balance: Option<Spanned<Decimal>>,
     pub unit: Spanned<UnitName>,
+    pub datestamp: Option<Spanned<Datestamp>>,
 }
 
 #[derive(Clone, Debug)]
@@ -65,6 +66,12 @@ impl Transaction {
             .find(|b| b.account.as_ref() == &account)
     }
 
+    pub fn datestamp_for(&self, account: AccountName) -> Datestamp {
+        self.change_for(account)
+            .and_then(|c| c.datestamp.and_then(|d| Some(d.0)))
+            .unwrap_or(self.datestamp)
+    }
+
     pub fn split_changes(
         &self,
         account: AccountName,
@@ -225,7 +232,7 @@ fn merge_balances(into_ref: &TransactionRef, from_ref: &TransactionRef) {
     for change in from_ref.borrow().changes.iter() {
         let Some(into_change) = into.change_for_mut(*change.account) else {
             log::error!("Merged transactions have different accounts. Skipping ...");
-            continue
+            continue;
         };
         let Some(bal) = change.balance else { continue };
         into_change.balance.get_or_insert(bal);
@@ -367,15 +374,14 @@ impl Hoard {
                     Entry::Vacant(e) => {
                         group_leads.insert(merge_group.to_string(), txn_ref.clone());
                         e.insert(vec![]);
-                    },
+                    }
                     Entry::Occupied(mut e) => {
                         e.get_mut().push(txn_ref.clone());
                         drop(e);
-                        log::info!("merging balances into representative txn");
                         let lead = group_leads.get(merge_group).unwrap();
                         merge_balances(lead, &txn_ref);
-                        continue
-                    },
+                        continue;
+                    }
                 }
             }
 
@@ -388,8 +394,8 @@ impl Hoard {
         }
 
         self.account_ledger_data
-            .values_mut()
-            .for_each(|v| v.sort_by_key(|tx| tx.borrow().datestamp));
+            .iter_mut()
+            .for_each(|(k, v)| v.sort_by_key(|tx| tx.borrow().datestamp_for(*k)));
     }
 
     pub fn all_raw_ledger_data(&self) -> &[RawLedgerEntry] {

+ 12 - 3
src/data/format.rs

@@ -46,18 +46,24 @@ fn format_transaction(
             String::new()
         };
 
+        let ds = if let Some(ds) = change.datestamp {
+            format!(" @{ds}")
+        } else {
+            String::new()
+        };
+
         if Some(*change.unit) == root.account_spec(*change.account).and_then(|s| s.unit)
             && !force_show
         {
             writeln!(
                 target,
-                " - {:padding$}: {spacer}{}{balance}",
+                " - {:padding$}: {spacer}{}{balance}{ds}",
                 change.account, change.amount,
             )?;
         } else {
             writeln!(
                 target,
-                " - {:padding$}: {spacer}{}{balance} {}",
+                " - {:padding$}: {spacer}{}{balance} {}{ds}",
                 change.account, change.amount, change.unit,
             )?;
         }
@@ -144,7 +150,10 @@ pub fn format_entire_ledger<'l>(
                 .map(|v| RawLedgerEntry::Comment(v.clone())),
         )
         .chain(
-            root.groups.values().flatten().map(|v| RawLedgerEntry::Transaction(v.borrow().clone())),
+            root.groups
+                .values()
+                .flatten()
+                .map(|v| RawLedgerEntry::Transaction(v.borrow().clone())),
         )
         .collect_vec();
 

+ 10 - 2
src/data/parse.rs

@@ -69,13 +69,20 @@ fn ledger_parser<'a>() -> impl Parser<
             inline_whitespace()
                 .at_least(1)
                 .ignore_then(chumsky::text::ident())
-                .then_ignore(inline_whitespace())
                 .map_with(|u, e| Some(Spanned(UnitName::new(u), e.span()))),
             inline_whitespace().map(|_| None),
+        )),
+        choice((
+            inline_whitespace()
+                .at_least(1)
+                .ignore_then(mark('@').ignore_then(datestamp))
+                .map_with(|ds, e| Some(Spanned(ds, e.span()))),
+            inline_whitespace().map(|_| None)
         ))
+        .then_ignore(inline_whitespace().at_least(0))
         .then_ignore(chumsky::text::newline()),
     ))
-    .try_map_with(|(_, acc, _, amount, balance, unit, ), e| {
+    .try_map_with(|(_, acc, _, amount, balance, unit, datestamp), e| {
         let span = e.span();
         let spec: &mut chumsky::extra::SimpleState<&SpecRoot> = e.state();
 
@@ -108,6 +115,7 @@ fn ledger_parser<'a>() -> impl Parser<
             balance,
             unit,
             source: Some(span),
+            datestamp,
         })
     });
 

+ 2 - 0
src/import.rs

@@ -147,6 +147,7 @@ fn import_from_csv(
                     balance: txn_balance.map(Into::into),
                     unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
                     source: None,
+                    datestamp: None,
                 },
                 data::Change {
                     account: transaction_target.into(),
@@ -157,6 +158,7 @@ fn import_from_csv(
                     balance: None,
                     unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
                     source: None,
+                    datestamp: None,
                 },
             ],
             source: None,

+ 7 - 0
testdata/merge-ds/ledger

@@ -0,0 +1,7 @@
+2000-01-01: Funds Withdrawal from A
+- a: -100 CAD
+- unbalanced: 100 CAD
+
+2000-01-02: Funds Deposit into B
+- b: 100 CAD
+- unbalanced: -100 CAD

+ 11 - 0
testdata/merge-ds/root.toml

@@ -0,0 +1,11 @@
+ledger_path = "./ledger"
+
+placeholder_account = "unbalanced"
+
+[units]
+CAD = { name = "Canadian Dollar", precision = 2 }
+
+[accounts.unbalanced]
+[accounts.a]
+[accounts.b]
+