Browse Source

Implement simple transaction inference.

Kestrel 2 weeks ago
parent
commit
bd509eb63d
6 changed files with 116 additions and 9 deletions
  1. 16 2
      src/cmd.rs
  2. 76 0
      src/cmd/infer.rs
  3. 4 0
      src/data.rs
  4. 2 0
      src/data/spec.rs
  5. 7 7
      src/import.rs
  6. 11 0
      src/show.rs

+ 16 - 2
src/cmd.rs

@@ -4,6 +4,8 @@ use itertools::Itertools;
 
 use crate::{check::CheckLevel, data, import::import_from, io, show};
 
+mod infer;
+
 #[derive(clap::Parser)]
 pub struct Invocation {
     /// path to treasure file
@@ -51,6 +53,7 @@ pub enum Command {
         from: std::path::PathBuf,
         target: Option<std::path::PathBuf>,
     },
+    Infer,
     Match {
         account: data::AccountName
     },
@@ -157,7 +160,9 @@ impl Command {
                     return Ok(());
                 };
 
-                let imported = import_from(aspec, aname, from.as_path()).unwrap();
+                let default_destination = data.spec_root().placeholder_account;
+
+                let imported = import_from(aspec, aname, default_destination, from.as_path()).unwrap();
                 log::info!("Imported {} transaction(s)", imported.len());
 
                 if let Some(target) = target {
@@ -182,6 +187,13 @@ impl Command {
                     tt.show(Some(&data), aname, imported.iter());
                 }
             }
+            Self::Infer => {
+                let mut data = load_data(&mut fsdata, inv, CheckLevel::WellFormed)?;
+
+                if infer::do_inference(&mut data)? {
+                    data::ledger::print_ledger(&mut fsdata, &data, data.all_ledger_data().iter())?;
+                }
+            }
             Self::Match { account } => {
                 let data = load_data(&mut fsdata, inv, CheckLevel::WellFormed)?;
 
@@ -190,9 +202,11 @@ impl Command {
                     return Ok(())
                 };
 
+                let txs = acc_data.iter().filter(|v| v.get_annotation("id").is_some()).sorted().collect_vec();
 
+                
 
-                show::TransactionTable::default().show(Some(&data), *account, acc_data.iter().map(|d| &d.0));
+                show::TransactionTable::default().show(Some(&data), *account, txs.iter().map(|v| &v.0));
             }
         }
         Ok(())

+ 76 - 0
src/cmd/infer.rs

@@ -0,0 +1,76 @@
+use crate::data::Root;
+use crate::show::show_transaction;
+
+pub fn do_inference(data: &mut Root) -> anyhow::Result<bool> {
+    let placeholder = data.spec_root().placeholder_account;
+
+    let num_txns = data.ledger_data_for(placeholder).unwrap().len();
+
+    let mut any_changed = false;
+
+    for idx in 0..num_txns {
+        let txns = data.ledger_data_for(placeholder).unwrap();
+        let txn = &txns[idx];
+        let txn_title = txn.title.clone();
+
+        log::trace!("Considering transaction");
+        let (_, mut chg) = txn.split_changes(placeholder).unwrap();
+
+        let complementary_change = chg.next().unwrap().clone();
+
+        if chg.next().is_some() {
+            log::debug!("Skipping transaction with more than two changes");
+            continue
+        }
+
+        let other_account = *complementary_change.account;
+        let Some(other_txns) = data.ledger_data_for(other_account) else {
+            // this should never happen
+            panic!("Account {other_account} has no transaction data!");
+        };
+
+        drop(chg);
+
+        for other_txn in other_txns {
+            if other_txn.title.is_none() || other_txn.title != txn_title || other_txn == txn {
+                continue
+            }
+
+            // get other_txn's other
+            let (_, mut chg) = other_txn.split_changes(other_account).unwrap();
+            let candidate = chg.next().unwrap();
+            if chg.next().is_some() || *candidate.account == placeholder {
+                continue
+            }
+            log::debug!("possible candidate {}, titles are {:?} and {:?}", candidate.account, other_txn.title, txn.title);
+
+            show_transaction(None, txn.as_ref());
+
+            println!("Candidate account: {} (Y/n)", console::style(candidate.account).red());
+            let answer = console::Term::stdout().read_char().unwrap();
+            match answer.to_lowercase().next() {
+                Some('y') | Some('\n') | Some('\r') => {
+                    let new_account = candidate.account.0.clone();
+                    println!("    Changing to account {} ...", console::style(new_account).green());
+
+                    drop(chg);
+
+                    data.ledger_data_for_mut(placeholder).unwrap()[idx].changes.iter_mut().for_each(|c| {
+                        log::info!("change account is {}, placeholder is {}", c.account, placeholder);
+                        if *c.account == placeholder {
+                            c.account = new_account.into();
+                            log::info!("    changed account to {new_account}");
+                        }
+                    });
+                    any_changed = true;
+                    
+                    break
+                }
+                | Some('n') => (),
+                c => println!("unknown {c:?}"),
+            }
+        }
+    }
+
+    Ok(any_changed)
+}

