add cli
This commit is contained in:
parent
c163d4f8d3
commit
d7bc50c267
15
Cargo.toml
15
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
|
||||
# [rust]
|
||||
# debuginfo-level = 1
|
||||
29
src/cli/balance.rs
Normal file
29
src/cli/balance.rs
Normal file
@ -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::<Vec<String>>()
|
||||
.join(" OR ");
|
||||
|
||||
let parsed_query = parser::query::<PostingField>(&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);
|
||||
}
|
||||
35
src/cli/commands.rs
Normal file
35
src/cli/commands.rs
Normal file
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
}
|
||||
62
src/cli/config.rs
Normal file
62
src/cli/config.rs
Normal file
@ -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<String, ImporterConfig>,
|
||||
pub variables: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct LedgerConfig {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ImporterConfig {
|
||||
pub template: String,
|
||||
pub column_date: Option<u32>,
|
||||
pub column_description: Option<u32>,
|
||||
pub column_amount: Option<i32>,
|
||||
pub column_unit: Option<u32>,
|
||||
pub column_price_total: Option<i32>,
|
||||
pub first_row: Option<u32>,
|
||||
pub reverse_order: Option<bool>,
|
||||
pub unit: Option<String>,
|
||||
pub date_format: Option<String>,
|
||||
pub mapping_file: Option<String>,
|
||||
pub output_file: String,
|
||||
}
|
||||
|
||||
pub fn read_config(file_path: &Path) -> Result<Config, CoreError> {
|
||||
let config_file_data =
|
||||
std::fs::read_to_string(file_path).map_err(|e| CoreError::from(e.to_string()))?;
|
||||
let mut config: Result<Config, CoreError> =
|
||||
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
|
||||
}
|
||||
98
src/cli/fmt.rs
Normal file
98
src/cli/fmt.rs
Normal file
@ -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<usize, ()> {
|
||||
match self {
|
||||
Variant::Int(val) => (*val).try_into().map_err(|_| ()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
620
src/cli/importer.rs
Normal file
620
src/cli/importer.rs
Normal file
@ -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<String>,
|
||||
payee: Option<String>,
|
||||
narration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Accounts {
|
||||
accounts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Payees {
|
||||
payees: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Narrations {
|
||||
narrations: Vec<String>,
|
||||
}
|
||||
|
||||
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<Result<csv::StringRecord, csv::Error>> = 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<Mapping>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
price_total: Option<Decimal>,
|
||||
account: String,
|
||||
payee: Option<String>,
|
||||
narration: Option<String>,
|
||||
) -> Result<String, ()> {
|
||||
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<Mapping> {
|
||||
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::<Mapping>()
|
||||
.map(|v| v.unwrap())
|
||||
.collect();
|
||||
// Reverse to check newest first
|
||||
mappings.reverse();
|
||||
mappings
|
||||
}
|
||||
|
||||
fn add_mapping(new_mapping: Mapping, mappings: &mut Vec<Mapping>, 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<File> = 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<Mapping>,
|
||||
) -> 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<Vec<String>, 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<String>,
|
||||
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
|
||||
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<Vec<String>, 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<String>,
|
||||
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
|
||||
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<Vec<String>, 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<String>,
|
||||
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
|
||||
let suggestions = self.get_suggestions(input);
|
||||
if highlighted_suggestion.is_none() {
|
||||
Ok(suggestions?.get(0).cloned())
|
||||
} else {
|
||||
Ok(highlighted_suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/cli/mod.rs
Normal file
37
src/cli/mod.rs
Normal file
@ -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(())
|
||||
}
|
||||
@ -77,7 +77,8 @@ impl Unit {
|
||||
}
|
||||
|
||||
pub fn get_scale(&self) -> Option<u32> {
|
||||
self.scale
|
||||
// TODO: probably don't hard code 8 here
|
||||
self.scale.or(Some(8))
|
||||
}
|
||||
|
||||
pub fn get_symbols(&self) -> &Vec<UnitSymbol> {
|
||||
|
||||
@ -60,6 +60,10 @@ impl Ledger {
|
||||
&self.transactions
|
||||
}
|
||||
|
||||
pub fn get_prices(&self) -> &Vec<Price> {
|
||||
&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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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<Amount>,
|
||||
@ -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);
|
||||
|
||||
@ -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<Ledger, CoreError> {
|
||||
pub fn get_balance_count(&self) -> usize {
|
||||
self.directives.balances.len()
|
||||
}
|
||||
|
||||
pub fn generate_ledger(&self, check_balance: bool) -> Result<Ledger, CoreError> {
|
||||
let mut ledger = Ledger::new();
|
||||
|
||||
for commodity in &self.directives.commodities {
|
||||
@ -45,11 +53,13 @@ impl Document {
|
||||
ledger.sort_prices();
|
||||
|
||||
let start = Instant::now();
|
||||
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 {
|
||||
|
||||
187
src/main.backup.rs
Normal file
187
src/main.backup.rs
Normal file
@ -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<dyn std::error::Error>> {
|
||||
// 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::<PostingField>(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(());
|
||||
}
|
||||
227
src/main.rs
227
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<dyn std::error::Error>> {
|
||||
// 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::<PostingField>(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(());
|
||||
// let a = "{abc} def".format(args);
|
||||
|
||||
// let mut stdout = stdout();
|
||||
|
||||
// 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();
|
||||
|
||||
// stdout.execute(MoveToPreviousLine(1)).unwrap();
|
||||
// stdout.execute(terminal::Clear(terminal::ClearType::FromCursorDown)).unwrap();
|
||||
|
||||
// // let name = Text::new("What is your name?").with_help_message("Help message").prompt();
|
||||
// println!("{:?}", date.year());
|
||||
}
|
||||
|
||||
@ -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::<Result<Vec<&str>, _>>()
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
///////////////
|
||||
|
||||
@ -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 {
|
||||
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.as_ref().unwrap().value.abs(),
|
||||
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 {
|
||||
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.as_ref().unwrap().value.abs(),
|
||||
value: p.0.value / amount,
|
||||
unit_symbol: p.0.unit_symbol,
|
||||
is_unit_prefix: p.0.is_unit_prefix,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
price = Some(p.0);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ use crate::core::{Amount, Ledger};
|
||||
|
||||
pub fn balance(
|
||||
ledger: &Ledger,
|
||||
filter: Option<&Query<PostingField>>,
|
||||
filter: Option<Query<PostingField>>,
|
||||
convert_to_unit: Option<(&str, NaiveDate)>,
|
||||
) -> HashMap<u32, Vec<Amount>> {
|
||||
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| {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user