2 İşlemeler 304a3fdb6f ... 391740a2d3

Yazar SHA1 Mesaj Tarih
  Kestrel 391740a2d3 Add per-change datestamps. 1 hafta önce
  Kestrel d892bf2500 Implement merge groups. 1 hafta önce
9 değiştirilmiş dosya ile 204 ekleme ve 56 silme
  1. 108 42
      src/check.rs
  2. 4 2
      src/cmd.rs
  3. 16 2
      src/cmd/txnmatch.rs
  4. 34 5
      src/data.rs
  5. 12 3
      src/data/format.rs
  6. 10 2
      src/data/parse.rs
  7. 2 0
      src/import.rs
  8. 7 0
      testdata/merge-ds/ledger
  9. 11 0
      testdata/merge-ds/root.toml

+ 108 - 42
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,48 +172,112 @@ 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)?;
         }
+    }
+    Ok(())
+}
+
+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;
+        };
 
-        /*
         for txn_ref in ledger {
             let txn = txn_ref.borrow();
-            let change = txn.change_for(account).unwrap();
-            let bal = running_balance
-                .entry(*change.unit)
-                .or_insert_with(|| Spanned::new(data::Decimal::default(), io::Span::default()));
-            let last_span = bal.span();
-            bal.0 = bal.checked_add(*change.amount).unwrap();
-            bal.1 = change.source.unwrap();
+            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| change_signature(&txn, c))
+                .sorted()
+                .collect_vec();
+
+            for placeholder_ref in group {
+                let placeholder = placeholder_ref.borrow();
+
+                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();
 
-            if let Some(sbal) = change.balance.as_ref() {
-                if **sbal != bal.0 {
-                    let report = ariadne::Report::build(
-                        ariadne::ReportKind::Error,
-                        txn.source.unwrap_or_default(),
-                    )
-                    .with_label(ariadne::Label::new(sbal.span()).with_message(format!(
-                        "Calculated balance is {} {}, specified balance is {} {}",
-                        bal, change.unit, sbal, change.unit
-                    )));
-                    let report = if !last_span.is_empty() {
-                        report.with_label(
-                            ariadne::Label::new(last_span)
-                                .with_message("Last balance is from here"),
-                        )
-                    } else {
-                        report
-                    };
-
-                    return Err(report.finish().into());
+                    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());
                 }
             }
         }
-        */
     }
+
     Ok(())
 }
 
@@ -221,6 +286,7 @@ pub fn run_checks(root: &mut data::Hoard, level: CheckLevel) -> Result<(), DataE
         log::debug!("Running transaction well-formedness checks...");
         check_precision(root)?;
         check_equal_sum(root)?;
+        check_merge_groups(root)?;
     }
     if level >= CheckLevel::Consistent {
         log::debug!("Running ledger consistency checks...");

+ 4 - 2
src/cmd.rs

@@ -57,7 +57,7 @@ pub enum Command {
     },
     Infer,
     Match {
-        account: data::AccountName,
+        account: Option<data::AccountName>,
     },
     Comb {
         account: Option<data::AccountName>,
@@ -217,7 +217,9 @@ impl Command {
             Self::Match { account } => {
                 let mut data = load_data(&mut fsdata, inv, check::CheckLevel::WellFormed)?;
 
-                if txnmatch::do_match(&mut data, *account)? {
+                let account = account.unwrap_or(data.spec_root().placeholder_account);
+
+                if txnmatch::do_match(&mut data, account)? {
                     data::format_entire_ledger(&mut fsdata, &data)?;
                 }
             }

+ 16 - 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();
 
@@ -27,15 +31,25 @@ fn do_merge(
     drop(rest2);
 
     change1.account = other2.account;
+    change1.balance = other2.balance;
     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);
 }
 

+ 34 - 5
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,
@@ -219,6 +226,19 @@ impl LedgerEntry {
     }
 }
 
+fn merge_balances(into_ref: &TransactionRef, from_ref: &TransactionRef) {
+    // merge balance assertions
+    let mut into = into_ref.borrow_mut();
+    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;
+        };
+        let Some(bal) = change.balance else { continue };
+        into_change.balance.get_or_insert(bal);
+    }
+}
+
 #[derive(Debug)]
 pub struct Hoard {
     path: std::path::PathBuf,
@@ -336,6 +356,7 @@ impl Hoard {
     }
 
     fn preprocess_ledger_data(&mut self) {
+        let mut group_leads = BTreeMap::<String, TransactionRef>::new();
         for entry in &self.raw_ledger_data {
             let tx = match entry {
                 RawLedgerEntry::Transaction(tx) => tx,
@@ -351,12 +372,16 @@ impl Hoard {
                 use std::collections::btree_map::Entry;
                 match self.groups.entry(merge_group.to_string()) {
                     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());
-                        continue
-                    },
+                        drop(e);
+                        let lead = group_leads.get(merge_group).unwrap();
+                        merge_balances(lead, &txn_ref);
+                        continue;
+                    }
                 }
             }
 
@@ -369,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] {
@@ -430,6 +455,10 @@ impl Hoard {
         self.groups.contains_key(name)
     }
 
+    pub fn merge_group(&self, name: &str) -> Option<&Vec<TransactionRef>> {
+        self.groups.get(name)
+    }
+
     pub fn kill_transaction(&mut self, txn_ref: TransactionRef) {
         let txn = txn_ref.borrow();
         for account in txn.changes.iter().map(|v| *v.account).collect::<Vec<_>>() {

+ 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]
+