From d7bc50c2673ddad4c53b7aa8af2a783fb84bbcf8 Mon Sep 17 00:00:00 2001 From: Evan Peterson <77evan@gmail.com> Date: Mon, 5 Jan 2026 14:19:32 -0500 Subject: [PATCH] add cli --- Cargo.toml | 15 +- src/cli/balance.rs | 29 ++ src/cli/commands.rs | 35 ++ src/cli/config.rs | 62 +++ src/cli/fmt.rs | 98 +++++ src/cli/importer.rs | 620 +++++++++++++++++++++++++++++ src/cli/mod.rs | 37 ++ src/core/amounts.rs | 3 +- src/core/ledger.rs | 5 +- src/core/transaction.rs | 2 +- src/document/ledger.rs | 5 +- src/document/mod.rs | 20 +- src/main.backup.rs | 187 +++++++++ src/main.rs | 219 +++------- src/output/amounts.rs | 14 +- src/output/cli/balance.rs | 2 +- src/parser/amount.rs | 11 +- src/parser/document/transaction.rs | 40 +- src/query/balance.rs | 4 +- 19 files changed, 1200 insertions(+), 208 deletions(-) create mode 100644 src/cli/balance.rs create mode 100644 src/cli/commands.rs create mode 100644 src/cli/config.rs create mode 100644 src/cli/fmt.rs create mode 100644 src/cli/importer.rs create mode 100644 src/cli/mod.rs create mode 100644 src/main.backup.rs diff --git a/Cargo.toml b/Cargo.toml index 5347031..7e64958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,31 @@ [package] name = "accounting-rust" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] chrono = "0.4.39" +clap = { version = "4.5.53", features = ["derive"] } +crossterm = "0.29.0" +csv = "1.4.0" +inquire = "0.9.1" nom = "7.1.3" nom_locate = "4.2.0" +nucleo-matcher = "0.3.1" rand = "0.8.5" ratatui = "0.29.0" regex = "1.11.1" +rt-format = "0.3.1" rust_decimal = "1.36.0" rust_decimal_macros = "1.36.0" +serde = { version="1.0", features = ["derive"] } +strsim = "0.11.1" +toml = "0.9.10" [profile.release] debug = 1 -[rust] -debuginfo-level = 1 \ No newline at end of file +# [rust] +# debuginfo-level = 1 \ No newline at end of file diff --git a/src/cli/balance.rs b/src/cli/balance.rs new file mode 100644 index 0000000..7b6b5a7 --- /dev/null +++ b/src/cli/balance.rs @@ -0,0 +1,29 @@ +use super::commands::Balance; +use accounting_rust::{core::Ledger, output::cli::format_balance, parser, query::{self, PostingField}}; +use chrono::Utc; + +pub fn balance(ledger: &Ledger, b: Balance) { + + let balance_query = if b.accounts.is_empty() { + None + } else { + let balance_query = b + .accounts + .iter() + .map(|a| format!("account.name ~ '{}'", a)) + .collect::>() + .join(" OR "); + + let parsed_query = parser::query::(&balance_query).unwrap(); + if parsed_query.0.trim().len() != 0 { + panic!("Full string not consumed") + } + Some(parsed_query.1) + }; + + let current_date = Utc::now().date_naive(); + + let balance = query::balance(&ledger, balance_query, Some(("$", current_date))); + + format_balance(&ledger, &balance); +} diff --git a/src/cli/commands.rs b/src/cli/commands.rs new file mode 100644 index 0000000..2774ccd --- /dev/null +++ b/src/cli/commands.rs @@ -0,0 +1,35 @@ +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub struct Cli { + #[arg(short, long)] + pub check: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Args, Debug)] +pub struct Balance { + /// Accounts to query + pub accounts: Vec, +} + +#[derive(Args, Debug)] +pub struct Import { + /// Import config name + pub config: String, + /// File path to import + pub file_path: String, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Shows balance of accounts + Balance(Balance), + /// Check transactions and balances + Check, + /// Import CSV + Import(Import), +} diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..e20ac9b --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,62 @@ +use accounting_rust::core::CoreError; +use serde::Deserialize; +use std::{collections::HashMap, path::Path}; + +#[derive(Deserialize, Debug)] +pub struct Config { + pub ledger: LedgerConfig, + pub importers: HashMap, + pub variables: Option>, +} + +#[derive(Deserialize, Debug)] +pub struct LedgerConfig { + pub path: String, +} + +#[derive(Deserialize, Debug)] +pub struct ImporterConfig { + pub template: String, + pub column_date: Option, + pub column_description: Option, + pub column_amount: Option, + pub column_unit: Option, + pub column_price_total: Option, + pub first_row: Option, + pub reverse_order: Option, + pub unit: Option, + pub date_format: Option, + pub mapping_file: Option, + pub output_file: String, +} + +pub fn read_config(file_path: &Path) -> Result { + let config_file_data = + std::fs::read_to_string(file_path).map_err(|e| CoreError::from(e.to_string()))?; + let mut config: Result = + toml::from_str(&config_file_data).map_err(|e| e.to_string().into()); + + if let Ok(config) = &mut config + && let Some(variables) = &config.variables + { + println!("{variables:?}"); + for variable in variables { + config.ledger.path = config + .ledger + .path + .replace(&format!("{{{}}}", variable.0), &variable.1); + for importer in &mut config.importers { + importer.1.mapping_file = importer + .1 + .mapping_file + .as_ref() + .map(|v| v.replace(&format!("{{{}}}", variable.0), &variable.1)); + importer.1.output_file = importer + .1 + .output_file + .replace(&format!("{{{}}}", variable.0), &variable.1); + } + } + } + config +} diff --git a/src/cli/fmt.rs b/src/cli/fmt.rs new file mode 100644 index 0000000..34bcd76 --- /dev/null +++ b/src/cli/fmt.rs @@ -0,0 +1,98 @@ +use core::fmt; + +use chrono::NaiveDate; +use rt_format::{Format, FormatArgument, Specifier}; +use rust_decimal::Decimal; + +#[derive(Debug, PartialEq)] +#[allow(dead_code)] +pub enum Variant { + Int(i32), + Float(f64), + String(String), + Decimal(Decimal), + Date(NaiveDate), +} + +impl FormatArgument for Variant { + fn supports_format(&self, spec: &Specifier) -> bool { + match self { + Self::Int(_) => true, + Self::Float(_) | Self::Decimal(_) => match spec.format { + Format::Display | Format::Debug | Format::LowerExp | Format::UpperExp => true, + _ => false, + }, + Self::String(_) | Self::Date(_) => match spec.format { + Format::Display | Format::Debug => true, + _ => false, + }, + } + } + + fn fmt_display(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::Display::fmt(&val, f), + Self::Float(val) => fmt::Display::fmt(&val, f), + Self::String(val) => fmt::Display::fmt(&val, f), + Self::Decimal(val) => fmt::Display::fmt(&val, f), + Self::Date(val) => fmt::Display::fmt(&val, f), + } + } + + fn fmt_debug(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(self, f) + } + + fn fmt_octal(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::Octal::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn fmt_lower_hex(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::LowerHex::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn fmt_upper_hex(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::UpperHex::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn fmt_binary(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::Binary::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn fmt_lower_exp(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::LowerExp::fmt(&val, f), + Self::Float(val) => fmt::LowerExp::fmt(&val, f), + Self::Decimal(val) => fmt::LowerExp::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn fmt_upper_exp(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Int(val) => fmt::UpperExp::fmt(&val, f), + Self::Float(val) => fmt::UpperExp::fmt(&val, f), + Self::Decimal(val) => fmt::UpperExp::fmt(&val, f), + _ => Err(fmt::Error), + } + } + + fn to_usize(&self) -> Result { + match self { + Variant::Int(val) => (*val).try_into().map_err(|_| ()), + _ => Err(()), + } + } +} diff --git a/src/cli/importer.rs b/src/cli/importer.rs new file mode 100644 index 0000000..5306a6b --- /dev/null +++ b/src/cli/importer.rs @@ -0,0 +1,620 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::format, + fs::{File, OpenOptions}, + io::{Write, stdout}, + path::Path, +}; + +use crate::cli::{config::ImporterConfig, fmt::Variant}; + +use super::{commands::Import, config::Config}; +use accounting_rust::{ + core::{Amount, CoreError, Ledger}, + parser::decimal, +}; +use chrono::NaiveDate; +use crossterm::{ExecutableCommand, cursor::MoveToPreviousLine, terminal}; +use inquire::{ + Autocomplete, Confirm, InquireError, Text, required, validator::{ErrorMessage, Validation} +}; +use rt_format::ParsedFormat; +use rust_decimal::Decimal; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Mapping { + original_description: String, + account: Option, + payee: Option, + narration: Option, +} + +#[derive(Clone)] +struct Accounts { + accounts: Vec, +} + +#[derive(Clone)] +struct Payees { + payees: Vec, +} + +#[derive(Clone)] +struct Narrations { + narrations: Vec, +} + +pub fn import(ledger: &Ledger, config: &Config, args: Import) -> Result<(), CoreError> { + let import_config = config + .importers + .get(&args.config) + .ok_or(CoreError::from("Config not found"))?; + + let mut mappings = get_mappings(&import_config); + + let file_to_read = Path::new(&args.file_path); + let mut reader = csv::ReaderBuilder::new() + .flexible(true) + .has_headers(false) + .from_path(file_to_read) + .unwrap(); + + let output_path = Path::new(&import_config.output_file); + let mut file: File = File::options() + .append(true) + .create(true) + .open(output_path) + .unwrap(); + writeln!(&mut file, "\n").unwrap(); + + // Needed to add bottom space for prompts + // Extra line needed because enter key after prompt + print!("\n\n\n\n"); + clear_lines(4); + + let mut records: Vec> = reader + .records() + .skip((import_config.first_row.unwrap_or(1) - 1) as usize) + .collect(); + if import_config.reverse_order.unwrap_or(false) { + records.reverse(); + } + + for record in records { + let transaction = parse_record( + record.map_err(|e| CoreError::from(e.to_string()))?, + ledger, + import_config, + &mut mappings, + ); + if let Some(transaction) = transaction { + let mut file: File = File::options() + .append(true) + .create(true) + .open(output_path) + .unwrap(); + write!(&mut file, "{}", transaction).unwrap(); + } + } + + Ok(()) +} + +fn clear_lines(count: u16) { + let mut stdout = stdout(); + stdout.execute(MoveToPreviousLine(count)).unwrap(); + stdout + .execute(terminal::Clear(terminal::ClearType::FromCursorDown)) + .unwrap(); +} + +fn parse_record( + record: csv::StringRecord, + ledger: &Ledger, + import_config: &ImporterConfig, + mappings: &mut Vec, +) -> Option { + let mut lines_to_clear = 0; + + let date = import_config + .column_date + .and_then(|col| record.get((col - 1) as usize)) + .filter(|val| val.trim().len() != 0); + let description = import_config + .column_description + .and_then(|col| record.get((col - 1) as usize)) + .unwrap_or(""); + let amount = import_config + .column_amount + .and_then(|col| record.get((col.abs() - 1) as usize)) + .filter(|val| val.trim().len() != 0); + let mut unit_symbol = import_config + .column_unit + .and_then(|col| record.get((col - 1) as usize).map(|c| c.to_string())) + .filter(|val| val.trim().len() != 0); + let mut price_total = import_config + .column_price_total + .and_then(|col| record.get((col.abs() - 1) as usize)) + .filter(|val| val.trim().len() != 0) + .and_then(|val| { + decimal(val).ok().map(|val| { + if let Some(column_price_total) = import_config.column_price_total + && column_price_total < 0 + { + val.1 * Decimal::NEGATIVE_ONE + } else { + val.1 + } + }) + }); + + let top_match = find_top_mapping(&description, mappings); + let match_to_use = top_match.filter(|m| m.1 >= 0.8).map(|m| m.0); + + println!( + "{} {:?} {}{}{}", + date.unwrap_or(""), + description, + amount.unwrap_or(""), + unit_symbol + .as_ref() + .and_then(|s| Some(format!(" ({s})"))) + .unwrap_or("".to_string()), + price_total + .and_then(|t| Some(format!(" @@ {t}"))) + .unwrap_or("".to_string()), + ); + lines_to_clear += 1; + + let Some(date) = date else { + Confirm::new("Skip row without date?") + .with_default(true) + .prompt() + .unwrap(); + lines_to_clear += 1; + clear_lines(lines_to_clear); + return None; + }; + let Ok(date) = NaiveDate::parse_from_str( + date, + import_config + .date_format + .as_ref() + .map(|v| v.as_str()) + .unwrap_or("%Y-%m-%d"), + ) else { + Confirm::new("Skip row with invalid date?") + .with_default(true) + .prompt() + .unwrap(); + lines_to_clear += 1; + clear_lines(lines_to_clear); + return None; + }; + + let Some(amount) = amount else { + Confirm::new("Skip row without amount?") + .with_default(true) + .prompt() + .unwrap(); + lines_to_clear += 1; + clear_lines(lines_to_clear); + return None; + }; + let Ok(amount) = decimal(amount) else { + Confirm::new("Skip with invalid amount?") + .with_default(true) + .prompt() + .unwrap(); + lines_to_clear += 1; + clear_lines(lines_to_clear); + return None; + }; + let mut amount = amount.1; + if let Some(column_amount) = import_config.column_amount + && column_amount < 0 + { + amount *= Decimal::NEGATIVE_ONE; + } + + if let Some(price_total_unwrap) = price_total { + if amount.is_zero() { + amount = price_total_unwrap; + unit_symbol = import_config.unit.clone(); + price_total = None; + } else { + price_total = Some(price_total_unwrap.abs()); + } + } + + if let Some(match_to_use) = match_to_use { + if let Some(match_account) = &match_to_use.account { + let skip = loop { + let skip = Confirm::new(&format!("Enter {:.0}% match?", top_match.unwrap().1 * 100.)) + .with_help_message(&format!( + "Account: {}\n Payee: {}\n Narration: {} ", + match_account, + match_to_use.payee.as_ref().unwrap_or(&"".to_string()), + match_to_use.narration.as_ref().unwrap_or(&"".to_string()) + )) + .with_default(true) + .prompt(); + match skip { + Ok(s) => break s, + Err(InquireError::OperationCanceled) => {}, + _ => panic!() + } + clear_lines(1); + }; + // .unwrap_or(true); + lines_to_clear += 1; + if skip { + clear_lines(lines_to_clear); + return Some( + format_transaction( + ledger, + &import_config, + date, + amount, + unit_symbol, + price_total, + match_account.clone(), + match_to_use.payee.clone(), + match_to_use.narration.clone(), + ) + .unwrap(), + ); + } + } else { + let skip = loop { + let skip = Confirm::new(&format!("Skip {:.1}% match?", top_match.unwrap().1 * 100.)) + .with_default(true) + .prompt(); + match skip { + Ok(s) => break s, + Err(InquireError::OperationCanceled) => {}, + _ => panic!() + } + clear_lines(1); + }; + lines_to_clear += 1; + if skip { + clear_lines(lines_to_clear); + return None; + } + } + } + + let mut account = Text::new("Account?"); + if let Some(match_to_use) = match_to_use + && let Some(match_account) = &match_to_use.account + { + account = account.with_default(&match_account); + } + let account = account + .with_autocomplete(Accounts::new(ledger)) + .with_validator(|v: &str| { + if v.trim().len() == 0 { + Ok(Validation::Invalid(ErrorMessage::Custom( + "Account cannot be empty, press escape to skip row".into(), + ))) + } else if v.contains(' ') { + Ok(Validation::Invalid(ErrorMessage::Custom( + "Account cannot contain spaces".into(), + ))) + } else { + Ok(Validation::Valid) + } + }) + .prompt_skippable() + .unwrap(); + lines_to_clear += 1; + + let Some(account) = account else { + clear_lines(lines_to_clear); + + if let Some(top_match) = top_match + && top_match.1 == 1. + { + } else { + add_mapping( + Mapping { + original_description: description.to_string(), + account: None, + payee: None, + narration: None, + }, + mappings, + import_config, + ); + } + + return None; + }; + + let mut payee = Text::new("Payee?"); + if let Some(match_to_use) = match_to_use + && let Some(match_payee) = &match_to_use.payee + { + payee = payee.with_default(&match_payee); + } + let payee = payee + .with_autocomplete(Payees::new(ledger)) + .with_validator(|p: &str| { + if p.contains('|') { + Ok(Validation::Invalid(ErrorMessage::Custom( + "Payee cannot contain '|'".into(), + ))) + } else { + Ok(Validation::Valid) + } + }) + .prompt_skippable() + .unwrap(); + lines_to_clear += 1; + let payee = payee.filter(|v| v.trim().len() != 0); + + let narration = Text::new("Narration?") + .with_autocomplete(Narrations::new(ledger)) + .with_validator(|p: &str| { + if p.contains('|') { + Ok(Validation::Invalid(ErrorMessage::Custom( + "Payee cannot contain '|'".into(), + ))) + } else { + Ok(Validation::Valid) + } + }) + .prompt_skippable() + .unwrap(); + lines_to_clear += 1; + let narration = narration.filter(|v| v.trim().len() != 0); + + clear_lines(lines_to_clear); + + if let Some(top_match) = top_match + && top_match.1 == 1. + { + } else { + add_mapping( + Mapping { + original_description: description.to_string(), + account: Some(account.clone()), + payee: payee.clone(), + narration: narration.clone(), + }, + mappings, + import_config, + ); + } + + Some( + format_transaction( + ledger, + &import_config, + date, + amount, + unit_symbol, + price_total, + account, + payee, + narration, + ) + .unwrap(), + ) +} + +fn format_transaction( + ledger: &Ledger, + import_config: &ImporterConfig, + date: NaiveDate, + amount: Decimal, + unit_symbol: Option, + price_total: Option, + account: String, + payee: Option, + narration: Option, +) -> Result { + let unit_symbol = unit_symbol.or(import_config.unit.clone()).ok_or(())?; + + let unit = ledger.get_unit_by_symbol(&unit_symbol); + let amount = if let Some(unit) = unit { + // TODO: pad to decimal point + ledger + .format_amount(&Amount { unit_id: unit.get_id(), value: amount }) + .unwrap() + .0 + } else { + format!("{} {}", amount, unit_symbol) + }; + let price_total = if let Some(price_total) = price_total { + let unit = ledger + .get_unit_by_symbol(import_config.unit.as_ref().unwrap()) + .unwrap(); + let price_total_str = ledger + .format_amount(&Amount { unit_id: unit.get_id(), value: price_total }) + .unwrap() + .0; + format!("@@ {}", price_total_str) + } else { + "".to_string() + }; + + let args = HashMap::from([ + ("date", Variant::Date(date)), + ("amount", Variant::String(amount)), + ("price_total", Variant::String(price_total)), + ("account", Variant::String(account)), + ("payee", Variant::String(payee.unwrap_or("".into()))), + ( + "narration", + Variant::String(narration.map(|v| format!("| {v}")).unwrap_or("".into())), + ), + ]); + + Ok(ParsedFormat::parse(&import_config.template, &[], &args) + .or(Err(()))? + .to_string()) +} + +fn get_mappings(import_config: &ImporterConfig) -> Vec { + let file_to_read = Path::new(import_config.mapping_file.as_ref().unwrap()); + let mut mappings_reader = csv::ReaderBuilder::new() + .flexible(true) + .has_headers(false) + .from_path(file_to_read) + .unwrap(); + let mut mappings: Vec<_> = mappings_reader + .deserialize::() + .map(|v| v.unwrap()) + .collect(); + // Reverse to check newest first + mappings.reverse(); + mappings +} + +fn add_mapping(new_mapping: Mapping, mappings: &mut Vec, import_config: &ImporterConfig) { + let file_to_append = OpenOptions::new() + .append(true) + .create(true) + .open(Path::new(import_config.mapping_file.as_ref().unwrap())) + .unwrap(); + let mut mappings_writer: csv::Writer = csv::WriterBuilder::new() + .has_headers(false) + .from_writer(file_to_append); + mappings_writer.serialize(&new_mapping).unwrap(); + // mappings_writer.write(); + mappings.push(new_mapping); +} + +fn find_top_mapping<'a>( + description: &str, + mappings: &'a Vec, +) -> Option<(&'a Mapping, f64)> { + let description = description.to_lowercase(); + let mut best_mapping: Option<(&'a Mapping, f64)> = None; + for mapping in mappings { + let score = + strsim::jaro_winkler(&description, &mapping.original_description.to_lowercase()); + if let Some(best_mapping) = best_mapping + && best_mapping.1 >= score + { + } else { + best_mapping = Some((mapping, score)); + } + } + best_mapping +} + +impl Accounts { + fn new(ledger: &Ledger) -> Self { + let mut accounts: Vec<_> = ledger + .get_accounts() + .iter() + .map(|a| a.get_name().clone()) + .collect(); + accounts.sort(); + Self { accounts } + } +} + +impl Autocomplete for Accounts { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + let input = input.to_lowercase(); + Ok(self + .accounts + .iter() + .filter(|a| a.to_lowercase().find(&input).is_some()) + .map(|t| t.clone()) + .collect()) + } + + fn get_completion( + &mut self, + input: &str, + highlighted_suggestion: Option, + ) -> Result { + let suggestions = self.get_suggestions(input); + if highlighted_suggestion.is_none() { + Ok(suggestions?.get(0).cloned()) + } else { + Ok(highlighted_suggestion) + } + } +} + +impl Payees { + fn new(ledger: &Ledger) -> Self { + let payees: HashSet<_> = ledger + .get_transactions() + .iter() + .filter_map(|t| t.get_payee().clone()) + .collect(); + let mut payees: Vec<_> = payees.into_iter().collect(); + payees.sort(); + Self { payees } + } +} + +impl Autocomplete for Payees { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + let input = input.to_lowercase(); + Ok(self + .payees + .iter() + .filter(|t| t.to_lowercase().find(&input).is_some()) + .map(|t| t.clone()) + .collect()) + } + + fn get_completion( + &mut self, + input: &str, + highlighted_suggestion: Option, + ) -> Result { + let suggestions = self.get_suggestions(input); + if highlighted_suggestion.is_none() { + Ok(suggestions?.get(0).cloned()) + } else { + Ok(highlighted_suggestion) + } + } +} + +impl Narrations { + fn new(ledger: &Ledger) -> Self { + let narrations: HashSet<_> = ledger + .get_transactions() + .iter() + .filter_map(|t| t.get_narration().clone()) + .collect(); + let mut narrations: Vec<_> = narrations.into_iter().collect(); + narrations.sort(); + Self { narrations } + } +} + +impl Autocomplete for Narrations { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + let input = input.to_lowercase(); + Ok(self + .narrations + .iter() + .filter(|t| t.to_lowercase().find(&input).is_some()) + .map(|t| t.clone()) + .collect()) + } + + fn get_completion( + &mut self, + input: &str, + highlighted_suggestion: Option, + ) -> Result { + let suggestions = self.get_suggestions(input); + if highlighted_suggestion.is_none() { + Ok(suggestions?.get(0).cloned()) + } else { + Ok(highlighted_suggestion) + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..b3ca9b6 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,37 @@ +use std::path::Path; + +use clap::Parser; + +use crate::cli::{balance::balance, commands::Commands, importer::import}; +use accounting_rust::{core::CoreError, document::Document}; + +mod balance; +mod commands; +pub mod config; +mod importer; +pub mod fmt; + +pub fn run() -> Result<(), CoreError> { + let cli = commands::Cli::parse(); + + let config_file = + Path::new("/Users/petersonev/Documents/Projects/accounting/ledger-data/config.toml"); + let config = config::read_config(config_file)?; + + let document = Document::new(Path::new(&config.ledger.path))?; + let balance_check = cli.check || matches!(cli.command, Commands::Check); + let ledger = document.generate_ledger(balance_check)?; + + match cli.command { + Commands::Balance(b) => balance(&ledger, b), + Commands::Check => { + println!("{} transactions", ledger.get_transactions().len()); + println!("{} prices", ledger.get_prices().len()); + println!("{} balances checked", document.get_balance_count()); + } + Commands::Import(args) => { + import(&ledger, &config, args)?; + } + } + Ok(()) +} diff --git a/src/core/amounts.rs b/src/core/amounts.rs index f296d87..e01eb69 100644 --- a/src/core/amounts.rs +++ b/src/core/amounts.rs @@ -77,7 +77,8 @@ impl Unit { } pub fn get_scale(&self) -> Option { - self.scale + // TODO: probably don't hard code 8 here + self.scale.or(Some(8)) } pub fn get_symbols(&self) -> &Vec { diff --git a/src/core/ledger.rs b/src/core/ledger.rs index 5fe24f5..6104220 100644 --- a/src/core/ledger.rs +++ b/src/core/ledger.rs @@ -60,6 +60,10 @@ impl Ledger { &self.transactions } + pub fn get_prices(&self) -> &Vec { + &self.prices + } + // Assume prices are sorted by date already // For now only trivial conversions, not multiple conversions pub fn get_price_on_date( @@ -166,7 +170,6 @@ impl Ledger { } pub fn sort_prices(&mut self) { - println!("price length = {:?}", self.prices.len()); self.prices.sort_by(|a, b| a.date.cmp(&b.date)); } } diff --git a/src/core/transaction.rs b/src/core/transaction.rs index 9948004..f3fa1df 100644 --- a/src/core/transaction.rs +++ b/src/core/transaction.rs @@ -98,7 +98,7 @@ impl Transaction { } amounts .iter() - .filter(|(_, &value)| value != dec!(0)) + .filter(|(_, value)| **value != dec!(0)) .map(|(&unit_id, &value)| Amount { value, unit_id }) .collect() } diff --git a/src/document/ledger.rs b/src/document/ledger.rs index b583986..6693ffc 100644 --- a/src/document/ledger.rs +++ b/src/document/ledger.rs @@ -90,7 +90,7 @@ pub fn check_balance2(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), let t2 = Instant::now(); - let accounts = query::balance(&ledger, Some(&balance.query), None); + let accounts = query::balance(&ledger, Some(balance.query.clone()), None); let t3 = Instant::now(); @@ -233,6 +233,7 @@ pub fn check_balance2(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), // Ok(()) // } +#[derive(Debug)] struct IncompletePosting { account_id: u32, amount: Option, @@ -300,7 +301,7 @@ fn complete_incomplete_postings( } if let Some(account_id) = incomplete_posting_account_id { - for (&unit_id, &value) in amounts.iter().filter(|(_, &v)| v != dec!(0)) { + for (&unit_id, &value) in amounts.iter().filter(|(_, v)| **v != dec!(0)) { let mut value = -value; if let Some(scale) = ledger.get_unit(unit_id).and_then(|u| u.get_scale()) { value = value.round_dp(scale); diff --git a/src/document/mod.rs b/src/document/mod.rs index fac3921..53a0f3e 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -4,7 +4,11 @@ mod ledger; pub use directives::*; use ledger::{add_transaction, check_balance2}; -use crate::{core::{CoreError, Ledger, Unit}, document::ledger::add_price, parser::parse_directives}; +use crate::{ + core::{CoreError, Ledger, Unit}, + document::ledger::add_price, + parser::parse_directives, +}; use std::{path::Path, time::Instant}; #[derive(Debug)] @@ -26,7 +30,11 @@ impl Document { Ok(document) } - pub fn generate_ledger(&self) -> Result { + pub fn get_balance_count(&self) -> usize { + self.directives.balances.len() + } + + pub fn generate_ledger(&self, check_balance: bool) -> Result { let mut ledger = Ledger::new(); for commodity in &self.directives.commodities { @@ -45,11 +53,13 @@ impl Document { ledger.sort_prices(); let start = Instant::now(); - for balance in &self.directives.balances { - check_balance2(&ledger, &balance)?; + if check_balance { + for balance in &self.directives.balances { + check_balance2(&ledger, &balance)?; + } } let end = Instant::now(); - println!("time to calculate balance: {:?}", end - start); + // println!("time to calculate balance: {:?}", end - start); Ok(ledger) // for balance in self.directives.balances { diff --git a/src/main.backup.rs b/src/main.backup.rs new file mode 100644 index 0000000..7f7fbbd --- /dev/null +++ b/src/main.backup.rs @@ -0,0 +1,187 @@ +use std::{ + io::{self, Write}, + path::Path, + time::Instant, +}; + +use accounting_rust::{ + document::Document, + output::cli::{format_balance, tui_to_ansi::text_to_ansi}, + parser::{self, query}, + query::{self, PostingField}, + // queries::{self, base::{self, DataValue, Query}, functions::{ComparisonFunction, LogicalFunction, SubAccountFunction}, transaction::{AccountField, PostingField, TransactionField}}, +}; +use chrono::{NaiveDate, Utc}; +use ratatui::{ + crossterm::{self, style::PrintStyledContent}, + layout::Rect, + prelude::CrosstermBackend, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::Widget, + Frame, Terminal, +}; + +// use accounting_rust::{create_ledger::create_ledger, document::{SingleDocument, WholeDocument}, parser::parse_directives}; + +pub fn main() -> Result<(), Box> { + // let file_data = fs::read_to_string("data/2020-bofa-checking.ledger").unwrap(); + // let file_data = fs::read_to_string("data/2020-fidelity-401k.ledger").unwrap(); + // let directives = parse_directives(file_data.as_str()).unwrap(); + // println!("{:?}", directives.balances); + + // let document = Document::new(Path::new("data/2020-fidelity-401k.ledger")).unwrap(); + + // let f = Frame:: + + // println!( + // "{}{}", + // span_to_ansi(&Span::raw("Hello ")), + // span_to_ansi(&Span::styled( + // "World", + // Style::new().fg(Color::Green).bg(Color::White) + // )) + // ); + + // let stdout = io::stdout(); + // let backend = CrosstermBackend::new(stdout); + // let mut terminal = Terminal::new(backend)?; + + // let line = Line::from(vec![ + // Span::raw("Hello "), + // Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))), + // Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)), + // ]) + // .centered(); + // let text = Text::from(line); + + // println!("{}", text_to_ansi(&text)); + + // // println!("{:?}", line.to_string()); + + // // terminal.dra + + // // crossterm::terminal::enable_raw_mode()?; + + // terminal.draw(|f| { + // let area = f.area(); + // f.render_widget(text, area); + // })?; + + // PrintStyledContent + + // let styled_text = Text::from(vec![ + // Line::from(vec![ + // Span::styled("Hello, ", Style::default().fg(Color::Green)), + // Span::styled("world! ", Style::default().fg(Color::Blue)), + // ]), + // Line::from(vec![ + // Span::styled("This is ", Style::default().fg(Color::Yellow)), + // Span::styled("styled text", Style::default().fg(Color::Red)), + // Span::raw("."), + // ]), + // ]); + + // // Convert the styled text to an ANSI-escaped string + // let ansi_string = styled_text.to_string(); + + // // Print the ANSI-styled string + // println!("{}", ansi_string); + + // println!("{}", text.render(area, buf);); + + // return Ok(()); + + let t1 = Instant::now(); + + let document = Document::new(Path::new("/Users/petersonev/Documents/Projects/accounting/ledger-data/main.ledger")).unwrap(); + + let t2 = Instant::now(); + + let ledger = document.generate_ledger().unwrap(); + + let t3 = Instant::now(); + + // let balance = queries::balance( + // &ledger, + // &[], + // ); + + // let balance = queries::balance2( + // &ledger, + // NaiveDate::from_ymd_opt(2100, 01, 01).unwrap(), + // Some("$") + // ); + + // let balance_query = "transaction.date < 2100-01-01"; + let balance_query = "account.name ~ 'Assets' OR account.name ~ 'Liabilities'"; + + let parsed_query = parser::query::(balance_query).unwrap(); + if parsed_query.0.trim().len() != 0 { + panic!("Full string not consumed") + } + let balance_query = parsed_query.1; + + let current_date = Utc::now().date_naive(); + + let balance = query::balance(&ledger, Some(&balance_query), Some(("$", current_date))); + + // let date_query = ComparisonFunction::new( + // "<=", + // Query::from_field(PostingField::Transaction(TransactionField::Date)), + // Query::from(DataValue::from(NaiveDate::from_ymd_opt(2100, 01, 01).unwrap())), + // ).unwrap(); + // let account_query = SubAccountFunction::new( + // "Assets".into(), + // base::Query::from_field(PostingField::Account(AccountField::Name)), + // ); + + // let total_query = LogicalFunction::new( + // "and", + // base::Query::from_fn(date_query), + // base::Query::from_fn(account_query), + // ).unwrap(); + + // let balance = queries::balance3( + // &ledger, + // &base::Query::from_fn(total_query), + // ); + + + let t4 = Instant::now(); + + format_balance(&ledger, &balance); + + let t5 = Instant::now(); + + println!("{:?} - {:?} - {:?} - {:?}", t2-t1, t3-t2, t4-t3, t5-t4); + + // return; + + // for (account_id, amounts) in balance { + // let account = ledger.get_account(account_id).unwrap(); + // if !account.get_name().starts_with("Assets") { + // continue; + // } + // print!("{} : ", account.get_name()); + // for (i, amount) in amounts.iter().enumerate() { + // if i != 0 { + // print!(", "); + // } + // print!("{}", ledger.format_amount(amount)); + // // let unit = ledger.get_unit(amount.unit_id).unwrap(); + // // let symbol = unit.default_symbol(); + // // if symbol.is_prefix { + // // print!("{}{}", symbol.symbol, amount.value); + // // } else { + // // print!("{} {}", amount.value, symbol.symbol); + // // } + // } + // println!(); + // } + + // let ledger = create_ledger(&file_data).unwrap(); + // println!("{:?}", val); + + return Ok(()); +} diff --git a/src/main.rs b/src/main.rs index de46942..7f0ab90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,187 +1,70 @@ -use std::{ - io::{self, Write}, - path::Path, - time::Instant, +// use std::path::PathBuf; + +use crossterm::{ + ExecutableCommand, QueueableCommand, + cursor::{self, MoveToPreviousLine}, + style::{self, Stylize}, + terminal, }; +use std::{collections::HashMap, default, io::stdout, path::Path}; -use accounting_rust::{ - document::Document, - output::cli::{format_balance, tui_to_ansi::text_to_ansi}, - parser::{self, query}, - query::{self, PostingField}, - // queries::{self, base::{self, DataValue, Query}, functions::{ComparisonFunction, LogicalFunction, SubAccountFunction}, transaction::{AccountField, PostingField, TransactionField}}, -}; -use chrono::{NaiveDate, Utc}; -use ratatui::{ - crossterm::{self, style::PrintStyledContent}, - layout::Rect, - prelude::CrosstermBackend, - style::{Color, Style, Stylize}, - text::{Line, Span, Text}, - widgets::Widget, - Frame, Terminal, -}; +use chrono::{Datelike, NaiveDate}; +use inquire::{CustomType, Text}; +use rt_format::ParsedFormat; -// use accounting_rust::{create_ledger::create_ledger, document::{SingleDocument, WholeDocument}, parser::parse_directives}; +use crate::cli::fmt::Variant; -pub fn main() -> Result<(), Box> { - // let file_data = fs::read_to_string("data/2020-bofa-checking.ledger").unwrap(); - // let file_data = fs::read_to_string("data/2020-fidelity-401k.ledger").unwrap(); - // let directives = parse_directives(file_data.as_str()).unwrap(); - // println!("{:?}", directives.balances); +mod cli; - // let document = Document::new(Path::new("data/2020-fidelity-401k.ledger")).unwrap(); +fn main() { + cli::run().unwrap(); - // let f = Frame:: + // let skip = inquire::Confirm::new("Test?") + // .with_help_message("Account: abc\n Payee: def\n Narration: sdfsdf") + // .with_default(true) + // .prompt() + // .unwrap(); - // println!( - // "{}{}", - // span_to_ansi(&Span::raw("Hello ")), - // span_to_ansi(&Span::styled( - // "World", - // Style::new().fg(Color::Green).bg(Color::White) - // )) + // let score = strsim::jaro_winkler( + // &"KEYSIGHT TECHNOL DES:DIRECT DEP ID:XXXXX4194068UTV INDN:PETERSON,EVAN J CO ID:XXXXX11101 PPD".to_lowercase(), + // &"KEYSIGHT TECHNOL DES:DIRECT DEP ID:XXXXX6001478UTV INDN:PETERSON,EVAN J CO ID:XXXXX11101 PPD".to_lowercase(), // ); + // // // let score = strsim::levenshtein("abc", ""); + // println!("{score}"); - // let stdout = io::stdout(); - // let backend = CrosstermBackend::new(stdout); - // let mut terminal = Terminal::new(backend)?; + // // nucleo::Nucleo::new(nucleo::Config::DEFAULT, notify, num_threads, columns); + // let mut matcher = nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT); - // let line = Line::from(vec![ - // Span::raw("Hello "), - // Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))), - // Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)), - // ]) - // .centered(); - // let text = Text::from(line); - - // println!("{}", text_to_ansi(&text)); - - // // println!("{:?}", line.to_string()); - - // // terminal.dra - - // // crossterm::terminal::enable_raw_mode()?; - - // terminal.draw(|f| { - // let area = f.area(); - // f.render_widget(text, area); - // })?; - - // PrintStyledContent - - // let styled_text = Text::from(vec![ - // Line::from(vec![ - // Span::styled("Hello, ", Style::default().fg(Color::Green)), - // Span::styled("world! ", Style::default().fg(Color::Blue)), - // ]), - // Line::from(vec![ - // Span::styled("This is ", Style::default().fg(Color::Yellow)), - // Span::styled("styled text", Style::default().fg(Color::Red)), - // Span::raw("."), - // ]), - // ]); - - // // Convert the styled text to an ANSI-escaped string - // let ansi_string = styled_text.to_string(); - - // // Print the ANSI-styled string - // println!("{}", ansi_string); - - // println!("{}", text.render(area, buf);); - - // return Ok(()); - - let t1 = Instant::now(); - - let document = Document::new(Path::new("data/full/main.ledger")).unwrap(); - - let t2 = Instant::now(); - - let ledger = document.generate_ledger().unwrap(); - - let t3 = Instant::now(); - - // let balance = queries::balance( - // &ledger, - // &[], + // let pattern = nucleo_matcher::pattern::Pattern::new( + // "Daniel Realty DES:RENTAL ID:XXXXX2645", + // nucleo_matcher::pattern::CaseMatching::Ignore, // Ignore case for matching + // nucleo_matcher::pattern::Normalization::Smart, // Use smart normalization + // nucleo_matcher::pattern::AtomKind::Fuzzy // Use the fuzzy matching algorithm // ); + // let mut buf = Vec::new(); + // let score = pattern.score(nucleo_matcher::Utf32Str::new("Daniel Realty DES:RENTAL ID:XXXXX8868", &mut buf), &mut matcher); + // println!("{score:?}"); - // let balance = queries::balance2( - // &ledger, - // NaiveDate::from_ymd_opt(2100, 01, 01).unwrap(), - // Some("$") - // ); + // let matches = pattern.match_list(&items_to_match, &mut matcher); + // matcher - // let balance_query = "transaction.date < 2100-01-01"; - let balance_query = "account.name ~ 'Assets' OR account.name ~ 'Liabilities'"; + // let args = HashMap::from([("abc", Variant::String("aaa".into()))]); + // let out = ParsedFormat::parse("{abc:>8} def", &[], &args).unwrap().to_string(); + // println!("{out}"); - let parsed_query = parser::query::(balance_query).unwrap(); - if parsed_query.0.trim().len() != 0 { - panic!("Full string not consumed") - } - let balance_query = parsed_query.1; + // let a = "{abc} def".format(args); - let current_date = Utc::now().date_naive(); + // let mut stdout = stdout(); - let balance = query::balance(&ledger, Some(&balance_query), Some(("$", current_date))); + // let date: NaiveDate = CustomType::new("Date:") + // .with_error_message("Please type a valid date") + // .with_default(NaiveDate::from_ymd_opt(2000, 01, 01).unwrap()) + // .prompt() + // .unwrap(); - // let date_query = ComparisonFunction::new( - // "<=", - // Query::from_field(PostingField::Transaction(TransactionField::Date)), - // Query::from(DataValue::from(NaiveDate::from_ymd_opt(2100, 01, 01).unwrap())), - // ).unwrap(); - // let account_query = SubAccountFunction::new( - // "Assets".into(), - // base::Query::from_field(PostingField::Account(AccountField::Name)), - // ); + // stdout.execute(MoveToPreviousLine(1)).unwrap(); + // stdout.execute(terminal::Clear(terminal::ClearType::FromCursorDown)).unwrap(); - // let total_query = LogicalFunction::new( - // "and", - // base::Query::from_fn(date_query), - // base::Query::from_fn(account_query), - // ).unwrap(); - - // let balance = queries::balance3( - // &ledger, - // &base::Query::from_fn(total_query), - // ); - - - let t4 = Instant::now(); - - format_balance(&ledger, &balance); - - let t5 = Instant::now(); - - println!("{:?} - {:?} - {:?} - {:?}", t2-t1, t3-t2, t4-t3, t5-t4); - - // return; - - // for (account_id, amounts) in balance { - // let account = ledger.get_account(account_id).unwrap(); - // if !account.get_name().starts_with("Assets") { - // continue; - // } - // print!("{} : ", account.get_name()); - // for (i, amount) in amounts.iter().enumerate() { - // if i != 0 { - // print!(", "); - // } - // print!("{}", ledger.format_amount(amount)); - // // let unit = ledger.get_unit(amount.unit_id).unwrap(); - // // let symbol = unit.default_symbol(); - // // if symbol.is_prefix { - // // print!("{}{}", symbol.symbol, amount.value); - // // } else { - // // print!("{} {}", amount.value, symbol.symbol); - // // } - // } - // println!(); - // } - - // let ledger = create_ledger(&file_data).unwrap(); - // println!("{:?}", val); - - return Ok(()); + // // let name = Text::new("What is your name?").with_help_message("Help message").prompt(); + // println!("{:?}", date.year()); } diff --git a/src/output/amounts.rs b/src/output/amounts.rs index 83657e8..15aa347 100644 --- a/src/output/amounts.rs +++ b/src/output/amounts.rs @@ -2,8 +2,8 @@ use crate::core::{Amount, Ledger}; impl Ledger { /// Returns formatted string and position of decimal point in string - pub fn format_amount(&self, amount: &Amount) -> (String, usize) { - let unit = self.get_unit(amount.unit_id).unwrap(); + pub fn format_amount(&self, amount: &Amount) -> Result<(String, usize), ()> { + let unit = self.get_unit(amount.unit_id).ok_or(())?; let default_symbol = unit.default_symbol(); let amount = self.round_amount(&amount); @@ -16,13 +16,13 @@ impl Ledger { let value = amount.value.abs().to_string(); let mut split = value.split("."); - let mut value = split.next().unwrap() + let mut value = split.next().ok_or(())? .as_bytes() .rchunks(3) .rev() .map(std::str::from_utf8) .collect::, _>>() - .unwrap() + .or(Err(()))? .join(","); let value_decimal_pos = value.len(); @@ -33,13 +33,13 @@ impl Ledger { if default_symbol.is_prefix { let decimal_pos = sign.len() + default_symbol.symbol.len() + value_decimal_pos; - (format!("{}{}{}", sign, default_symbol.symbol, value), decimal_pos) + Ok((format!("{}{}{}", sign, default_symbol.symbol, value), decimal_pos)) } else { let decimal_pos = sign.len() + value_decimal_pos; if default_symbol.symbol.len() == 1 { - (format!("{}{}{}", sign, value, default_symbol.symbol), decimal_pos) + Ok((format!("{}{}{}", sign, value, default_symbol.symbol), decimal_pos)) } else { - (format!("{}{} {}", sign, value, default_symbol.symbol), decimal_pos) + Ok((format!("{}{} {}", sign, value, default_symbol.symbol), decimal_pos)) } } } diff --git a/src/output/cli/balance.rs b/src/output/cli/balance.rs index c3063e8..e08f46c 100644 --- a/src/output/cli/balance.rs +++ b/src/output/cli/balance.rs @@ -231,7 +231,7 @@ fn tree_to_text(tree: &BalanceTreeStr, ledger: &Ledger, base_amount_pos: usize, // } fn balance_tree_to_str_tree(tree: BalanceTree, ledger: &Ledger) -> BalanceTreeStr { - let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a)).collect()); + let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a).unwrap()).collect()); let children = tree.children.into_iter().map(|c| balance_tree_to_str_tree(c, ledger)).collect(); BalanceTreeStr{amounts, name: tree.name, children} diff --git a/src/parser/amount.rs b/src/parser/amount.rs index 98fc2e5..ea274e2 100644 --- a/src/parser/amount.rs +++ b/src/parser/amount.rs @@ -1,21 +1,26 @@ use nom::{ + IResult, Parser, branch::alt, character::complete::{none_of, one_of, space0}, combinator::{opt, recognize}, multi::many1, - sequence::{preceded, tuple}, IResult, Parser, + sequence::{preceded, tuple}, }; use rust_decimal_macros::dec; +use super::{decimal, quoted_string}; use crate::core::RawAmount; -use super::decimal; pub fn amount(input: &str) -> IResult<&str, RawAmount> { alt((suffix_amount, prefix_amount)).parse(input) } pub fn unit(input: &str) -> IResult<&str, &str> { - recognize(many1(none_of("0123456789,+-_()*/.{} \t"))).parse(input) + alt(( + quoted_string, + recognize(many1(none_of("0123456789,+-_()*/.{} \t"))), + )) + .parse(input) } /////////////// diff --git a/src/parser/document/transaction.rs b/src/parser/document/transaction.rs index c65354f..530f68a 100644 --- a/src/parser/document/transaction.rs +++ b/src/parser/document/transaction.rs @@ -114,26 +114,38 @@ fn posting(input: &str) -> IResult<&str, DirectivePosting> { amount = Some(v.0); if let Some(c) = v.1.0 { if c.1 { - cost = Some(RawCost { - amount: RawAmount { - value: c.0.amount.value / amount.as_ref().unwrap().value.abs(), - unit_symbol: c.0.amount.unit_symbol, - is_unit_prefix: c.0.amount.is_unit_prefix, - }, - date: c.0.date, - label: c.0.label, - }); + let amount = amount.as_ref().unwrap().value.abs(); + if amount.is_zero() { + // TODO: this isn't really right + cost = None; + } else { + cost = Some(RawCost { + amount: RawAmount { + value: c.0.amount.value / amount, + unit_symbol: c.0.amount.unit_symbol, + is_unit_prefix: c.0.amount.is_unit_prefix, + }, + date: c.0.date, + label: c.0.label, + }); + } } else { cost = Some(c.0); } } if let Some(p) = v.1.1 { if p.1 { - price = Some(RawAmount { - value: p.0.value / amount.as_ref().unwrap().value.abs(), - unit_symbol: p.0.unit_symbol, - is_unit_prefix: p.0.is_unit_prefix, - }); + let amount = amount.as_ref().unwrap().value.abs(); + if amount.is_zero() { + // TODO: this isn't really right + price = None; + } else { + price = Some(RawAmount { + value: p.0.value / amount, + unit_symbol: p.0.unit_symbol, + is_unit_prefix: p.0.is_unit_prefix, + }); + } } else { price = Some(p.0); } diff --git a/src/query/balance.rs b/src/query/balance.rs index e292be3..bd4eb15 100644 --- a/src/query/balance.rs +++ b/src/query/balance.rs @@ -10,7 +10,7 @@ use crate::core::{Amount, Ledger}; pub fn balance( ledger: &Ledger, - filter: Option<&Query>, + filter: Option>, convert_to_unit: Option<(&str, NaiveDate)>, ) -> HashMap> { let convert_to_unit = convert_to_unit.map(|u| (ledger.get_unit_by_symbol(u.0).unwrap(), u.1)); @@ -29,7 +29,7 @@ pub fn balance( let filter = match filter { Some(filter) => filter, - None => &Query::Value(true.into()), + None => Query::Value(true.into()), }; let filtered_postings = postings.filter(|data| {