Sfoglia il codice sorgente

Reformat, implement ghost transactions.

Kestrel 2 settimane fa
parent
commit
56461a7bae
11 ha cambiato i file con 231 aggiunte e 138 eliminazioni
  1. 14 11
      src/check.rs
  2. 11 7
      src/cmd.rs
  3. 21 14
      src/cmd/comb.rs
  4. 19 14
      src/cmd/infer.rs
  5. 58 28
      src/cmd/txnmatch.rs
  6. 40 19
      src/data.rs
  7. 23 6
      src/data/format.rs
  8. 17 14
      src/data/parse.rs
  9. 4 4
      src/data/spec.rs
  10. 3 2
      src/import.rs
  11. 21 19
      src/show.rs

+ 14 - 11
src/check.rs

@@ -29,12 +29,12 @@ fn check_equal_sum(root: &data::Hoard) -> Result<(), DataError> {
             .iter()
             .try_fold(data::Decimal::ZERO, |acc, b| acc.checked_add(*b.amount));
         if net != Some(data::Decimal::ZERO) {
-            let report = ariadne::Report::build(ariadne::ReportKind::Error, tx.source.unwrap_or_default()).with_labels(
-                tx.changes.iter().map(|v| {
-                    let span = v.amount.span().union(v.unit.span());
-                    ariadne::Label::new(span).with_message("change here")
-                }),
-            );
+            let report =
+                ariadne::Report::build(ariadne::ReportKind::Error, tx.source.unwrap_or_default())
+                    .with_labels(tx.changes.iter().map(|v| {
+                        let span = v.amount.span().union(v.unit.span());
+                        ariadne::Label::new(span).with_message("change here")
+                    }));
 
             let report = if let Some(net) = net {
                 report
@@ -126,11 +126,14 @@ fn check_balances(root: &data::Hoard) -> Result<(), DataError> {
 
             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 = 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)

+ 11 - 7
src/cmd.rs

@@ -5,8 +5,8 @@ use itertools::Itertools;
 // use crate::{check::CheckLevel, data, import::import_from, io, show};
 use crate::prelude::*;
 
-mod infer;
 mod comb;
+mod infer;
 mod txnmatch;
 
 #[derive(clap::Parser)]
@@ -77,7 +77,11 @@ fn summarize(data: &data::Hoard) {
         if shortname.as_ref() == "initial" {
             continue;
         }
-        if let Some(balances) = data.balance(shortname) {
+        if let Some(mut balances) = data.balance(shortname) {
+            // insert a default balance of 0 if no balance is present
+            if let Some(unit) = data.account_spec(shortname).unwrap().unit {
+                balances.entry(unit).or_default();
+            }
             let balances = balances
                 .into_iter()
                 .filter(|(_, b)| !b.is_zero())
@@ -86,11 +90,11 @@ fn summarize(data: &data::Hoard) {
                     (
                         u,
                         if b.is_sign_positive() {
-                            positive.apply_to(b)
+                            positive.apply_to(b).to_string()
                         } else if b.is_sign_negative() {
-                            negative.apply_to(b)
+                            negative.apply_to(b).to_string()
                         } else {
-                            neutral.apply_to(b)
+                            neutral.apply_to("0").to_string()
                         },
                     )
                 });
@@ -151,7 +155,7 @@ impl Command {
                                 id = nanoid::nanoid!(10);
                             }
                             // assign ID
-                            txn.annotations.push(format!("id:{id}"));
+                            txn.annotations.insert("id".to_string(), id);
                         }
                     }
                 }
@@ -216,7 +220,7 @@ impl Command {
                 if txnmatch::do_match(&mut data, *account)? {
                     data::format_entire_ledger(&mut fsdata, &data)?;
                 }
-            },
+            }
             Self::Comb { account } => {
                 let data = load_data(&mut fsdata, inv, check::CheckLevel::Consistent)?;
 

+ 21 - 14
src/cmd/comb.rs

@@ -10,13 +10,13 @@ enum Action {
     Skip,
     Edit,
     Finish,
-    Abort
+    Abort,
 }
 
 pub fn do_comb(data: &Hoard, account: AccountName) -> anyhow::Result<bool> {
     let Some(txns) = data.ledger_data_for(account) else {
         log::error!("No ledger data available for account {account}");
-        return Ok(false)
+        return Ok(false);
     };
 
     let mut aborted = false;
@@ -27,40 +27,47 @@ pub fn do_comb(data: &Hoard, account: AccountName) -> anyhow::Result<bool> {
     for txn_ref in txns {
         let txn = txn_ref.borrow();
         if txn.changes.len() != 2 {
-            continue
+            continue;
         }
 
         if ignore.contains(&txn.title) {
-            continue
+            continue;
         }
 
         println!();
         show::show_transaction(Some(&data), &txn);
 
-        let action = cliask::ActionPrompt::new().with_default(Action::Ignore).run()?;
+        let action = cliask::ActionPrompt::new()
+            .with_default(Action::Ignore)
+            .run()?;
 
         match action {
             Action::Ignore => (),
             Action::Skip => {
                 ignore.insert(txn.title.clone());
-            },
+            }
             Action::Edit => {
                 let Some(new_account) = cliask::SelectPrompt::new("Select account:")
                     .with_items(data.account_names())
-                    .run_cancellable()? else { continue };
+                    .run_cancellable()?
+                else {
+                    continue;
+                };
 
                 drop(txn);
                 let mut txn = txn_ref.borrow_mut();
-                txn.changes.iter_mut().find(|e| *e.account == account).iter_mut().for_each(|ch| *ch.account = new_account);
+                txn.changes
+                    .iter_mut()
+                    .find(|e| *e.account == account)
+                    .iter_mut()
+                    .for_each(|ch| *ch.account = new_account);
                 changed = true;
-            },
-            Action::Finish => {
-                break
-            },
+            }
+            Action::Finish => break,
             Action::Abort => {
                 aborted = true;
-                break
-            },
+                break;
+            }
         }
     }
 

+ 19 - 14
src/cmd/infer.rs

@@ -36,7 +36,8 @@ pub fn do_inference(data: &mut Hoard) -> anyhow::Result<bool> {
 
         for other_txn_ref in other_txns {
             let other_txn = other_txn_ref.borrow();
-            if other_txn.title.is_none() || other_txn.title != txn_title || other_txn_ref == txn_ref {
+            if other_txn.title.is_none() || other_txn.title != txn_title || other_txn_ref == txn_ref
+            {
                 continue;
             }
 
@@ -46,7 +47,7 @@ pub fn do_inference(data: &mut Hoard) -> anyhow::Result<bool> {
             if chg.next().is_some() || *candidate.account == placeholder {
                 continue;
             } else if rejected.contains(&candidate.account) {
-                continue
+                continue;
             }
 
             log::trace!(
@@ -58,9 +59,16 @@ pub fn do_inference(data: &mut Hoard) -> anyhow::Result<bool> {
 
             show_transaction(None, &txn);
 
-            println!("Candidate account is {}. Use?", console::style(candidate.account).red());
+            println!(
+                "Candidate account is {}. Use?",
+                console::style(candidate.account).red()
+            );
 
-            if cliask::ActionPrompt::<cliask::YesNoAction>::new().with_default(cliask::YesNoAction::Yes).run()?.into() {
+            if cliask::ActionPrompt::<cliask::YesNoAction>::new()
+                .with_default(cliask::YesNoAction::Yes)
+                .run()?
+                .into()
+            {
                 let new_account = *candidate.account;
                 log::info!(
                     "    Changing to account {} ...",
@@ -69,17 +77,14 @@ pub fn do_inference(data: &mut Hoard) -> anyhow::Result<bool> {
 
                 drop(txn);
 
-                txn_ref.borrow_mut()
-                    .changes
-                    .iter_mut()
-                    .for_each(|c| {
-                        if *c.account == placeholder {
-                            c.account = new_account.into();
-                            log::trace!("    changed account to {new_account}");
-                        }
-                    });
+                txn_ref.borrow_mut().changes.iter_mut().for_each(|c| {
+                    if *c.account == placeholder {
+                        c.account = new_account.into();
+                        log::trace!("    changed account to {new_account}");
+                    }
+                });
                 any_changed = true;
-                break
+                break;
             } else {
                 rejected.insert(*candidate.account);
             }

+ 58 - 28
src/cmd/txnmatch.rs

@@ -2,7 +2,11 @@ use std::collections::BTreeSet;
 
 use crate::prelude::*;
 
-fn do_merge(txn1_ref: &data::TransactionRef, txn2_ref: &data::TransactionRef, common: data::AccountName) -> data::TransactionRef {
+fn do_merge(
+    txn1_ref: &data::TransactionRef,
+    txn2_ref: &data::TransactionRef,
+    common: data::AccountName,
+) {
     let mut txn1 = txn1_ref.borrow_mut();
     let mut txn2 = txn2_ref.borrow_mut();
 
@@ -21,17 +25,16 @@ fn do_merge(txn1_ref: &data::TransactionRef, txn2_ref: &data::TransactionRef, co
     drop(rest1);
     drop(rest2);
 
-    // keep whichever txn has the withdrawal
-    if change1.amount.is_sign_positive() {
-        change1.account = other2.account;
+    change1.account = other2.account;
+    change2.account = other1.account;
 
-        txn2.annotations.push("killed".into());
-        txn2_ref.clone()
+    // ghost whichever is the deposit
+    if change1.amount.is_sign_positive() {
+        txn1.annotations
+            .insert("ghost".to_string(), txn2.id().unwrap().to_string());
     } else {
-        change2.account = other1.account;
-
-        txn1.annotations.push("killed".into());
-        txn1_ref.clone()
+        txn2.annotations
+            .insert("ghost".to_string(), txn1.id().unwrap().to_string());
     }
 }
 
@@ -45,36 +48,66 @@ enum Action {
 }
 
 pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::Result<bool> {
-    let Some(account_data)  = data.ledger_data_for(account) else { 
+    let Some(account_data) = data.ledger_data_for(account) else {
         log::warn!("No data for account {account}");
-        return Ok(false)
+        return Ok(false);
     };
 
     let mut killed = vec![];
     let mut skip = BTreeSet::<data::TransactionRef>::new();
 
-    let mut sorted_data : Vec<_> = account_data.into();
+    let mut sorted_data: Vec<_> = account_data.into();
     sorted_data.sort_by_key(|d| d.borrow().datestamp);
 
     let search_window = chrono::Days::new(7);
 
     'outer_loop: for txn_ref in &sorted_data {
         if skip.contains(txn_ref) {
-            continue
+            continue;
         }
         let txn = txn_ref.borrow();
+        if txn.id().is_none() {
+            continue;
+        }
+
+        let (_, mut txn_known_it) = txn.split_changes(account).unwrap();
+        let txn_known = txn_known_it.next().unwrap();
+        drop(txn_known_it);
 
         let amount = *txn.change_for(account).unwrap().amount;
 
         // go one week prior and after
-        let range_start = sorted_data.binary_search_by_key(&txn.datestamp.checked_sub_days(search_window).unwrap(), |d| d.borrow().datestamp).unwrap_or_else(|a| a);
-        let range_end = sorted_data.binary_search_by_key(&txn.datestamp.checked_add_days(search_window).unwrap(), |d| d.borrow().datestamp).unwrap_or_else(|a| a);
+        let range_start = sorted_data
+            .binary_search_by_key(
+                &txn.datestamp.checked_sub_days(search_window).unwrap(),
+                |d| d.borrow().datestamp,
+            )
+            .unwrap_or_else(|a| a);
+        let range_end = sorted_data
+            .binary_search_by_key(
+                &txn.datestamp.checked_add_days(search_window).unwrap(),
+                |d| d.borrow().datestamp,
+            )
+            .unwrap_or_else(|a| a);
 
         for otxn_ref in sorted_data.iter().take(range_end).skip(range_start) {
             if skip.contains(otxn_ref) {
-                continue
+                continue;
             }
             let otxn = otxn_ref.borrow();
+
+            if otxn.id().is_none() {
+                continue;
+            }
+
+            let (_, mut otxn_known_it) = otxn.split_changes(account).unwrap();
+            let otxn_known = otxn_known_it.next().unwrap();
+            drop(otxn_known_it);
+
+            if otxn_known.account == txn_known.account {
+                continue;
+            }
+
             if *otxn.change_for(account).unwrap().amount == -amount {
                 println!("==== Candidate match ====");
 
@@ -82,7 +115,10 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
                 println!();
                 show::show_transaction(Some(data), &otxn);
 
-                match cliask::ActionPrompt::new().with_default(Action::Skip).run()? {
+                match cliask::ActionPrompt::new()
+                    .with_default(Action::Skip)
+                    .run()?
+                {
                     Action::Finish => break 'outer_loop,
                     Action::Skip => continue,
                     Action::Ignore => break,
@@ -92,19 +128,13 @@ pub fn do_match(data: &mut data::Hoard, account: data::AccountName) -> anyhow::R
                         killed.push(do_merge(txn_ref, otxn_ref, account));
                         skip.insert(txn_ref.clone());
                         skip.insert(otxn_ref.clone());
-                        break
-                    },
-                    Action::Abort => {
-                        return Ok(false)
-                    },
+                        break;
+                    }
+                    Action::Abort => return Ok(false),
                 }
             }
         }
     }
 
-    let any_merged = !killed.is_empty();
-
-    data.kill_transactions(killed.into_iter());
-
-    Ok(any_merged)
+    Ok(!skip.is_empty())
 }

+ 40 - 19
src/data.rs

@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::BTreeMap;
 
 use itertools::Itertools;
 
@@ -13,7 +13,7 @@ mod format;
 mod parse;
 pub mod spec;
 
-pub use format::{format_ledger,format_entire_ledger};
+pub use format::{format_entire_ledger, format_ledger};
 pub use parse::parse_ledger;
 
 pub struct UnitTag;
@@ -46,7 +46,7 @@ pub struct Transaction {
 
     pub datestamp: Datestamp,
     pub title: Option<String>,
-    pub annotations: Vec<String>,
+    pub annotations: BTreeMap<String, String>,
     pub changes: Vec<Change>,
 }
 
@@ -60,7 +60,9 @@ impl Transaction {
     }
 
     pub fn change_for_mut(&mut self, account: AccountName) -> Option<&mut Change> {
-        self.changes.iter_mut().find(|b| b.account.as_ref() == &account)
+        self.changes
+            .iter_mut()
+            .find(|b| b.account.as_ref() == &account)
     }
 
     pub fn split_changes(
@@ -115,12 +117,15 @@ impl Transaction {
     }
 
     pub fn get_annotation(&self, label: &str) -> Option<&str> {
-        for anno in self.annotations.iter() {
-            if let Some(body) = anno.strip_prefix(label) {
-                return Some(body);
-            }
-        }
-        None
+        self.annotations.get(label).map(String::as_str)
+    }
+
+    pub fn id(&self) -> Option<&str> {
+        self.get_annotation("id")
+    }
+
+    pub fn ghost(&self) -> Option<&str> {
+        self.get_annotation("ghost")
     }
 }
 
@@ -146,7 +151,7 @@ impl PartialEq for TransactionRef {
     }
 }
 
-impl Eq for TransactionRef { }
+impl Eq for TransactionRef {}
 
 impl PartialOrd for TransactionRef {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
@@ -224,10 +229,11 @@ pub struct Hoard {
     spec_root: spec::SpecRoot,
 
     comments: Vec<Spanned<String>>,
+    ghosts: Vec<Transaction>,
 
     raw_ledger_data: Vec<RawLedgerEntry>,
 
-    account_ledger_data: HashMap<AccountName, Vec<TransactionRef>>,
+    account_ledger_data: BTreeMap<AccountName, Vec<TransactionRef>>,
 }
 
 impl Hoard {
@@ -246,6 +252,7 @@ impl Hoard {
                     spec_root,
                     raw_ledger_data: vec![],
                     comments: vec![],
+                    ghosts: vec![],
                     account_ledger_data: Default::default(),
                 };
 
@@ -338,10 +345,15 @@ impl Hoard {
                 RawLedgerEntry::Transaction(tx) => tx,
                 RawLedgerEntry::Comment(c) => {
                     self.comments.push(c.clone());
-                    continue
+                    continue;
                 }
             };
 
+            if tx.ghost().is_some() {
+                self.ghosts.push(tx.clone());
+                continue;
+            }
+
             let txn_ref = TransactionRef(std::rc::Rc::new(std::cell::RefCell::new(tx.clone())));
             for bal in &tx.changes {
                 self.account_ledger_data
@@ -351,7 +363,9 @@ impl Hoard {
             }
         }
 
-        self.account_ledger_data.values_mut().for_each(|v| v.sort_by_key(|t| t.borrow().datestamp));
+        self.account_ledger_data
+            .values_mut()
+            .for_each(|v| v.sort_by_key(|t| t.borrow().datestamp));
     }
 
     pub fn all_raw_ledger_data(&self) -> &[RawLedgerEntry] {
@@ -366,7 +380,10 @@ impl Hoard {
         self.account_ledger_data.get(&aname).map(Vec::as_slice)
     }
 
-    pub fn raw_ledger_data_from(&self, source: io::SourceFile) -> impl Iterator<Item = &RawLedgerEntry> {
+    pub fn raw_ledger_data_from(
+        &self,
+        source: io::SourceFile,
+    ) -> impl Iterator<Item = &RawLedgerEntry> {
         self.all_raw_ledger_data()
             .iter()
             .filter(move |le| le.span().context == Some(source))
@@ -388,8 +405,8 @@ impl Hoard {
         &self.spec_root
     }
 
-    pub fn balance(&self, aname: AccountName) -> Option<HashMap<UnitName, Decimal>> {
-        let mut running = HashMap::<UnitName, Decimal>::new();
+    pub fn balance(&self, aname: AccountName) -> Option<BTreeMap<UnitName, Decimal>> {
+        let mut running = BTreeMap::<UnitName, Decimal>::new();
 
         for le in self.ledger_data_for(aname)? {
             for b in &le.borrow().changes {
@@ -407,8 +424,12 @@ impl Hoard {
     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<_>>() {
-            let Some(account_data) = self.account_ledger_data.get_mut(&account) else { continue };
-            let Some(pos) = account_data.iter().position(|v| *v == txn_ref) else { continue };
+            let Some(account_data) = self.account_ledger_data.get_mut(&account) else {
+                continue;
+            };
+            let Some(pos) = account_data.iter().position(|v| *v == txn_ref) else {
+                continue;
+            };
             account_data.remove(pos);
         }
         // transaction txn_ref should now be dangling

+ 23 - 6
src/data/format.rs

@@ -1,4 +1,4 @@
-use std::collections::{BTreeMap,BTreeSet};
+use std::collections::{BTreeMap, BTreeSet};
 
 use itertools::Itertools;
 
@@ -24,7 +24,10 @@ fn format_transaction(
         writeln!(
             target,
             "    {}",
-            tx.annotations.iter().map(|a| format!("[{a}]")).join(" ")
+            tx.annotations
+                .iter()
+                .map(|(k, v)| format!("[{k}:{v}]"))
+                .join(" ")
         )?;
     }
 
@@ -119,19 +122,33 @@ pub fn format_ledger<'l>(
 
 pub fn format_entire_ledger<'l>(
     fsdata: &mut crate::io::FilesystemData,
-    root: &Hoard
+    root: &Hoard,
 ) -> std::io::Result<()> {
     let mut entries = BTreeSet::<TransactionRef>::new();
 
     for acc in root.account_names() {
-        let Some(acc_data) = root.ledger_data_for(acc) else { continue };
+        let Some(acc_data) = root.ledger_data_for(acc) else {
+            continue;
+        };
         for txn_ref in acc_data {
             entries.insert(txn_ref.clone());
         }
     }
 
-    let raw_data = entries.into_iter().map(|v| RawLedgerEntry::Transaction(v.borrow().clone())).chain(
-        root.comments.iter().map(|v| RawLedgerEntry::Comment(v.clone()))).collect_vec();
+    let raw_data = entries
+        .into_iter()
+        .map(|v| RawLedgerEntry::Transaction(v.borrow().clone()))
+        .chain(
+            root.comments
+                .iter()
+                .map(|v| RawLedgerEntry::Comment(v.clone())),
+        )
+        .chain(
+            root.ghosts
+                .iter()
+                .map(|v| RawLedgerEntry::Transaction(v.clone())),
+        )
+        .collect_vec();
 
     format_ledger(fsdata, root, raw_data.iter())
 }

+ 17 - 14
src/data/parse.rs

@@ -1,11 +1,13 @@
+use std::collections::BTreeMap;
+
+use chumsky::{prelude::*, text::inline_whitespace};
+
 use crate::prelude::*;
 
 use data::{AccountName, DataError, Datestamp, Decimal, Spanned, UnitName, spec::SpecRoot};
 
 use super::{Change, RawLedgerEntry, Transaction};
 
-use chumsky::{prelude::*, text::inline_whitespace};
-
 type InputWithContext<'a> = chumsky::input::WithContext<io::Span, &'a str>;
 
 fn ledger_parser<'a>() -> impl Parser<
@@ -32,7 +34,8 @@ fn ledger_parser<'a>() -> impl Parser<
 
     let datestamp =
         group((int, just('-').ignored(), int, just('-').ignored(), int)).map(|(y, _, m, _, d)| {
-            Datestamp::from_ymd_opt(y as i32, m as u32, d as u32).expect("parse error: date format did not match")
+            Datestamp::from_ymd_opt(y as i32, m as u32, d as u32)
+                .expect("parse error: date format did not match")
         });
 
     let decimal_digit = one_of("0123456789.,");
@@ -111,9 +114,11 @@ fn ledger_parser<'a>() -> impl Parser<
     });
 
     let annotation = mark('[')
-        .ignore_then(none_of("]\n\t").repeated().to_slice())
+        .ignore_then(none_of(":]\n\t").repeated().to_slice())
+        .then_ignore(just(':'))
+        .then(none_of("]\n\t").repeated().to_slice())
         .then_ignore(mark(']'))
-        .map(|v: &str| String::from(v.trim()));
+        .map(|(k, v): (&str, &str)| (String::from(k.trim()), String::from(v.trim())));
 
     let transaction = group((
         chumsky::text::whitespace(),
@@ -136,15 +141,13 @@ fn ledger_parser<'a>() -> impl Parser<
     ))
     .map_with(
         |(_, datestamp, _, _, title, _, annotations, changes, _), e| {
-            RawLedgerEntry::Transaction(
-                Transaction {
-                    datestamp,
-                    title: (!title.is_empty()).then_some(title),
-                    annotations,
-                    changes,
-                    source: Some(e.span()),
-                }
-            )
+            RawLedgerEntry::Transaction(Transaction {
+                datestamp,
+                title: (!title.is_empty()).then_some(title),
+                annotations: annotations.into_iter().collect(),
+                changes,
+                source: Some(e.span()),
+            })
         },
     );
 

+ 4 - 4
src/data/spec.rs

@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::BTreeMap;
 
 use super::{AccountName, UnitName};
 
@@ -39,7 +39,7 @@ pub struct AccountSpec {
     pub title: Option<String>,
     pub description: Option<String>,
 
-    pub annotations: Option<HashMap<String, String>>,
+    pub annotations: Option<BTreeMap<String, String>>,
 
     pub unit: Option<UnitName>,
 
@@ -58,9 +58,9 @@ pub struct UnitSpec {
 pub struct SpecRoot {
     pub ledger_path: std::path::PathBuf,
 
-    pub units: HashMap<UnitName, UnitSpec>,
+    pub units: BTreeMap<UnitName, UnitSpec>,
 
     pub placeholder_account: AccountName,
 
-    pub accounts: HashMap<AccountName, AccountSpec>,
+    pub accounts: BTreeMap<AccountName, AccountSpec>,
 }

+ 3 - 2
src/import.rs

@@ -86,7 +86,8 @@ fn import_from_csv(
             match spec {
                 data::spec::CsvColumnSpec::Ignore => (),
                 data::spec::CsvColumnSpec::Datestamp => {
-                    txn_datestamp = data::Datestamp::parse_from_str(record, &csv_spec.date_format).ok();
+                    txn_datestamp =
+                        data::Datestamp::parse_from_str(record, &csv_spec.date_format).ok();
                 }
                 data::spec::CsvColumnSpec::Title => {
                     txn_title = Some(record.into());
@@ -138,7 +139,7 @@ fn import_from_csv(
         txns.push(data::Transaction {
             datestamp: txn_datestamp.unwrap(),
             title: txn_title,
-            annotations: vec![],
+            annotations: Default::default(),
             changes: vec![
                 data::Change {
                     account: account.into(),

+ 21 - 19
src/show.rs

@@ -127,8 +127,15 @@ impl TransactionTable {
         ])
     }
 
-    fn format_txn(&self, root: Option<&Hoard>, account: AccountName, txn: &Transaction) -> Option<Row> {
-        let Some(chg) = txn.change_for(account) else { return None };
+    fn format_txn(
+        &self,
+        root: Option<&Hoard>,
+        account: AccountName,
+        txn: &Transaction,
+    ) -> Option<Row> {
+        let Some(chg) = txn.change_for(account) else {
+            return None;
+        };
         let precision = root
             .and_then(|r| r.unit_spec(*chg.unit))
             .and_then(|v| v.precision)
@@ -152,28 +159,23 @@ impl TransactionTable {
     ) {
         show_table(
             self.make_columns(),
-            vec![
-                self.make_header_row(),
-                Row::Line,
-            ]
-            .into_iter()
-            .chain(
-                txns.filter_map(|txn| self.format_txn(root, account, txn))
-            ),
+            vec![self.make_header_row(), Row::Line]
+                .into_iter()
+                .chain(txns.filter_map(|txn| self.format_txn(root, account, txn))),
         )
     }
 
-    pub fn show_refs<'d>(self, root: Option<&Hoard>, account: AccountName, txns: impl Iterator<Item = &'d TransactionRef>) {
+    pub fn show_refs<'d>(
+        self,
+        root: Option<&Hoard>,
+        account: AccountName,
+        txns: impl Iterator<Item = &'d TransactionRef>,
+    ) {
         show_table(
             self.make_columns(),
-            vec![
-                self.make_header_row(),
-                Row::Line,
-            ]
-            .into_iter()
-            .chain(
-                txns.filter_map(|txn| self.format_txn(root, account, &txn.borrow()))
-            ),
+            vec![self.make_header_row(), Row::Line]
+                .into_iter()
+                .chain(txns.filter_map(|txn| self.format_txn(root, account, &txn.borrow()))),
         )
     }
 }