+ 4 - 0
src/data.rs

@@ -339,6 +339,10 @@ impl Root {
         self.account_ledger_data.get(&aname).map(Vec::as_slice)
     }
 
+    pub fn ledger_data_for_mut(&mut self, aname: AccountName) -> Option<&mut [Spanned<ledger::Transaction>]> {
+        self.account_ledger_data.get_mut(&aname).map(Vec::as_mut_slice)
+    }
+
     pub fn ledger_data_from(
         &self,
         source: SourceFile,

+ 2 - 0
src/data/spec.rs

@@ -57,5 +57,7 @@ pub struct SpecRoot {
 
     pub units: HashMap<UnitName, UnitSpec>,
 
+    pub placeholder_account: AccountName,
+
     pub accounts: HashMap<AccountName, AccountSpec>,
 }

+ 7 - 7
src/import.rs

@@ -51,6 +51,7 @@ fn try_parse_decimal(from: &str) -> Result<Decimal, ImportError> {
 fn import_from_csv(
     csv_spec: &CsvImportSpec,
     aspec: &AccountSpec,
+    account: AccountName,
     target: AccountName,
     reader: impl std::io::Read,
 ) -> Result<Vec<Transaction>, ImportError> {
@@ -76,8 +77,6 @@ fn import_from_csv(
     let date_format = Box::leak(csv_spec.date_format.clone().into_boxed_str());
     let date_parser = strptime::Parser::new(date_format);
 
-    let unbalanced = AccountName::new("unbalanced");
-
     let mut txns = vec![];
 
     for record in csv_reader.records() {
@@ -144,14 +143,14 @@ fn import_from_csv(
             annotations: vec![],
             changes: vec![
                 Change {
-                    account: target.into(),
+                    account: account.into(),
                     amount: txn_change.unwrap().into(),
                     balance: txn_balance.map(Into::into),
                     unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
                 }
                 .into(),
                 Change {
-                    account: unbalanced.into(),
+                    account: target.into(),
                     amount: Decimal::ZERO
                         .checked_sub(txn_change.unwrap())
                         .unwrap()
@@ -239,19 +238,20 @@ fn postprocess(account: AccountName, transactions: &mut Vec<Transaction>) {
 
 pub fn import_from(
     aspec: &AccountSpec,
+    account: AccountName,
     target: AccountName,
     path: &std::path::Path,
 ) -> Result<Vec<Transaction>, ImportError> {
     let reader = std::fs::File::open(path)?;
 
     let mut output = match &aspec.import {
-        Some(ImportFileFormat::Csv(csv)) => import_from_csv(csv, aspec, target, reader),
+        Some(ImportFileFormat::Csv(csv)) => import_from_csv(csv, aspec, account, target, reader),
         None => Err(ImportError::ConfigError(format!(
-            "no import configuration for {target}"
+            "no import configuration for {account}"
         ))),
     }?;
 
-    postprocess(target, &mut output);
+    postprocess(account, &mut output);
 
     Ok(output)
 }

+ 11 - 0
src/show.rs

@@ -73,6 +73,17 @@ fn show_table(cols: Vec<Column>, rows: impl Iterator<Item = Row>) {
     }
 }
 
+pub fn show_transaction(_root: Option<&Root>, txn: &Transaction) {
+    let bluestyle = console::Style::new().blue();
+    // let greenstyle = console::Style::new().green();
+    let yellowstyle = console::Style::new().yellow();
+    let graystyle = console::Style::new().white().dim();
+    println!("{}: {}", bluestyle.apply_to(txn.datestamp), txn.title.as_deref().unwrap_or(""));
+    for change in &txn.changes {
+        println!(" - {}: {} {}", yellowstyle.apply_to(change.account), change.amount, graystyle.apply_to(change.unit));
+    }
+}
+
 #[derive(Clone, Copy, Default)]
 pub struct TransactionTable {}