use crate::ImportResult;
use crate::csv_importer::CsvImporter;
use anyhow::{Context, Result};
use format_num_pattern::{Locale, NumberFormat, NumberSymbols, core::parse_sym, fmt_to, parse_fmt};
use rust_decimal::Decimal;
use std::{fmt::Display, ops::Neg, path::Path};
#[derive(Debug, Clone)]
pub struct ImporterConfig {
pub account: String,
pub currency: Option<String>,
pub amount_format: AmountFormat,
pub importer_type: ImporterType,
}
#[derive(Debug, Clone)]
pub enum ImporterType {
Csv(CsvConfig),
}
#[derive(Debug, Clone)]
pub struct CsvConfig {
pub date_column: ColumnSpec,
pub date_format: String,
pub narration_column: Option<ColumnSpec>,
pub payee_column: Option<ColumnSpec>,
pub amount_column: Option<ColumnSpec>,
pub amount_locale: Option<Locale>,
pub amount_format: Option<String>,
pub debit_column: Option<ColumnSpec>,
pub credit_column: Option<ColumnSpec>,
pub has_header: bool,
pub delimiter: char,
pub skip_rows: usize,
pub invert_sign: bool,
pub default_expense: Option<String>,
pub default_income: Option<String>,
pub mappings: Vec<(String, String)>,
}
impl Default for CsvConfig {
fn default() -> Self {
Self {
date_column: ColumnSpec::Name("Date".to_string()),
date_format: "%Y-%m-%d".to_string(),
narration_column: Some(ColumnSpec::Name("Description".to_string())),
payee_column: None,
amount_column: Some(ColumnSpec::Name("Amount".to_string())),
amount_locale: Some(Locale::POSIX),
amount_format: None,
debit_column: None,
credit_column: None,
has_header: true,
delimiter: ',',
skip_rows: 0,
invert_sign: false,
default_expense: None,
default_income: None,
mappings: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub enum ColumnSpec {
Name(String),
Index(usize),
}
#[derive(Debug, Clone)]
pub enum AmountFormat {
Symbols(NumberSymbols),
Format(NumberFormat),
}
impl AmountFormat {
pub fn parse(&self, amount: &str) -> Result<Decimal> {
let value: Decimal = match self {
Self::Symbols(number_symbols) => parse_sym(amount, number_symbols)
.with_context(|| format!("unable to parse using symbols: {number_symbols:?}")),
Self::Format(number_format) => parse_fmt(amount, number_format)
.with_context(|| format!("unable to parse using given format: {number_format}")),
}?;
if amount.trim().starts_with('(') && amount.trim().ends_with(')') {
Ok(value.neg())
} else {
Ok(value)
}
}
pub const fn apply(&self, amount: Decimal) -> FormattedAmount<'_> {
FormattedAmount {
amount,
formatter: self,
}
}
fn fmt_into<W: core::fmt::Write>(&self, amount: Decimal, writer: &mut W) {
match self {
Self::Symbols(number_symbols) => fmt_to(
amount,
&NumberFormat::news("###,##0.##", *number_symbols).unwrap(),
writer,
),
Self::Format(number_format) => fmt_to(amount, number_format, writer),
}
}
}
#[derive(Debug, Clone)]
pub struct FormattedAmount<'a> {
amount: Decimal,
formatter: &'a AmountFormat,
}
impl Display for FormattedAmount<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.formatter.fmt_into(self.amount, f);
Ok(())
}
}
impl Default for AmountFormat {
fn default() -> Self {
Self::Symbols(NumberSymbols::monetary(Locale::POSIX))
}
}
impl ImporterConfig {
pub fn csv() -> CsvConfigBuilder {
CsvConfigBuilder::new()
}
pub fn extract(&self, path: &Path) -> Result<ImportResult> {
match &self.importer_type {
ImporterType::Csv(csv_config) => {
let importer = CsvImporter::new(self.clone());
importer.extract_file(path, csv_config)
}
}
}
pub fn extract_from_string(&self, content: &str) -> Result<ImportResult> {
match &self.importer_type {
ImporterType::Csv(csv_config) => {
let importer = CsvImporter::new(self.clone());
importer.extract_string(content, csv_config)
}
}
}
}
pub struct CsvConfigBuilder {
account: Option<String>,
currency: Option<String>,
config: CsvConfig,
}
impl CsvConfigBuilder {
pub fn new() -> Self {
Self {
account: None,
currency: None,
config: CsvConfig::default(),
}
}
pub fn account(mut self, account: impl Into<String>) -> Self {
self.account = Some(account.into());
self
}
pub fn currency(mut self, currency: impl Into<String>) -> Self {
self.currency = Some(currency.into());
self
}
pub fn amount_locale(mut self, locale: impl Into<Locale>) -> Self {
self.config.amount_locale = Some(locale.into());
self
}
pub fn amount_format(mut self, format: impl Into<String>) -> Self {
self.config.amount_format = Some(format.into());
self
}
pub fn date_column(mut self, name: impl Into<String>) -> Self {
self.config.date_column = ColumnSpec::Name(name.into());
self
}
pub fn date_column_index(mut self, index: usize) -> Self {
self.config.date_column = ColumnSpec::Index(index);
self
}
pub fn date_format(mut self, format: impl Into<String>) -> Self {
self.config.date_format = format.into();
self
}
pub fn narration_column(mut self, name: impl Into<String>) -> Self {
self.config.narration_column = Some(ColumnSpec::Name(name.into()));
self
}
pub fn narration_column_index(mut self, index: usize) -> Self {
self.config.narration_column = Some(ColumnSpec::Index(index));
self
}
pub fn payee_column(mut self, name: impl Into<String>) -> Self {
self.config.payee_column = Some(ColumnSpec::Name(name.into()));
self
}
pub fn payee_column_index(mut self, index: usize) -> Self {
self.config.payee_column = Some(ColumnSpec::Index(index));
self
}
pub fn amount_column(mut self, name: impl Into<String>) -> Self {
self.config.amount_column = Some(ColumnSpec::Name(name.into()));
self
}
pub fn amount_column_index(mut self, index: usize) -> Self {
self.config.amount_column = Some(ColumnSpec::Index(index));
self
}
pub fn debit_column(mut self, name: impl Into<String>) -> Self {
self.config.debit_column = Some(ColumnSpec::Name(name.into()));
self
}
pub fn credit_column(mut self, name: impl Into<String>) -> Self {
self.config.credit_column = Some(ColumnSpec::Name(name.into()));
self
}
pub const fn has_header(mut self, has_header: bool) -> Self {
self.config.has_header = has_header;
self
}
pub const fn delimiter(mut self, delimiter: char) -> Self {
self.config.delimiter = delimiter;
self
}
pub const fn skip_rows(mut self, count: usize) -> Self {
self.config.skip_rows = count;
self
}
pub const fn invert_sign(mut self, invert: bool) -> Self {
self.config.invert_sign = invert;
self
}
pub fn default_expense(mut self, account: impl Into<String>) -> Self {
self.config.default_expense = Some(account.into());
self
}
pub fn default_income(mut self, account: impl Into<String>) -> Self {
self.config.default_income = Some(account.into());
self
}
pub fn mappings(mut self, mappings: Vec<(String, String)>) -> Self {
self.config.mappings = mappings
.into_iter()
.map(|(pattern, account)| (pattern.to_lowercase(), account))
.collect();
self
}
pub fn build(self) -> Result<ImporterConfig> {
Ok(ImporterConfig {
account: self
.account
.unwrap_or_else(|| "Expenses:Unknown".to_string()),
amount_format: match (&self.config.amount_format, &self.config.amount_locale) {
(None, None) => AmountFormat::Symbols(NumberSymbols::monetary(Locale::POSIX)),
(None, Some(locale)) => AmountFormat::Symbols(NumberSymbols::monetary(*locale)),
(Some(fmt), None) => AmountFormat::Format(
NumberFormat::new(fmt).with_context(|| "invalid amount_format")?,
),
(Some(fmt), Some(locale)) => AmountFormat::Format(
NumberFormat::news(fmt, NumberSymbols::monetary(*locale))
.with_context(|| "invalid number format")?,
),
},
currency: self.currency,
importer_type: ImporterType::Csv(self.config),
})
}
}
impl Default for CsvConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_csv_config_default() {
let config = CsvConfig::default();
assert!(matches!(config.date_column, ColumnSpec::Name(ref s) if s == "Date"));
assert_eq!(config.date_format, "%Y-%m-%d");
assert!(config.narration_column.is_some());
assert!(config.payee_column.is_none());
assert!(config.amount_column.is_some());
assert!(config.has_header);
assert_eq!(config.delimiter, ',');
assert_eq!(config.skip_rows, 0);
assert!(!config.invert_sign);
}
#[test]
fn test_csv_config_builder_new() {
let builder = CsvConfigBuilder::new();
assert!(builder.account.is_none());
assert!(builder.currency.is_none());
}
#[test]
fn test_csv_config_builder_default() {
let builder = CsvConfigBuilder::default();
assert!(builder.account.is_none());
}
#[test]
fn test_csv_config_builder_account() {
let config = CsvConfigBuilder::new()
.account("Assets:Bank:Checking")
.build()
.unwrap();
assert_eq!(config.account, "Assets:Bank:Checking");
}
#[test]
fn test_csv_config_builder_default_account() {
let config = CsvConfigBuilder::new().build().unwrap();
assert_eq!(config.account, "Expenses:Unknown");
}
#[test]
fn test_csv_config_builder_currency() {
let config = CsvConfigBuilder::new().currency("EUR").build().unwrap();
assert_eq!(config.currency, Some("EUR".to_string()));
}
#[test]
fn test_csv_config_builder_date_column() {
let config = CsvConfigBuilder::new()
.date_column("TransactionDate")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(
matches!(csv_config.date_column, ColumnSpec::Name(ref s) if s == "TransactionDate")
);
}
#[test]
fn test_csv_config_builder_date_column_index() {
let config = CsvConfigBuilder::new()
.date_column_index(0)
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(csv_config.date_column, ColumnSpec::Index(0)));
}
#[test]
fn test_csv_config_builder_date_format() {
let config = CsvConfigBuilder::new()
.date_format("%m/%d/%Y")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(csv_config.date_format, "%m/%d/%Y");
}
#[test]
fn test_csv_config_builder_narration_column() {
let config = CsvConfigBuilder::new()
.narration_column("Memo")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(
matches!(csv_config.narration_column, Some(ColumnSpec::Name(ref s)) if s == "Memo")
);
}
#[test]
fn test_csv_config_builder_narration_column_index() {
let config = CsvConfigBuilder::new()
.narration_column_index(2)
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(
csv_config.narration_column,
Some(ColumnSpec::Index(2))
));
}
#[test]
fn test_csv_config_builder_payee_column() {
let config = CsvConfigBuilder::new()
.payee_column("Merchant")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(
matches!(csv_config.payee_column, Some(ColumnSpec::Name(ref s)) if s == "Merchant")
);
}
#[test]
fn test_csv_config_builder_payee_column_index() {
let config = CsvConfigBuilder::new()
.payee_column_index(3)
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(
csv_config.payee_column,
Some(ColumnSpec::Index(3))
));
}
#[test]
fn test_csv_config_builder_amount_column() {
let config = CsvConfigBuilder::new()
.amount_column("Value")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(csv_config.amount_column, Some(ColumnSpec::Name(ref s)) if s == "Value"));
}
#[test]
fn test_csv_config_builder_amount_column_index() {
let config = CsvConfigBuilder::new()
.amount_column_index(4)
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(
csv_config.amount_column,
Some(ColumnSpec::Index(4))
));
}
#[test]
fn test_csv_config_builder_debit_credit_columns() {
let config = CsvConfigBuilder::new()
.debit_column("Debit")
.credit_column("Credit")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(csv_config.debit_column, Some(ColumnSpec::Name(ref s)) if s == "Debit"));
assert!(matches!(csv_config.credit_column, Some(ColumnSpec::Name(ref s)) if s == "Credit"));
}
#[test]
fn test_csv_config_builder_has_header() {
let config = CsvConfigBuilder::new().has_header(false).build().unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(!csv_config.has_header);
}
#[test]
fn test_csv_config_builder_delimiter() {
let config = CsvConfigBuilder::new().delimiter(';').build().unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(csv_config.delimiter, ';');
}
#[test]
fn test_csv_config_builder_skip_rows() {
let config = CsvConfigBuilder::new().skip_rows(3).build().unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(csv_config.skip_rows, 3);
}
#[test]
fn test_csv_config_builder_invert_sign() {
let config = CsvConfigBuilder::new().invert_sign(true).build().unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(csv_config.invert_sign);
}
#[test]
fn test_csv_config_builder_full_chain() {
let config = CsvConfigBuilder::new()
.account("Assets:Bank:Checking")
.currency("USD")
.date_column("Date")
.date_format("%Y/%m/%d")
.narration_column("Description")
.payee_column("Payee")
.amount_column("Amount")
.has_header(true)
.delimiter(',')
.skip_rows(1)
.invert_sign(false)
.build()
.unwrap();
assert_eq!(config.account, "Assets:Bank:Checking");
assert_eq!(config.currency, Some("USD".to_string()));
let ImporterType::Csv(csv_config) = &config.importer_type;
assert!(matches!(csv_config.date_column, ColumnSpec::Name(ref s) if s == "Date"));
assert_eq!(csv_config.date_format, "%Y/%m/%d");
assert!(csv_config.narration_column.is_some());
assert!(csv_config.payee_column.is_some());
assert!(csv_config.amount_column.is_some());
assert!(csv_config.has_header);
assert_eq!(csv_config.delimiter, ',');
assert_eq!(csv_config.skip_rows, 1);
assert!(!csv_config.invert_sign);
}
#[test]
fn test_importer_config_csv() {
let builder = ImporterConfig::csv();
let config = builder.build().unwrap();
assert!(matches!(config.importer_type, ImporterType::Csv(_)));
}
#[test]
fn test_importer_config_extract_from_string() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv = "Date,Description,Amount\n2024-01-15,Test,-10.00\n";
let result = config.extract_from_string(csv).unwrap();
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_column_spec_name() {
let spec = ColumnSpec::Name("Amount".to_string());
assert!(matches!(spec, ColumnSpec::Name(ref s) if s == "Amount"));
}
#[test]
fn test_column_spec_index() {
let spec = ColumnSpec::Index(5);
assert!(matches!(spec, ColumnSpec::Index(5)));
}
}